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/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/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/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/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/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/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/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/16045-spec-declaration-text-snapshots.md b/.changeset/16045-spec-declaration-text-snapshots.md deleted file mode 100644 index deae247feef..00000000000 --- a/.changeset/16045-spec-declaration-text-snapshots.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`api-surface-declarations/.txt` — every export of every published entry point now ships a readable pin of the `.d.ts` declaration text the packed build actually emits for it, and the 27-entry `api-surface-signatures.json` hash it subsumes is retired (#16045). - -`Clause-②: yes (widening)` - -Until now this package pinned its public surface on one axis. `api-surface/.json` records each export as `name (kind)` — 5336 rows across 17 entry points, re-derived on the landing tree — and a signature change, a renamed interface field and a dropped union member move **none** of them. The only shape pin was `api-surface-signatures.json`: 27 rows, 0.5% of the surface, and reference-level even there, because it hashed `checker.typeToString()`, which prints `z.input` without expanding it. A breaking shape change to a ratified public type could pass every witness green. - -- **Text, ⛔ not a hash, deliberately.** A digest answers "did the bytes move" with one opaque bit whose known failure at scale is that a red one gets *accepted* rather than investigated. Each shard holds one block per declaration — `// ── Name (kind) ──` followed by the declaration verbatim — so a diff names the export and shows the change, and the existing review discipline is what guards it. -- **The input is the packed `.d.ts` reached through the `exports` map**, i.e. the declarations a consumer installs, never `src/`. Two of the manifest's 19 `exports` entries are asset subpaths with no declaration (`./openapi.json`, `./package.json`), which is why this artifact and `api-surface/` both hold 17 shards. -- **What it costs, measured on the landing tree**: 12,661,943 bytes (12.08 MiB) of text across 17 shards, 237,706 lines, 1.02 MiB gzipped against this package's ~17.6 MiB compressed `dist`. The skew is extreme — the median declaration is 81 bytes and the 20 largest hold ~65% of the bytes, because a Zod schema's packed declaration is its fully expanded structural type. That expansion is exactly what makes an inner field rename visible; it also means four declarations exceed 20,000 lines each. -- **Leading TSDoc is excluded**, so a re-worded `.describe()` does not churn this artifact — documentation drift stays `check:docs`'s axis. -- **The retirement is a strict superset, proven before it landed**: all 27 factory names resolve to a declaration block in `api-surface-declarations/root.txt`, 0 missing. For those 27 declarations text and hash discriminate the same amount (both print a type reference); what is *gained* is the 5309 other declarations, including the schemas those factories point at, whose expanded blocks are where an inner-key narrowing shows up. Nothing published read the retired file: it was not in this package's `files[]`. -- **Sharded per entry point from day one**, for the reason `api-surface/` is: the merge queue rebuilds server-side where no custom merge driver runs, so two PRs sharing one generated file evict the second. - -Regenerate with `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec gen:api-surface-declarations`; `check:api-surface-declarations` names that command when it fails. It reads the built dist, so a missing or stale one is a hard refusal in both modes rather than a green run over nothing. 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/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/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/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/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 e9d090c5b97..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: `api-surface-declarations/ui.txt` gains the seven members on `ElementTextPropsSchema` and on `ComponentPropsMap['element:text']`, and 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/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/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/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/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/17393-view-row-ceiling.md b/.changeset/17393-view-row-ceiling.md deleted file mode 100644 index 675b2c05b0f..00000000000 --- a/.changeset/17393-view-row-ceiling.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Gallery, kanban and timeline view configs declare an author-settable row ceiling. - -`GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a -`limit` member — a positive integer, default **100** — saying how many records the -view fetches. The default is APPLIED by the schema rather than only described, and -the key's own text states the other half of the contract: when the ceiling applies, -the renderer must show a visible truncation signal, because a bounded view that -looks complete is worse than an unbounded one. `DEFAULT_VIEW_ROW_LIMIT` is exported -so a consumer reads that number instead of re-declaring it. - -The knob belongs in the protocol because two renderers already cap by author choice -off keys the protocol never declared: objectui's kanban board fetches -`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` with `limit` declared in -`@object-ui/types` alone, its timeline does the same off a component props -interface, and its gallery caps not at all. `limit` is the name those consumers -already read, so this declaration absorbs the consumer-local keys instead of -introducing a second spelling of one concept. - -Nothing is removed, renamed or narrowed, and no document that parsed before is -refused now. Two things to know when upgrading: - -- a parsed gallery / kanban / timeline config carries `limit: 100` where the author - wrote no ceiling, so code that compares a parsed config against a literal object - sees the new member; -- `KanbanConfigParsed` is now declared (ADR-0122) because that schema has two shapes - for the first time; `KanbanConfig` is unchanged and remains the author state. - -The non-grid four — gantt, calendar, map and tree — are deliberately untouched: -their rows stay bounded by a platform ceiling the renderer owns, because a gantt's -range, a map's camera fit and a tree's parent chain are computed over the whole set. - -Clause-②: yes (widening) 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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 9ef401a8f51..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 deliberately NOT declared**, and stays refused by name on all three. Its read is `perms.can(objectName, name)`, whose second parameter is this package's own closed `PermissionActionSchema` enum, not the ADR-0066 capability set that name means on `action`, `app`, `field` and `bulkAction`. Measured on both shipped permission providers: under the backend-backed one an unmapped name falls through to the object's `allowRead` bit, so a capability the caller does not hold passes for every reader; under the role-based one the same name is denied for everyone whenever the object carries a permission config. Declaring it would mint the ADR-0049 fail-open access gate retired from `app.areas[].requiredPermissions` in 17.0.0. The exit is a ruling, not an omission. - -⚠️ **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/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/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/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/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/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/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/18582-connector-analytics-cube-liveness-ledgers.md b/.changeset/18582-connector-analytics-cube-liveness-ledgers.md deleted file mode 100644 index 8422f3f3078..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 `ConnectorProviderContext` fields plus `name` and `enabled`; `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`: refused outright by ADR-0097 §3, never ignored. -- **`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 2bd6cbd7a2c..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` writes the registry row's `enabled` from `enableOnInstall ?? true` (#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: "honoured at POST /api/v1/packages: the installed row's `enabled` is written from this key". 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 default, same meaning, restated on the in-process protocol primitive `ObjectStackProtocol.installPackage`. Its published description now records that this layer does not read it: the implementation reads `manifest` and `settings` only, 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`, `api-surface-declarations`, `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/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/18677-validate-per-package-authoring-pass.md b/.changeset/18677-validate-per-package-authoring-pass.md deleted file mode 100644 index 53b968962fe..00000000000 --- a/.changeset/18677-validate-per-package-authoring-pass.md +++ /dev/null @@ -1,22 +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 "exactly the set the union could not see", so 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, which means **nothing that builds today stops validating**. No newly-refused input could be exhibited on any fixture — across the repo's own two-package example and three constructed variants the observable change is advisory-only, because `packageBodyAsStack` hands each package the artifact's whole `packages[]` as resolution context and the reference-integrity suite resolves object names through it. Graded `minor` rather than `patch` for the new observable step line, the new advisories and the newly reachable non-zero exit; ⛔ **not** declared breaking, because the narrowing could not be exhibited and is bounded by an existing gate. - -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/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/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 acd6d6207e6..00000000000 --- a/.changeset/18778-lint-per-package-authoring-pass.md +++ /dev/null @@ -1,38 +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 — "exactly the set the union could not see", in the build command's own -words — was reported by the command that ships and invisible on the fastest of -the three doors. 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/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/18931-me-permissions-unrestricted-export-annotation.md b/.changeset/18931-me-permissions-unrestricted-export-annotation.md deleted file mode 100644 index 38bfb50fcbe..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 now applies annotate's own predicate**: resolve with the export slot annotate will read for the entry being seeded, and skip only an object that is unrestricted **and** keeps `export`. A seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser` does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to the same wildcard bit the seed read — the two passes cannot diverge again. -- **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 `true` — 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/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/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 3ed4a3fe8b3..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 is still skipped, because for it the client's default-allow path is already right. - -⚠️ **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 084dc47715c..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**, and the generated declaration baseline that reproduces one of them. - -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`.** Two entries of this package's `files[]` move. `api-surface-declarations/` ships, and the member docblock sits *inside* the `ResolveApiOptions` interface body, so it is part of the declaration text that artifact records (leading TSDoc is excluded; an interior member's is not) — `check:api-surface-declarations` reported the shard stale as `~ ResolveApiOptions (interface) (declaration text changed)`, 0 removed, 0 added, 1 reshaped, and the regenerated `data.txt` carries the new text. `dist/` ships too, and the packed `dist/data/index.d.ts` carries it. A consumer reading either one 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/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/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 edba671e8aa..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` and `check:api-surface-declarations` are 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/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/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/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/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/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/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/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-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/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 13881152eed..00000000000 --- a/.changeset/email-template-locale-floor.md +++ /dev/null @@ -1,24 +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 retries exactly one rung — -the literal `en-US`. 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 — and it -validated, built and installed clean. - -- `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the - single literal `en-US` rung, 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 resolver's sole retry rung. -- `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/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 37edaaed6fb..00000000000 --- a/.changeset/flow-edge-condition-evaluated-slot.md +++ /dev/null @@ -1,108 +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. -- **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 /flows`; 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 refusal above, which this change tightens but does not type. 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/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 33fd97c8e46..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`; `update` is unchanged. 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/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/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/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/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/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-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/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.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-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 2036027c131..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` 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-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/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-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-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/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-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/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/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 04b848ed3c0..c837fccd5b4 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -74,7 +74,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 @@ -92,7 +92,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 @@ -143,7 +143,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 ``` @@ -161,7 +161,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..2a3396fb8ec 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-20). - [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 59f4220ba07..35fde020ef7 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -51,7 +51,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..8639a913ce4 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,244 @@ # @objectstack/example-crm +## 4.0.97 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..22c509e07f3 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,223 @@ # @objectstack/example-multi-package +## 0.0.4 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..5344a37f5d8 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,266 @@ # @objectstack/example-showcase +## 0.3.19 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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/service-datasource@17.5.0 + - @objectstack/connector-mcp@17.5.0 + - @objectstack/connector-openapi@17.5.0 + - @objectstack/connector-rest@17.5.0 + - @objectstack/connector-slack@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..f6b08acfec4 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,294 @@ # @objectstack/example-todo +## 4.0.97 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [cea85fd] +- Updated dependencies [cb04f45] +- Updated dependencies [b6471ba] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [b9d5422] +- Updated dependencies [be7aeb8] +- Updated dependencies [c7448dc] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- 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 [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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/mcp@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..351a88fc700 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,246 @@ # @objectstack/example-embed-objectql +## 0.0.37 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [922c755] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- 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 [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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..b3ae8daf6a7 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,52 @@ # @objectstack/hono +## 17.5.0 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [7d0f911] +- 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 [4fef271] +- Updated dependencies [a484966] +- Updated dependencies [2767af8] +- Updated dependencies [215840f] +- 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..8ee7f6ad1c6 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,232 @@ # @objectstack/account +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..32c56a7ebdf 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,232 @@ # @objectstack/setup +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..e8e343d432b 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,232 @@ # @objectstack/studio +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..624df982c8d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,2539 @@ # @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. + + +- 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. +- 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 "exactly the set the union could not see", so 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, which means **nothing that builds today stops validating**. No newly-refused input could be exhibited on any fixture — across the repo's own two-package example and three constructed variants the observable change is advisory-only, because `packageBodyAsStack` hands each package the artifact's whole `packages[]` as resolution context and the reference-integrity suite resolves object names through it. Graded `minor` rather than `patch` for the new observable step line, the new advisories and the newly reachable non-zero exit; ⛔ **not** declared breaking, because the narrowing could not be exhibited and is bounded by an existing gate. + + 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 — "exactly the set the union could not see", in the build command's own + words — was reported by the command that ships and invisible on the fastest of + the three doors. 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) + + +- 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. +- 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. +- 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 [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [c8a006f] +- Updated dependencies [7843663] +- Updated dependencies [eac58c3] +- Updated dependencies [7851fa3] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [9fca8eb] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [917b87e] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [e526556] +- 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 [4f1a56b] +- 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 [146c291] +- 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 [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 [0fb6f97] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [5741ff1] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [6ec467b] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [69b5059] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [c54d8d6] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [bce5270] +- Updated dependencies [340b6dc] +- Updated dependencies [3ab1508] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- 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 [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 [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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [97466dd] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [b4b83b3] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [a43b9d0] +- Updated dependencies [4fef271] +- Updated dependencies [554e928] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [2767af8] +- Updated dependencies [021755a] +- 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 [362035c] +- Updated dependencies [d7f7e34] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [58644ad] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [841a71e] +- Updated dependencies [74fb2f7] +- Updated dependencies [5636641] +- 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 [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- 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 [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 [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 [e07eecf] +- Updated dependencies [6e3462d] +- Updated dependencies [8fe5cb8] +- Updated dependencies [362dcc3] +- Updated dependencies [31064ca] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [f89dd33] +- 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 [2bd53f1] +- Updated dependencies [e6965dd] +- 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 [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 [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 [131851f] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [7173d7d] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- 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 [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/rest@17.5.0 + - @objectstack/service-automation@17.5.0 + - @objectstack/plugin-approvals@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/metadata-protocol@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/lint@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/plugin-hono-server@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/plugin-security@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/service-queue@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/metadata-core@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/service-datasource@17.5.0 + - @objectstack/service-settings@17.5.0 + - @objectstack/trigger-schedule@17.5.0 + - @objectstack/plugin-email@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/account@17.5.0 + - @objectstack/setup@17.5.0 + - @objectstack/plugin-reports@17.5.0 + - @objectstack/plugin-webhooks@17.5.0 + - @objectstack/service-sms@17.5.0 + - @objectstack/trigger-api@17.5.0 + - @objectstack/trigger-record-change@17.5.0 + - @objectstack/plugin-pinyin-search@17.5.0 + - @objectstack/console@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d84eb3ea81..9f5d97667c3 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..41ca956553d 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,268 @@ # @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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [be7aeb8] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..83f5ee3380a 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,1028 @@ # @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. +- 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. +- 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. +- 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. +- 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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..8bf15e51e02 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,354 @@ # @objectstack/cloud-connection +## 17.5.0 + +### 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [c17ff70] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [74327d3] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..e065e00f266 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/connector-mcp +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..0204d507e32 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/connector-openapi +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..94fd1b75089 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/connector-rest +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..29237b25bbc 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/connector-slack +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..2b80d8f4244 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/console +## 17.5.0 + ## 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..f6265c750e6 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,1069 @@ # @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. + + +- 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. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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 b901e014003..29d0a09e058 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..a22434c6ee2 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,70 @@ # create-objectstack +## 17.5.0 + +### 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. +- 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 80f367d0269..32ee03a6b59 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..994758475a0 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,659 @@ # @objectstack/driver-memory +## 17.5.0 + +### Minor Changes + +- 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. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..d150b1795ef 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,254 @@ # @objectstack/driver-mongodb +## 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..046cf4fe63e 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,1016 @@ # @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. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..68e15835b9c 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,346 @@ # @objectstack/driver-sqlite-wasm +## 17.5.0 + +### Minor Changes + +- 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 + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [be5c602] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..c696bdaee26 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,506 @@ # @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. +- 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. +- 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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [be5c602] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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 571c9abb9c0..5dee92c4728 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..090f2406190 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,487 @@ # @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) +- 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. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..2629ca6a63d 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,1472 @@ # @objectstack/lint +## 17.5.0 + +### Minor Changes + +- 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. +- 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. +- 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. +- 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). +- 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` +- 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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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 9f9fedda1fd..d158d94fd86 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..065eccf96e2 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,474 @@ # @objectstack/plugin-mcp-server +## 17.5.0 + +### Minor Changes + +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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 85d4ee15de3..6e25216276f 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..9122ee5fece 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,450 @@ # @objectstack/metadata-core +## 17.5.0 + +### Minor Changes + +- 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 + +- 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. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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 fd114e45379..22beba65066 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..6f02a554262 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,15 @@ # @objectstack/metadata-fs +## 17.5.0 + +### Patch Changes + +- Updated dependencies [134b410] +- 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..9bae1efff28 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,1148 @@ # @objectstack/metadata-protocol +## 17.5.0 + +### Minor Changes + +- 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. +- 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). +- 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. +- 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`. +- 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. +- 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. +- 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). +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [c88fa2c] +- Updated dependencies [1e496f9] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [0fb6f97] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [6ec467b] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [3ab1508] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [f2044ef] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [6f8d751] +- 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 [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [a43b9d0] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [58644ad] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [8fe5cb8] +- Updated dependencies [362dcc3] +- Updated dependencies [31064ca] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [f89dd33] +- Updated dependencies [cca1dc0] +- Updated dependencies [7e05b9d] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/lint@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index ecc9c7d23c3..38d8deda1b2 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..3e4de723473 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,595 @@ # @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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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 6dac4f471b1..494bd54d895 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..5b29a6d8046 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,1267 @@ # @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. +- 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`; `update` is unchanged. 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 + +- 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. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [69b5059] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [e3b3cdd] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [58644ad] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [4062aef] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 7b2710d80d5..bdca9b8a926 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..bf8b6bb5301 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,235 @@ # @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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..7c580115610 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,717 @@ # @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. +- 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. +- 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. +- 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` and `check:api-surface-declarations` are 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). +- 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. +- 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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..2d48782c942 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,223 @@ # @objectstack/embedder-openai +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..f7fea96f7a2 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,236 @@ # @objectstack/knowledge-memory +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..a109c2e65c6 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,254 @@ # @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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..d047ccd3b2c 100644 --- a/packages/plugins/organizations/CHANGELOG.md +++ b/packages/plugins/organizations/CHANGELOG.md @@ -1,5 +1,300 @@ # @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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [45c2cf9] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..913fcd4418e 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,478 @@ # @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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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/formula@17.5.0 + - @objectstack/metadata-core@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..6c9781f623a 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,378 @@ # @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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- 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 [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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/objectql@17.5.0 + - @objectstack/metadata-core@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..96ad87c1657 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,956 @@ # 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. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [6e4024c] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [df1b275] +- Updated dependencies [1aa5026] +- Updated dependencies [879b512] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [5941246] +- Updated dependencies [564ac2f] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- 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 [e07eecf] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [dfb42c5] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..cffdac1b1e3 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,352 @@ # @objectstack/plugin-dev +## 17.5.0 + +### Patch Changes + +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [4f1a56b] +- 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 [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [c54d8d6] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- 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 [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 [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [2767af8] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [215840f] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- 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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- 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 [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 [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 [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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/objectql@17.5.0 + - @objectstack/plugin-hono-server@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..69d3236f182 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,264 @@ # @objectstack/plugin-email +## 17.5.0 + +### Patch Changes + +- 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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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 8ff131ba9cd..8916bed7938 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..79f6b8d5295 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,392 @@ # @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 + +- 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 now applies annotate's own predicate**: resolve with the export slot annotate will read for the entry being seeded, and skip only an object that is unrestricted **and** keeps `export`. A seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser` does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to the same wildcard bit the seed read — the two passes cannot diverge again. + - **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 `true` — 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 is still skipped, because for it the client's default-allow path is already right. + + ⚠️ **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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..11bc3bc2531 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,66 @@ # @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 [ada2869] +- Updated dependencies [e7ff9c2] +- Updated dependencies [75237a9] +- Updated dependencies [920f887] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [17005cc] +- Updated dependencies [5ba2ec3] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [ef67b47] +- Updated dependencies [a675ad4] +- Updated dependencies [4fef271] +- Updated dependencies [a484966] +- Updated dependencies [875e9ad] +- 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-reports/CHANGELOG.md b/packages/plugins/plugin-reports/CHANGELOG.md index ba4c503ddc5..682e4ea2ee8 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,244 @@ # @objectstack/plugin-reports +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 184649861d0..599f0a8cd07 100644 --- a/packages/plugins/plugin-reports/package.json +++ b/packages/plugins/plugin-reports/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-reports", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 02025fa8143..5c9f751c99b 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,687 @@ # @objectstack/plugin-security +## 17.5.0 + +### Minor Changes + +- 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) +- 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`; `update` is unchanged. 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`. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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/formula@17.5.0 + - @objectstack/metadata-core@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..54d7f2d1fe0 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,350 @@ # @objectstack/plugin-sharing +## 17.5.0 + +### 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [922c755] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [a675ad4] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- 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 [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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/formula@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/metadata-core@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..aa26c82eb27 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,254 @@ # @objectstack/plugin-webhooks +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [879b512] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [564ac2f] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [e07eecf] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..6efac1ecd5c 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,370 @@ # @objectstack/dogfood +## 0.0.45 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [c8a006f] +- Updated dependencies [7843663] +- Updated dependencies [7851fa3] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [9fca8eb] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [917b87e] +- Updated dependencies [482d34d] +- Updated dependencies [e526556] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [4f1a56b] +- 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 [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- 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 [9e3c485] +- Updated dependencies [5741ff1] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [c54d8d6] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- 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 [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 [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [d7f7e34] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [841a71e] +- Updated dependencies [74fb2f7] +- Updated dependencies [5636641] +- 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 [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [e66da5c] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- 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 [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 [e07eecf] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- 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 [2bd53f1] +- 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 [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 [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [6ff5b56] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [6465cc0] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [6058cb2] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/plugin-approvals@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/mcp@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-security@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/trigger-schedule@17.5.0 + - @objectstack/plugin-email@17.5.0 + - @objectstack/example-crm@4.0.97 + - @objectstack/example-multi-package@0.0.4 + - @objectstack/example-showcase@0.3.19 + - @objectstack/connector-mcp@17.5.0 + - @objectstack/connector-openapi@17.5.0 + - @objectstack/connector-rest@17.5.0 + - @objectstack/plugin-webhooks@17.5.0 + - @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..a8ddf1c713a 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,223 @@ # @objectstack/downstream-contract +## 0.0.43 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..0fe0af10636 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,31 @@ # @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 [5ba2ec3] +- Updated dependencies [fe0ae5c] +- Updated dependencies [74832b6] +- 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..5e3fa14ed67 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,1016 @@ # @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. +- 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. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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 eacac0afc67..5bb3d9543e1 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..ae65c4de0db 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,1288 @@ # @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. + + +- 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. + + +- 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` 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)`. +- 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. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- 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 [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [dc709b2] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- 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 [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [69b5059] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [c54d8d6] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- 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 [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 [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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- 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 [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [e3b3cdd] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [58644ad] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a61ae59] +- 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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- 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 [2bd53f1] +- 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 [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [4062aef] +- Updated dependencies [777d0c2] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- 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/rest@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/driver-sqlite-wasm@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 806bd2c1de3..574be8b7a53 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..e1c02999523 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,79 @@ # @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. + + + +### Patch Changes + +- 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..1d9628c042f 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,1287 @@ # 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. +- 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. +- 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. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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 376df4e580b..877a81b434c 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..029b045f596 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,1088 @@ # @objectstack/service-automation +## 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. +- 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. +- 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. +- 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [9be2b59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- Updated dependencies [e75cc3c] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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/formula@17.5.0 + - @objectstack/metadata-core@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..68b82d8a157 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,248 @@ # @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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..ab457e54e60 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,224 @@ # @objectstack/service-cluster-redis +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..336c816ba4b 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/service-cluster +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..355370994a5 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,314 @@ # @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. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [ef67b47] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..c25e0a8adef 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,254 @@ # @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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..236eec8cd3c 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,256 @@ # @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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..08607d8ebe1 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/service-knowledge +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..3ac6e155ab8 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,407 @@ # @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 +- 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 +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..da5c4271fcd 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,250 @@ # @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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..dfb85e611a1 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,280 @@ # @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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..4963bf99637 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,256 @@ # @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. +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..c86b3dce1dd 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,321 @@ # @objectstack/service-settings +## 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. +- 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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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..b6dbfc89fdf 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,250 @@ # @objectstack/service-sms +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [45c2cf9] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..bc31426b85c 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,338 @@ # @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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- 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 48ebd2808d4..8ee0ee03f2e 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,7860 @@ # @objectstack/spec +## 17.5.0 + +### Minor Changes + +- 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. + + +- 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. +- 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. +- d8b12fc: `api-surface-declarations/.txt` — every export of every published entry point now ships a readable pin of the `.d.ts` declaration text the packed build actually emits for it, and the 27-entry `api-surface-signatures.json` hash it subsumes is retired (#16045). + + `Clause-②: yes (widening)` + + Until now this package pinned its public surface on one axis. `api-surface/.json` records each export as `name (kind)` — 5336 rows across 17 entry points, re-derived on the landing tree — and a signature change, a renamed interface field and a dropped union member move **none** of them. The only shape pin was `api-surface-signatures.json`: 27 rows, 0.5% of the surface, and reference-level even there, because it hashed `checker.typeToString()`, which prints `z.input` without expanding it. A breaking shape change to a ratified public type could pass every witness green. + + - **Text, ⛔ not a hash, deliberately.** A digest answers "did the bytes move" with one opaque bit whose known failure at scale is that a red one gets *accepted* rather than investigated. Each shard holds one block per declaration — `// ── Name (kind) ──` followed by the declaration verbatim — so a diff names the export and shows the change, and the existing review discipline is what guards it. + - **The input is the packed `.d.ts` reached through the `exports` map**, i.e. the declarations a consumer installs, never `src/`. Two of the manifest's 19 `exports` entries are asset subpaths with no declaration (`./openapi.json`, `./package.json`), which is why this artifact and `api-surface/` both hold 17 shards. + - **What it costs, measured on the landing tree**: 12,661,943 bytes (12.08 MiB) of text across 17 shards, 237,706 lines, 1.02 MiB gzipped against this package's ~17.6 MiB compressed `dist`. The skew is extreme — the median declaration is 81 bytes and the 20 largest hold ~65% of the bytes, because a Zod schema's packed declaration is its fully expanded structural type. That expansion is exactly what makes an inner field rename visible; it also means four declarations exceed 20,000 lines each. + - **Leading TSDoc is excluded**, so a re-worded `.describe()` does not churn this artifact — documentation drift stays `check:docs`'s axis. + - **The retirement is a strict superset, proven before it landed**: all 27 factory names resolve to a declaration block in `api-surface-declarations/root.txt`, 0 missing. For those 27 declarations text and hash discriminate the same amount (both print a type reference); what is *gained* is the 5309 other declarations, including the schemas those factories point at, whose expanded blocks are where an inner-key narrowing shows up. Nothing published read the retired file: it was not in this package's `files[]`. + - **Sharded per entry point from day one**, for the reason `api-surface/` is: the merge queue rebuilds server-side where no custom merge driver runs, so two PRs sharing one generated file evict the second. + + Regenerate with `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec gen:api-surface-declarations`; `check:api-surface-declarations` names that command when it fails. It reads the built dist, so a missing or stale one is a hard refusal in both modes rather than a green run over nothing. +- 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) +- 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: `api-surface-declarations/ui.txt` gains the seven members on `ElementTextPropsSchema` and on `ComponentPropsMap['element:text']`, and 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. +- 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. +- 1b82c51: Gallery, kanban and timeline view configs declare an author-settable row ceiling. + + `GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a + `limit` member — a positive integer, default **100** — saying how many records the + view fetches. The default is APPLIED by the schema rather than only described, and + the key's own text states the other half of the contract: when the ceiling applies, + the renderer must show a visible truncation signal, because a bounded view that + looks complete is worse than an unbounded one. `DEFAULT_VIEW_ROW_LIMIT` is exported + so a consumer reads that number instead of re-declaring it. + + The knob belongs in the protocol because two renderers already cap by author choice + off keys the protocol never declared: objectui's kanban board fetches + `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` with `limit` declared in + `@object-ui/types` alone, its timeline does the same off a component props + interface, and its gallery caps not at all. `limit` is the name those consumers + already read, so this declaration absorbs the consumer-local keys instead of + introducing a second spelling of one concept. + + Nothing is removed, renamed or narrowed, and no document that parsed before is + refused now. Two things to know when upgrading: + + - a parsed gallery / kanban / timeline config carries `limit: 100` where the author + wrote no ceiling, so code that compares a parsed config against a literal object + sees the new member; + - `KanbanConfigParsed` is now declared (ADR-0122) because that schema has two shapes + for the first time; `KanbanConfig` is unchanged and remains the author state. + + The non-grid four — gantt, calendar, map and tree — are deliberately untouched: + their rows stay bounded by a platform ceiling the renderer owns, because a gantt's + range, a map's camera fit and a tree's parent chain are computed over the whole set. + + Clause-②: yes (widening) +- 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. +- 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. +- 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. +- 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. +- 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. +- 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). +- 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. +- 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 deliberately NOT declared**, and stays refused by name on all three. Its read is `perms.can(objectName, name)`, whose second parameter is this package's own closed `PermissionActionSchema` enum, not the ADR-0066 capability set that name means on `action`, `app`, `field` and `bulkAction`. Measured on both shipped permission providers: under the backend-backed one an unmapped name falls through to the object's `allowRead` bit, so a capability the caller does not hold passes for every reader; under the role-based one the same name is denied for everyone whenever the object carries a permission config. Declaring it would mint the ADR-0049 fail-open access gate retired from `app.areas[].requiredPermissions` in 17.0.0. The exit is a ruling, not an omission. + + ⚠️ **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. +- 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) +- 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. +- 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. +- 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` writes the registry row's `enabled` from `enableOnInstall ?? true` (#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: "honoured at POST /api/v1/packages: the installed row's `enabled` is written from this key". 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 default, same meaning, restated on the in-process protocol primitive `ObjectStackProtocol.installPackage`. Its published description now records that this layer does not read it: the implementation reads `manifest` and `settings` only, 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`, `api-surface-declarations`, `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. +- 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. + + +- 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. + + +- 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`. +- 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 +- 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` and `check:api-surface-declarations` are 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). +- 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) +- 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 retries exactly one rung — + the literal `en-US`. 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 — and it + validated, built and installed clean. + + - `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the + single literal `en-US` rung, 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 resolver's sole retry rung. + - `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. +- 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. + - **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 /flows`; 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 refusal above, which this change tightens but does not type. +- 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. + + +- 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. + +### 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. +- 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. +- 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. +- 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. +- 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: 「同意」. +- 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. +- 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. +- 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. +- 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. +- 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. +- 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. +- 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. +- 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 `ConnectorProviderContext` fields plus `name` and `enabled`; `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`: refused outright by ADR-0097 §3, never ignored. + - **`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**, and the generated declaration baseline that reproduces one of them. + + 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`.** Two entries of this package's `files[]` move. `api-surface-declarations/` ships, and the member docblock sits *inside* the `ResolveApiOptions` interface body, so it is part of the declaration text that artifact records (leading TSDoc is excluded; an interior member's is not) — `check:api-surface-declarations` reported the shard stale as `~ ResolveApiOptions (interface) (declaration text changed)`, 0 removed, 0 added, 1 reshaped, and the regenerated `data.txt` carries the new text. `dist/` ships too, and the packed `dist/data/index.d.ts` carries it. A consumer reading either one reads different bytes after this change, so the corrected sentence is what reaches them. +- 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. +- 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. +- 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. +- 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. +- 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. +- 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. +- 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`. +- 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 a8ddb4ec222..3c0db43b4d2 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..15023b43b13 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/trigger-api +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..f51cebd7528 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/plugin-trigger-record-change +## 17.5.0 + +### Patch Changes + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..5440f05bf54 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,532 @@ # @objectstack/plugin-trigger-schedule +## 17.5.0 + +### Minor Changes + +- 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 + +- Updated dependencies [863c7c4] +- Updated dependencies [0f95f43] +- Updated dependencies [825d70f] +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [758ac40] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [74832b6] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- 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 [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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..8e4be782e58 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,661 @@ # @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 [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7843663] +- Updated dependencies [ce57857] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [839d1b0] +- Updated dependencies [2fc092b] +- 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 [146c291] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [182bbde] +- Updated dependencies [24d622b] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [75237a9] +- Updated dependencies [497655f] +- Updated dependencies [3a9ad22] +- Updated dependencies [2bf6ef1] +- 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 [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [cb1f274] +- Updated dependencies [5c28cc7] +- Updated dependencies [b0eb9a5] +- Updated dependencies [e233db9] +- Updated dependencies [176b035] +- Updated dependencies [a83dbb6] +- Updated dependencies [51297e9] +- 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 [9a910c4] +- Updated dependencies [340b6dc] +- Updated dependencies [99fcb4a] +- Updated dependencies [0f1cd83] +- Updated dependencies [a3d4c59] +- Updated dependencies [1aa5026] +- Updated dependencies [b9d5422] +- Updated dependencies [c7448dc] +- Updated dependencies [627382b] +- Updated dependencies [0b31d90] +- 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 [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [5f392f0] +- Updated dependencies [a362e0e] +- Updated dependencies [f26fb8e] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [b8ec127] +- Updated dependencies [e81c4e5] +- 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 [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 [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- 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 [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- 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 [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..5de8125b919 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,512 @@ # @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 [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [7d0f911] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [c8a006f] +- Updated dependencies [7843663] +- Updated dependencies [eac58c3] +- Updated dependencies [7851fa3] +- Updated dependencies [ce57857] +- Updated dependencies [2d81e39] +- Updated dependencies [c7d4825] +- Updated dependencies [4844840] +- Updated dependencies [fe71032] +- Updated dependencies [d8b12fc] +- Updated dependencies [74eaab8] +- Updated dependencies [0b788da] +- Updated dependencies [482d34d] +- Updated dependencies [e526556] +- Updated dependencies [305e7fc] +- Updated dependencies [216b066] +- Updated dependencies [839d1b0] +- Updated dependencies [b722547] +- Updated dependencies [ee6fbd7] +- Updated dependencies [2fc092b] +- Updated dependencies [4f1a56b] +- 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 [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [d5c91dd] +- 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 [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1b82c51] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [d285bf0] +- Updated dependencies [dfeba25] +- Updated dependencies [9059a94] +- Updated dependencies [0a88a80] +- Updated dependencies [12bb672] +- Updated dependencies [97233b9] +- Updated dependencies [c199772] +- Updated dependencies [c54d8d6] +- Updated dependencies [182bbde] +- 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 [3a9ad22] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [2bf6ef1] +- Updated dependencies [c744c0a] +- 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 [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 [51297e9] +- 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 [9a910c4] +- Updated dependencies [bce5270] +- Updated dependencies [340b6dc] +- Updated dependencies [fe0ae5c] +- Updated dependencies [99fcb4a] +- 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 [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 [559041d] +- Updated dependencies [e0d0553] +- Updated dependencies [5100c42] +- Updated dependencies [596090e] +- Updated dependencies [5380daa] +- Updated dependencies [00b38d7] +- Updated dependencies [47a9002] +- Updated dependencies [7056ca5] +- Updated dependencies [5eebc9e] +- Updated dependencies [72c1640] +- Updated dependencies [5e5ec9f] +- Updated dependencies [922923b] +- Updated dependencies [97466dd] +- Updated dependencies [e6c34f6] +- Updated dependencies [062f5cd] +- Updated dependencies [5d8319f] +- Updated dependencies [43f4766] +- Updated dependencies [4fef271] +- Updated dependencies [554e928] +- Updated dependencies [8e8ea99] +- Updated dependencies [a484966] +- Updated dependencies [2767af8] +- Updated dependencies [021755a] +- Updated dependencies [dbd4744] +- Updated dependencies [14a762f] +- Updated dependencies [b146102] +- Updated dependencies [215840f] +- Updated dependencies [75c0dac] +- Updated dependencies [9bb059d] +- Updated dependencies [07c6f82] +- Updated dependencies [362035c] +- Updated dependencies [d7f7e34] +- Updated dependencies [875e9ad] +- Updated dependencies [74554a3] +- Updated dependencies [44a2332] +- Updated dependencies [15f9284] +- Updated dependencies [74fb2f7] +- 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 [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [e66da5c] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- 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 [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 [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- 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 [2bd53f1] +- Updated dependencies [e6965dd] +- 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 [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [777d0c2] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [f04be62] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [7607076] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [a49e8ae] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [f6189a4] +- Updated dependencies [7cd5874] +- Updated dependencies [a2509d7] +- 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/rest@17.5.0 + - @objectstack/service-automation@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/service-settings@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",