diff --git a/.changeset/action-doubled-redirect-refusal.md b/.changeset/action-doubled-redirect-refusal.md deleted file mode 100644 index 15c10005c1..0000000000 --- a/.changeset/action-doubled-redirect-refusal.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": patch ---- - -feat(spec,runtime): refuse the doubled post-success navigation channel on a `type: 'script'` action (#11519) - -**BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under -the repo's launch-window convention for breaking changes. - -Two independent channels could name a post-success destination for one -`type: 'script'` action: the declared `onSuccess` block (`{ navigate, openIn }`, -validated and visible in metadata) and the handler-returned `{ redirectUrl }` -convention (runtime-only). The spec ruled each surface's default in isolation -and said nothing about an action carrying both — so the renderer had to pick, -and the pick lived only in one renderer's implementation (declared `onSuccess` -wins, objectstack-ai/objectui#5933). Maintainer ruling 2026-08-24: refuse the -doubled channel; ⛔ no `precedence` contract field. - -The measured static-knowability partition: - -- **Authoring-time refine (spec):** "the handler can return `redirectUrl`" is - runtime-only in general (`target` names an opaque registry entry; - `HookBodySchema` declares no return contract) — but `opensInNewTab: true` is - a schema-visible declaration of the handler-redirect channel (its contract is - "pre-open a tab, then drive it to the handler's returned `redirectUrl`"). - A `type: 'script'` action declaring `onSuccess` beside `opensInNewTab: true` - is now **rejected at parse time**, with guidance naming both channels and the - remedy. Previously the pair parsed clean and one declaration was silently - dead at render. -- **Dispatch-seam diagnostic (runtime):** the runtime-only remainder — a - handler that actually returns `{ redirectUrl }` while the action declares - `onSuccess` — now logs a loud `[action-contract]` warning at both dispatch - surfaces (the REST `/actions` route and the MCP `run_action` bridge), naming - the action, both channels, the interim winner and the remedy. Observe-only: - the wire is untouched and the interim renderer precedence stands until the - author takes the remedy. - -Single-channel declarations are untouched and pinned byte-identically: only -`onSuccess`, only `opensInNewTab` (with or without `newTabUrl`), and -`opensInNewTab: false` beside `onSuccess` all parse exactly as before. The -corpus was measured at zero doubled producers (this repo's examples and -platform metadata, objectui metadata, and the cloud SSO handoff producers per -the #11519 measurement), so no shipped metadata is affected. - -**Migration.** An action refused by the new refine must pick its one -destination: keep `onSuccess` and drop `opensInNewTab` (and stop returning -`redirectUrl` from the handler), or keep `opensInNewTab` + the handler -redirect and drop `onSuccess`. Which channel is right is an authoring decision -the metadata cannot make for you, and zero such actions exist in any measured -corpus. - - diff --git a/.changeset/action-newtaburl-requires-opensinnewtab.md b/.changeset/action-newtaburl-requires-opensinnewtab.md deleted file mode 100644 index fb90c2e57b..0000000000 --- a/.changeset/action-newtaburl-requires-opensinnewtab.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): enforce the documented `newTabUrl` / `opensInNewTab` co-constraint on `ActionSchema` (#11842) - -**BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under -the repo's launch-window convention for breaking changes. - -`newTabUrl`'s doc has always said "Only valid together with `opensInNewTab`", -and every renderer read point agrees: objectui's pre-opened-tab wrapper reads -the key only behind `action.opensInNewTab && newTabUrl`, and no other path -reads it at all. Nothing on the refine chain enforced the pairing, so an -action declaring `newTabUrl` without `opensInNewTab: true` parsed clean and -the key was silently inert — the ADR-0078 declared-but-unenforced shape, -arriving through a documented co-constraint rather than a missing key. - -`ActionSchema` now **rejects at parse time** an action declaring `newTabUrl` -whose `opensInNewTab` is not `true`, with guidance naming the pre-opened-tab -contract and both remedies (declare the flag if a pre-opened tab is intended; -otherwise delete the inert key — behavior is unchanged either way it was -already behaving, because the lone key was never read). An explicit -`opensInNewTab: false` beside `newTabUrl` is refused too, deliberately: -unlike the #11519 doubled-channel rule, `newTabUrl` has no meaning outside -the pre-opened-tab flow, so a declared-off channel leaves the key exactly as -dead as an undeclared one. - -The legal pairing is untouched and pinned byte-identically: `opensInNewTab: -true` + `newTabUrl`, `opensInNewTab` alone, and `opensInNewTab: false` alone -all parse exactly as before. The corpus was measured at zero lone-`newTabUrl` -producers (this repo's examples and platform metadata, objectui's fixtures -and renderer read points, and the cloud SSO producers, which declare the pair -correctly — re-measured at claim per the triage requirement), so no shipped -metadata is affected. - - diff --git a/.changeset/action-param-carryover-declaration.md b/.changeset/action-param-carryover-declaration.md deleted file mode 100644 index f3a91225ee..0000000000 --- a/.changeset/action-param-carryover-declaration.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-security': patch ---- - -feat(spec): `ActionParamSchema.carryOver` — the declared carry-over param: seeded from the row, rendered as a non-editable summary, submitted verbatim (#11753 ruling, spec half; #11992) - - - -The maintainer's 2026-08-25 ruling on #11753 (recommendation A) declares ONE -carry-over contract instead of a rendering convention: a param may state, in -metadata, that its value is carried through the action dialog rather than -collected from the user. - -- `carryOver: true` — seed from the current row (`defaultFromRow: true` is - required alongside, enforced at parse time), render as a NON-EDITABLE - summary, submit VERBATIM. Unlike `visible: false` — the measured non-answer, - which omits the param from the submission entirely — a carry-over param is - always sent. -- Aliases: `readonly` / `disabled` are refused with guidance naming - `carryOver` (a field's `readonly` means write-path strip, which is exactly - the wrong half here). -- Exemplar (`@objectstack/plugin-security`): the five `clone_permission_set` - JSON facet params (`object_permissions`, `field_permissions`, - `system_permissions`, `row_level_security`, `tab_permissions`) declare it, - so the sanctioned clone path stops offering five prefilled raw-JSON - textareas an admin could hand-mangle into a clone that grants MORE than its - base. `description` stays an ordinary editable param. The send-side contract - is unchanged (#11703 pin 6 stays green). - -The objectui renderer leg (honouring the declaration in `ActionParamDialog`) -is the downstream card tracked on #11753. diff --git a/.changeset/admin-has-permission-adr0068-answer.md b/.changeset/admin-has-permission-adr0068-answer.md deleted file mode 100644 index f943583f01..0000000000 --- a/.changeset/admin-has-permission-adr0068-answer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -`POST /api/v1/auth/admin/has-permission` now answers an ObjectStack platform admin from the ADR-0068 platform-authz predicate. The vendor evaluated this permission query on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, so a genuine platform admin was answered `success: false` — indistinguishable from a plain member. The route is now shaded by an ObjectStack raw mount: a platform admin's query is evaluated against the vendor's own admin access-control statements with only the identity signal replaced (an ungranted or unknown permission still answers `false`), while anonymous callers, plain members, and every request body the vendor refuses to evaluate are delegated to the vendor unchanged, byte for byte. diff --git a/.changeset/admin-remove-user-gate-ordering.md b/.changeset/admin-remove-user-gate-ordering.md deleted file mode 100644 index 0c31ac97f9..0000000000 --- a/.changeset/admin-remove-user-gate-ordering.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(auth): authorize before the break-glass guard on `POST /api/v1/auth/admin/remove-user` (#11477) - -The break-glass last-local-credential guard is registered as a global better-auth -`hooks.before`, which runs ahead of an endpoint's own middleware. On -`/admin/remove-user` — served directly by better-auth's router, whose -`adminMiddleware` establishes only a session, with the role decision landing -later inside the vendor's handler — that ordering let the guard's lookup and its -distinctive refusal be reached by any **authenticated** caller before either -authorization layer had run. Because that refusal is target-dependent, the -refusal itself carried a per-record fact about a user the caller was not -entitled to ask about. - -`/admin/ban-user` already ran the same guard **after** authorization: #9652 -shades that path with an ObjectStack raw mount whose platform-admin gate fires -first. One guard, two routes, opposite orders, and nothing asserting either. - -`/admin/remove-user` now carries the same shading, converging the whole -`/admin/*` family on **authorization before the guard**. The mount reuses the -landed #9652 / #9653 pattern and introduces no new mechanism. - -What changes is **when** the guard decides, never **what** it decides: - -- an anonymous caller still gets `401 UNAUTHENTICATED`; -- an authenticated non-admin now gets `403 PERMISSION_DENIED` for every target, - so the guard is unreachable before authorization and its answer no longer - varies with the named user; -- a platform admin is unaffected in every respect — the mount **delegates** into - better-auth rather than re-implementing removal, so the path-keyed hook still - fires and the guard still refuses the removal of the last local password - login, and admission remains the vendor's own decision (#9969). - -An ordering pin ships with the fix so the sequence is mechanically checkable -rather than re-argued: it asserts that one authenticated non-admin naming two -different targets receives **indistinguishable** responses, and — so the pin -cannot be satisfied by deleting the guard — that an admitted platform admin -still hits the guard's refusal, and still succeeds on an ordinary user. diff --git a/.changeset/advanced-plugin-lifecycle-config-retired.md b/.changeset/advanced-plugin-lifecycle-config-retired.md deleted file mode 100644 index a58b7e0388..0000000000 --- a/.changeset/advanced-plugin-lifecycle-config-retired.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the authorable `AdvancedPluginLifecycleConfig` surface; the lifecycle classes stay as a host-driven library (#11825, ADR-0049) - - - -**BREAKING** export removal, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescription is registered -under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry -`advanced-plugin-lifecycle-config-retired` — where `os migrate meta` users -will look). - -`AdvancedPluginLifecycleConfigSchema` aggregated six lifecycle config groups — -`health`, `hotReload`, `degradation`, `updates`, `resources`, -`observability` — and NO group had a runtime reader, re-measured per group at -the retirement's base commit with positive controls: the kernel never -constructs `PluginHealthMonitor` or `HotReloadManager` (only their own unit -tests and `core/examples/phase2-integration.ts` do, passing config DIRECTLY -to the classes, never through this container); the `degradation` / `updates` -/ `resources` / `observability` keys have no implementation body at all; and -no manifest, stack collection or metadata-type binding ever embedded the -container, so no authored document could carry it. An author declaring -`health: {...}` or `rollback: { automatic: true }` got a clean parse and -NOTHING — the #3950 shape at container scale, sharpened by production-safety -vocabulary (auto-restart, zero-downtime rolling updates, automatic rollback) -an AI author reads as proof the capability exists. - -FROM → TO: - -- `AdvancedPluginLifecycleConfigSchema` / `AdvancedPluginLifecycleConfig` / - `AdvancedPluginLifecycleConfigParsed` → *(removed — no replacement - container)*. The supported lifecycle surface is the host-driven library in - `@objectstack/core`: construct `PluginHealthMonitor` and pass a - `PluginHealthCheck`, construct `HotReloadManager` and pass a - `HotReloadConfig` (the `content/docs/protocol/kernel/lifecycle.mdx` - examples, #11811, are the supported usage). -- `GracefulDegradationSchema` / `GracefulDegradation` / - `GracefulDegradationParsed` and `PluginUpdateStrategySchema` / - `PluginUpdateStrategy` / `PluginUpdateStrategyParsed` → *(removed)* — value - schemas whose only consumer was the retired container; no implementation - body exists for any of their keys. They return only via the ENFORCE route - of ADR-0049 through a new ADR — the executor first, the vocabulary second. - -One-line fix: delete the config object (it configured nothing); if you drive -the library classes yourself, keep passing `PluginHealthCheck` / -`HotReloadConfig` directly — those vocabularies survive unchanged. - -The retirement kit: - -- whole-def deletion (route 3 — no carrier key, no authored document, so no - tombstone and no D2 conversion; the #8715 `identity/ApiKey` shape): - `kernel/AdvancedPluginLifecycleConfig`, `kernel/GracefulDegradation`, - `kernel/PluginUpdateStrategy` in `RETIRED_DEFS_BY_MAJOR[18]`, plus the D3 - semantic entry `advanced-plugin-lifecycle-config-retired` -- pin test (`kernel/plugin-lifecycle-advanced-retirement.test.ts`): zero - holders for all 9 retired names on every public entry, survivors pinned - (`PluginHealthStatus` / `PluginHealthCheck` / `PluginHealthReport`, - `HotReloadConfig` + `DistributedStateConfig`, `PluginStateSnapshot` — the - kept host-driven library vocabularies) -- zero authored occurrences in objectstack or objectui (measured at - dispatch, re-verified per group), so no in-repo source changes ride along; - `@objectstack/core` classes and tests are untouched and stay green diff --git a/.changeset/aggregate-unresolvable-column-refusal.md b/.changeset/aggregate-unresolvable-column-refusal.md deleted file mode 100644 index 34c6754be1..0000000000 --- a/.changeset/aggregate-unresolvable-column-refusal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`aggregate()` now answers an unresolvable column with the same refusal class as `find()` and `count()` instead of the generic `DATABASE_ERROR`/500 terminal — the #8790 refusal reaching the third read door (#11541). The dialect-named column is attributed to the clause the caller's own query names it in: a `groupBy` field or an aggregation `field` refuses with `INVALID_FIELD`/400 naming the column and the clause (the same code the protocol ingress gives this condition, #4254); a column named by neither clause is the WHERE, which answers #8790's `INVALID_FILTER`/400 refusal verbatim; a dialect wording that yields no column name keeps the #11455 terminal envelope unchanged, because no attribution is supportable there (#8931). Drivers extending `SqlDriver` (`driver-turso`'s embedded face, `driver-sqlite-wasm`) inherit the same answers. diff --git a/.changeset/ai-manifest-cloud-only-boundary.md b/.changeset/ai-manifest-cloud-only-boundary.md deleted file mode 100644 index 567bc78682..0000000000 --- a/.changeset/ai-manifest-cloud-only-boundary.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Tell the operator in Settings → AI that `@objectstack/service-ai` has no -open-edition version to install, instead of three bare "Mount it" lines - -Configure any real LLM provider in **Settings → AI** and press *Test -connection*, and the built-in fallback handler answered — on all three -real-provider branches — "Mount `@objectstack/service-ai` to exercise live -calls." This platform's own capability roster says that cannot be done: -`PLATFORM_CAPABILITY_PROVIDERS.ai` in `@objectstack/spec/kernel` declares -`edition: 'cloud'`, which `CapabilityEdition` defines as "realized only by a -cloud runtime tier; there is **no installable version in the open edition**" — -the un-followable "add it to your dependencies" that framework#3366 exists to -make legible. The package is in no directory of this repo (0 path hits for -`/service-ai/` on `main`; `/service-settings/` returns 64 and -`/embedder-openai/` 8 under the identical command, so the zero is real). - -The instruction is **kept** — an operator may well have a cloud tier — and -gains the boundary it was missing, so one who does not can see the path is -closed to them. - -Worse than a 404, the install succeeds. Measured 2026-08-23 against the public -npm registry (unauthenticated, `@objectstack/spec` + `@objectstack/cli` as -positive controls, `@objectstack/service-ai-studio` — the sibling -`edition: 'cloud'` entry — as a negative control returning 404): -`@objectstack/service-ai` returns **200** with 57 versions, and the highest is -**10.3.0** (2026-06-23) — entirely below the 11.3.0 cut the roster note names, -i.e. the pre-cloud tail left behind on the registry. A determined operator -following the old sentence installs a seven-major-old AI runtime that -exact-pins `@objectstack/spec@10.3.0` against this repo's 17.2.0, resolving a -second spec beside this one. The new message says so, so nobody discovers it -from a dependency error. - -The boundary sentence is **read from the roster**, not hand-written a fourth -time: `note` is documented as "surfaced verbatim inside the preflight / boot -error so the message carries its own context", and `packages/cli`'s capability -preflight already interpolates it the same way. The three provider prefixes -stay distinct — only the shared trailing sentence converges. - -No behaviour change: `ok` and `severity` are untouched on every branch, and the -embedder hint at the fourth site is deliberately left alone (its package -**is** built here, so that instruction is followable as written) and pinned by -a contrast test. diff --git a/.changeset/analytics-current-user-token.md b/.changeset/analytics-current-user-token.md deleted file mode 100644 index 4d1938f7bb..0000000000 --- a/.changeset/analytics-current-user-token.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-analytics': minor ---- - -Resolve `{current_user_id}` (and every other filter placeholder) on the direct analytics query path, at parity with the list path and the dashboard dataset path. - -What changes for an app author: a widget or report whose filter says `owner: '{current_user_id}'` used to render `0` for every viewer whenever the query reached the SQL strategy — the literal text was bound into the `WHERE` and matched no row, silently. Now the same filter expression means the same thing on every surface: `AnalyticsService.query` and `generateSql` expand `where`, `timeDimensions[].dateRange`, and a registered dataset's own filter / measure filters against the requesting user before any strategy compiles, so each viewer gets their own rows. A placeholder that cannot be resolved — an unknown spelling, or `{current_user_id}` on an unauthenticated request — now refuses loudly with `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` (HTTP 400) instead of charting a plausible zero. - -This also closes a gap on the dashboard dataset door: the dataset-scope channel used to hand strategies the registry's unresolved filter copy, which was ANDed in beside the resolved one (`owner = $viewer AND owner = '{current_user_id}'`) and selected nothing. diff --git a/.changeset/analytics-dataset-query-declared-5xx-relay.md b/.changeset/analytics-dataset-query-declared-5xx-relay.md deleted file mode 100644 index 33ca8343c1..0000000000 --- a/.changeset/analytics-dataset-query-declared-5xx-relay.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `POST /analytics/dataset/query` relays a producer-declared 5xx instead of collapsing it to `500 ANALYTICS_QUERY_FAILED` (#11718) - -**Response-contract change on a shipped public route.** The door is -`POST /api/v1/analytics/dataset/query` (and its environment-scoped twin). An -error whose producer declared a 5xx `status` now reaches the client with **that -status and that ADR-0112 `code`**, where the route previously answered a -hand-built `500` `ANALYTICS_QUERY_FAILED` for every one of them. - -Measured door-to-door before the fix, one error object, both handlers driven in -process: - -| face | answer | -|---|---| -| `POST /api/v1/data/:object` | `503` — `{"error":"Internal server error","code":"SERVICE_UNAVAILABLE"}` | -| `POST /api/v1/analytics/dataset/query` | `500` — `{"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` | - -`/data` is the reference and does not move. Its relay is #5582's ruling — `502` -and `503` are `isExpectedDataStatus` lifecycle outcomes that proxies and retry -policies read differently from a `500`, so collapsing them destroys the -declaration — and that ruling never reached this route because the analytics -catch built its 5xx envelope by hand. The **sibling** analytics face -`/analytics/query` already relayed both halves through -`dispatcher-plugin.errorResponseBase`, so this door was the only one of three -overwriting a producer's declaration. - -The repair imports `/data`'s own arm rather than restating it: the branch is -lifted into `declaredServerFaultAnswer` in `error-response.ts` and read by both -doors, the same way the 4xx arm already imports `classifiedRefusalAnswer`. A -third local opinion at this boundary is how the two faces came to disagree. - -**Not a re-opening of #5352/#5367/#5811 — the prose is still withheld.** A -declared server fault's message is still replaced by the generic sentence, from -the same shared arm, and the full original text still reaches the operator: the -`logError` line runs *before* the relay branch and is unconditional, so a -producer cannot buy its way past the operator's log with a declared status. What -moves is the classification the producer declared and this route was -overwriting. - -**What callers see change:** - -- A declared `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` → `503` - `SERVICE_UNAVAILABLE` (was `500` `ANALYTICS_QUERY_FAILED`). -- An unregistered declared code demotes exactly as `/data` demotes it — `503` - `{"code":"SERVICE_UNAVAILABLE","declaredCode":"WAREHOUSE_UNAVAILABLE"}` (#9232). -- `read-scope-sql`'s ten fail-closed RLS refusals answer `500` - `READ_SCOPE_COMPILE_FAILED` instead of `500` `ANALYTICS_QUERY_FAILED`. Their - 2026-08-06 ruling is untouched in substance — still a SERVER fault, still - `500`, still with the RLS policy content withheld from the body and intact in - the log — and the code they now carry is the one they declare and the one the - sibling `/analytics/query` face has always shipped to clients. - -**Unchanged:** an *undeclared* fault. No declared status means nothing to relay, -so it keeps `500` `ANALYTICS_QUERY_FAILED` and #5667's tiering, which leaves a -self-authored fault readable. A declared **4xx** is untouched — that band is -arms ① and ①b, and the half-envelope rule (a 4xx status with no code invents no -code) still stands. diff --git a/.changeset/analytics-measure-filter-crossobject-refusal.md b/.changeset/analytics-measure-filter-crossobject-refusal.md deleted file mode 100644 index b65f7c5a32..0000000000 --- a/.changeset/analytics-measure-filter-crossobject-refusal.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -`ObjectQLStrategy` now refuses a cross-object leaf in a compiled measure's own `filter`, on both of its doors, instead of sending it to an engine that cannot join (#11461). This is the third producer of a predicate on that path — after the caller's `where` and the dataset's definition-level `filter` (#10861) — and the one `filterMemberView` did not fold in: #10413 phase 2 lowers `measureFilters[m]` onto that measure's `aggregations[].filter` entry (#10576), and the envelope check enumerated only two origins while its `query.measures` arm read each measure's resolved *field* and never its filter. - -Measured on one fixture before the change, both doors in one run: a measure declaring `filter: { 'account.region': 'West' }` on a cube with `include: ['account']` was ACCEPTED, `engine.aggregate` received `{field:"*",method:"count",alias:"west_count",filter:{"account.region":"West"}}`, and an honest evaluator answered `west_count: 0` where the truthful answer was `2` — beside a correct `total_count: 3`, so the wrong number came back wearing the same response shape as the right one. The `/analytics/sql` echo rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` carrying no join at all. Both doors now answer `INVALID_FIELD`/400 before the engine is reached, naming the offending field, the dataset, and — the locator neither sibling refusal has — the measure whose declaration holds the leaf. - -Ordinary per-measure filters are unaffected and still reach the engine carrying their own `aggregations[].filter`, and a cross-object filter declared on a measure a query does not ask for changes nothing: only the measures in `query.measures` are judged, which is exactly the set both doors lower. The same definition remains valid on a native-SQL driver, which the refusal says. diff --git a/.changeset/approval-snapshot-docstring-audit-evidence.md b/.changeset/approval-snapshot-docstring-audit-evidence.md deleted file mode 100644 index 27233643f2..0000000000 --- a/.changeset/approval-snapshot-docstring-audit-evidence.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -Correct what `sys_approval_request.payload_json` is documented to be FOR — it is audit evidence served redacted per reader, not a notification source - -The object's module docstring — which ships to consumers in the package's type -declarations — justified the snapshot column with: *"used by notifications so -they can render before the record is locked or changed."* That consumer does -not exist. Measured against every `this.notify(...)` call site in -`approval-service.ts`, all **12** of them, each passes a payload of -`{ title, message, actionUrl }` (two also carry `actions`), built from -`object_name` / `record_id` and the caller's own comment. **None** reads -`payload_json` or the parsed `payload`. - -This is more than tidiness: that sentence was the only documented -justification for the column holding a *full* row, and it was cited as such -during the #10749 consumer inventory before anyone checked it. The docstring -now states the real reason — the snapshot is retained as **audit evidence of -what was actually submitted**, so the column stays whole at rest, and is served -**redacted per reader** by the subject object's field-level read controls via -`getReadableFields`, on the approvals-inbox door and the generic data door -alike (#11039). - -The field's own `description` is deliberately unchanged: `Record snapshot at -submission time` is accurate, and it — unlike the JSDoc — is the string -extracted into the four generated i18n bundles, so no translation leaf moves -and no locale is left holding an English seed. - -Also carried in the same pass, the residual documentation the #10749 closure -assigned to the next docs touch in this lane: `payload-redaction.ts` recorded -`hidden`-vs-serialization as an **open** `packages/spec` question, and it has -since been ruled (maintainer, 2026-08-24, applying the 2026-08-12 lineage). -That paragraph now states the ruling — **`hidden: true` stays UI-only; -`internal: true` is the serialization primitive** — so an author who needs a -field kept out of read results is pointed at `internal: true` (#7728, -ADR-0049) rather than at `hidden`, which never governed serialization. - -Documentation only: no runtime behaviour, no schema field, and no public type -signature changes. diff --git a/.changeset/approver-org-scoped-team-justification.md b/.changeset/approver-org-scoped-team-justification.md deleted file mode 100644 index c2dd3d0b2e..0000000000 --- a/.changeset/approver-org-scoped-team-justification.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Docs:** `APPROVER_ORG_SCOPED`'s docblock stops justifying `team: false` with "the engine never scoped it", which the team organization screen made false (#10548). - -The docblock over `APPROVER_ORG_SCOPED` carried two clauses about `team`, and after #10230 they no longer agreed. "`sys_team_member` carries no organization column" is still true — it is still why a team's *members* are not individually placed. "the engine never scoped it" no longer described the engine: `expandTeamUsers` (`packages/plugins/plugin-approvals/src/approval-service.ts`) opens with `teamIsProvablyOutsideOrg`, which reads `sys_team`'s `organization_id` and drops the team when it names an organization other than the request's, at both of its call sites. - -The **flag value is unchanged and deliberately so**. The table answers ADR-0105 D9 *retargetability* — "does an `organization:` declaration apply to this type" — and `team` still consults no org-scoped directory, so a declaration on it still has nothing to redirect and is still rightly refused by `resolveApproverDirectoryOrg`. Only the justification had drifted, by resting on an engine behaviour that has since changed. The replacement text says the flag is about **targeting, not tenancy**: org-agnostic for retargeting because no directory is consulted, and screened to the request's organization on the team's own `organization_id` regardless. - -The risk repaired is the ordinary one for a load-bearing comment: the next reader deciding whether `team` needs an organization screen would find a spec docblock asserting the engine has none and conclude the work is outstanding when it has landed — or read the `team: false` / `manager: false` pairing as still marking "the unscreened types", which after #10153 and #10230 it does not. - -Prose only: no schema shape, no `.describe()` text, no runtime behaviour, and no authorable-surface movement. It is graded rather than skipped because the text ships to consumers on **two** surfaces — `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts`, so the docblock travels in the npm tarball as source, and unlike a property-level comment inside a `z.object({ … })` literal, a docblock over a top-level `export const` survives declaration emit: the stale sentence is present in the built `dist/automation/index.d.ts` and `dist/automation/index.d.mts`, which is what a consumer's editor surfaces on hover. diff --git a/.changeset/audience-posture-invite-only-default.md b/.changeset/audience-posture-invite-only-default.md deleted file mode 100644 index 1c4e98de32..0000000000 --- a/.changeset/audience-posture-invite-only-default.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-auth': minor -'@objectstack/verify': patch ---- - -feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` - -**BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. - -- New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. -- **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. -- `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. -- Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. -- `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. -- The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. -- `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). - - diff --git a/.changeset/auth-audience-settings-surface.md b/.changeset/auth-audience-settings-surface.md deleted file mode 100644 index 73fac60302..0000000000 --- a/.changeset/auth-audience-settings-surface.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/service-settings": minor -"@objectstack/plugin-auth": minor ---- - -feat(settings,auth): expose the audience posture in the `auth` settings namespace (#11768) - -The audience posture shipped by #11739 (`invite_only | email_domain | open`, -default `invite_only`) was switchable only from stack config at boot; a -self-host admin had no console channel. The `auth` settings namespace now -carries an `audience` group — three new authorable keys, which is what a host -sees and why this is `minor`: - -- `audience_posture` — a select over the closed vocabulary (the option table - is enforced on `setMany` and on the `OS_AUTH_AUDIENCE_POSTURE` env-override - door); -- `audience_allowed_email_domains` — newline- or comma-separated bare domains - (exact, case-insensitive matching; subdomains need their own entries); -- `audience_self_registration_permission_set` — the `sys_permission_set` name - each self-registrant receives. - -`bindAuthSettings` maps the three keys — one atomic declaration — to one -`AuthManager.applyConfigPatch({ audience })`, which replaces the whole -audience object and validates the MERGED result. Every #11739 invariant holds -through the new channel: a self-registration posture with verification -explicitly off, an empty domain list under `email_domain`, and a missing or -`admin_full_access` permission set are all refused loudly (the standing -config keeps ruling — fail closed), and off-vocabulary postures are refused, -never coerced, per the `membership_policy` precedent (#5152). Only EXPLICIT -settings values apply: the manifest defaults never mask a deployment's -boot-config declaration. Switching back to `invite_only` always applies — -leftover text in the posture-hidden sibling fields cannot make closing the -wall refusable. diff --git a/.changeset/auth-settings-bind-window-ordering.md b/.changeset/auth-settings-bind-window-ordering.md deleted file mode 100644 index 95b24f82dd..0000000000 --- a/.changeset/auth-settings-bind-window-ordering.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -Apply the workspace's SAVED auth settings at boot — `AuthPlugin` now declares -the settings ordering edge instead of reading in the pre-bind window - -`SettingsServicePlugin` registers the `settings` service in `init()` but binds -its DATA ENGINE from a `kernel:ready` hook it registers in `start()`. Between -those two moments the service is resolvable and answers reads — from an empty -in-memory fallback and the manifest defaults, with `source: 'default'` — while -the deployment's real `sys_setting` rows sit unread. Nothing distinguishes that -from "no row exists". - -`AuthPlugin` was reading inside that window. Its `start()`-registered -`kernel:ready` hooks reach `getService('settings')` at depth 3 (`runBackfill` → -`ensureAuthSettingsBound` → `bindAuthSettings`) and call -`getNamespace('auth')` in the same tick. Handlers fire in registration order, -registration order is `start()` order, and `AuthPlugin` declared -`dependencies: ['com.objectstack.engine.objectql']` and nothing about settings -— so nothing ordered it after the settings plugin. - -On the shipped composition that order was not merely unconstrained, it was -**wrong**: `os serve` does `kernel.use(new AuthPlugin(...))` before the -capability loop registers `SettingsServicePlugin`, and `resolvePluginOrder` -preserves insertion order for plugins with no edge between them. So everything -`applySettings()` derives was computed from DEFAULTS at boot — the ADR-0093 -membership policy the D6 backfill runs under, and the `google_*` social-provider -config. `settings.subscribe('auth', …)` only re-applies on a *later* change, so -a workspace that configured auth in Setup and never touched it again kept -booting with the wrong values: authored, stored, and silently not applied. - -The repair is one declaration, the same shape the three other shipped readers -(`plugin-email`, `service-sms`, `service-storage`) already carry: - -```ts -optionalDependencies = ['com.objectstack.service.settings']; -``` - -SOFT, not hard — a kernel with no settings service must still boot auth, and -`bindAuthSettings` already returns early when the service is absent. -`requiresServices` would not have done it: that asserts the service is -REGISTERED before `init()`, which it always is, and carries no `start()` -ordering. - -Enforced in both directions. `check:settings-bind-window` goes green with the -`com.objectstack.auth` entry **deleted** from its shrink-only ledger — deleting -it while the defect stood reproduces the finding, so the green is a measurement -rather than a suppression. And `auth-settings-ordering.pin.test.ts` resolves a -hostile registry that composes auth BEFORE settings, then removes the -declaration from a live instance and watches the order revert (ADR-0049: -a declaration nothing acts on is the defect, not the fix). diff --git a/.changeset/automation-toggle-deny-message.md b/.changeset/automation-toggle-deny-message.md deleted file mode 100644 index 4e71fdff5c..0000000000 --- a/.changeset/automation-toggle-deny-message.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): a refused `POST /automation/:name/toggle` is told what it attempted (#11666) - -The enablement door refuses in its own words now. A caller without -`manage_metadata` that hit `POST /api/v1/automation/:name/toggle` was answered -with the refusal the three definition writes share: - -```text -before: Authoring automation flows requires the `manage_metadata` capability. -after: Enabling or disabling an automation flow requires the `manage_metadata` capability. -``` - -They were disabling a flow, not authoring one. The sentence was accurate about -the policy — #10243's ruling classified toggle into the `manage_metadata` -authoring write set — and it named a verb the caller did not use. - -⛔ **Copy only; no policy moved.** The accept set is bit-identical: the same -callers are refused on the same four routes, `POST /` / `PUT /:name` / -`DELETE /:name` keep the shared sentence they read correctly with, and the -envelope is untouched — `PERMISSION_DENIED` / **403** on every arm, as #11660's -pins and the ADR-0112 vocabulary assert. Nothing becomes newly accepted or -newly rejected. - -Shaped on this domain's own precedent (`SCREEN_READ_DENY_MESSAGE` beside -`RUN_READ_DENY_MESSAGE`, #7968): a second constant for a second question, -rather than a reworded shared one. Rewording the shared sentence to cover both -was considered and declined — it would degrade the message for the three -definition writes in order to fix one arm. Both sentences still satisfy #7450: -each names the capability that would admit any caller, and nothing about this -one. - -A client branching on the human-readable prose of a 403 (rather than on -`error.code`) is the only thing that can notice. diff --git a/.changeset/automation-toggle-manage-metadata.md b/.changeset/automation-toggle-manage-metadata.md deleted file mode 100644 index f20b153a89..0000000000 --- a/.changeset/automation-toggle-manage-metadata.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -**BREAKING (authorization):** `POST /api/v1/automation/:name/toggle` now requires the `manage_metadata` capability. A caller that holds a session but not that capability is answered **403 `PERMISSION_DENIED`** where it previously received **200** with the flow's enablement changed. - -This narrows what the API accepts, so it ships as `minor` with the breaking surface named rather than as a `patch`. - - - -**The exact surface that moves** - -| | before | after | -|---|---|---| -| authenticated caller **with** `manage_metadata` | 200, flow toggled | 200, flow toggled — unchanged | -| authenticated caller **without** it | 200, flow toggled | **403 `PERMISSION_DENIED`**, `toggleFlow` never entered | -| anonymous caller | 401 | 401 — unchanged, the #5519 floor still answers first | -| engine self-invocation (`isSystem`) | 200 | 200 — unchanged | - -Nothing else on the domain moves. The execution doors keep their posture: `POST /:name/trigger`, the legacy `POST /trigger/:name` and `POST /:name/runs/:runId/resume` are untouched, so ordinary members can still run the flows built for them. The reads are untouched. `GET /automation/_status` still serves enablement to any authenticated caller — this change is about mutating the bit, not observing it. - -**Why enablement joined the metadata write set** - -#10145 gated the automation definition writes (`POST /`, `PUT /:name`, `DELETE /:name`) and deliberately left `toggle` out in the open, because whether disabling a flow is authoring or operating is a product call. It was filed, measured over HTTP, and ruled on 2026-08-23. - -The measurement is why "it is engine state, so leave it" did not survive: **the enabled bit is not a row, so no organization wall scopes it.** `toggleFlow(name, enabled)` writes an in-process map keyed by flow name only, `getFlowRuntimeStates()` reads that same map with no caller and no organization, and the automation service is one instance per environment. On a real, non-degraded `isolated` posture, a tenant org owner without the capability — refused 403 by `PUT /meta/:type/:name`, `POST /automation` and `DELETE /automation/:name` at the same session — switched a shipped flow off, and an unrelated tenant in a **different organization** plus the platform admin both read it off, symmetrically in both directions. Disabling a shipped flow is functionally equivalent to deleting it for as long as it stays off, and `DELETE /:name` was already gated. Mitigating but not exculpating: the override is process-local, so a cold boot reads `enabled: true` again. - -**No new capability name was minted.** The change is one arm on the existing `isFlowAuthoringWrite` predicate in `packages/runtime/src/domains/automation.ts` — the #10145 author wrote that as a single function precisely so this ruling would be one edit rather than a fourth copy of the policy. Fail-closed by construction, exactly like its three siblings: an absent `executionContext`, an absent `systemPermissions` or an empty one all refuse, and the gate runs ahead of the body checks so a refused caller learns nothing about the toggle contract. - -**Migration.** A caller that toggles flows programmatically — `client.automation.toggle(name, enabled)` — must present a principal holding `manage_metadata`; the same capability its `create` / `update` / `delete` neighbours have required since #10145. No caller of this route was found in this repo, in the Console UI (`objectstack-ai/objectui`, which posts only `/trigger` and `/resume` and merely *displays* enablement), or in the example apps, so the expected migration surface is programmatic SDK callers rather than end-user UI. diff --git a/.changeset/autorestart-covers-thrown-health-checks.md b/.changeset/autorestart-covers-thrown-health-checks.md deleted file mode 100644 index 818cc68715..0000000000 --- a/.changeset/autorestart-covers-thrown-health-checks.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/core": patch ---- - -fix(core): `autoRestart` now fires for a health check that throws or times out, not only for one that returns a failure (#11852) - -`PluginHealthMonitor.performHealthCheck` reaches its failure handling by two -disjoint routes, and only one of them could ever restart the plugin. - -A check that **returned** a failure (`false` or `{ status: 'unhealthy' }`) -incremented `failureCounters`, cleared `successCounters`, and — once -`failureThreshold` consecutive failures accumulated — consulted `autoRestart` -and restarted the plugin. A check that **threw** took a separate `catch` block -that incremented `failureCounters` and stopped there: it never cleared -`successCounters` and never read `autoRestart`. Because `raceCheckTimeout` -rejects rather than resolving, every `timeout` overrun lands in that `catch`, -so a plugin that hung was marked `failed` and never restarted no matter how -many rounds passed or what `autoRestart` said. The severer of the two failure -modes was the one that could not trigger recovery. - -Both routes now funnel into one `recordFailedRound` step that owns the -counters, the `failureThreshold` comparison and the `autoRestart` decision, so -a thrown or timed-out check is restart-eligible on exactly the same terms as a -returned failure. - -The per-route *status* label is deliberately unchanged: a throw is still the -separate `failed` status applied immediately with no threshold, as -`content/docs/protocol/kernel/lifecycle.mdx` documents. Only the counters and -the restart decision are shared — those are what `failureThreshold` and -`autoRestart` declare, and neither names a route. diff --git a/.changeset/backfill-orgless-platform-rows.md b/.changeset/backfill-orgless-platform-rows.md deleted file mode 100644 index 649624d726..0000000000 --- a/.changeset/backfill-orgless-platform-rows.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -**Ops:** a one-off, idempotent backfill for the platform rows the pre-#10101 writers stranded with no organization — dry run first (#11308). - -#10101 fixed the WRITERS: a `sys_approval_request` and a `sys_automation_run` are now stamped from the SUBJECT record's organization, with the acting context as the ruled fallback. It wrote nothing to existing rows, so the population produced before it persists — a **pending** org-less approval request LOCKS the record it is about while being invisible in every organization-scoped inbox, its own owner's included, and automation-run history stays unattributed. This is the repair for those rows, on the maintainer's 2026-08-23 ruling (direction 3). - -`packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` sweeps `sys_approval_request` (with its `sys_approval_action` / `sys_approval_approver` children, which move with their request) and `sys_automation_run`. It scans only rows whose organization column is unset, re-reads each row's subject at repair time — live record first, the write-time snapshot (`payload_json` / `context_json`'s `record`) second for a subject that has since been deleted — and stamps the platform row with the subject's own organization. - -**Dry run first, and by default.** `planPlatformRowOrganizationBackfill(engine)` reads only and returns a per-object report naming every row it would touch; `runPlatformRowOrganizationBackfill(engine, { dryRun: false })` writes. Nothing runs at boot and nothing is scheduled: this is an operator-invoked module, run once against an affected install. - -**Rows whose subject is equally org-less are counted and named, never written.** The acting-context fallback the writers apply is not available to a repair — the acting context is gone — and inventing one stays vetoed. Those ids are reported so the population is checkable and stays visible. - -**`sys_api_key`'s divergence is preserved, not flattened.** Both the column read on a subject and the column written on a platform row are resolved from the registered schema through the shared `createRecordOrganizationResolver` (`@objectstack/metadata-core`), so a platform row ABOUT an API key is repaired from that object's stamp-only `active_organization_id` (limb 0, #8778) and the credential table itself is never written to. - -**Idempotent, and asserted rather than claimed.** Every scan is `WHERE IS NULL` and every write fills that column, so a repaired row cannot match again; the test suite runs the sweep twice and pins the second run at zero writes. - -Publishes no runtime code: the module is not exported from the package index and not bundled into `dist` (`tsup` builds `src/index.ts`). It is graded rather than skipped because the release notes are where an operator of an affected install learns the repair exists, what it will and will not touch, and that the dry run comes first. diff --git a/.changeset/backfill-zero-organization-is-not-ambiguous.md b/.changeset/backfill-zero-organization-is-not-ambiguous.md deleted file mode 100644 index fd1634260f..0000000000 --- a/.changeset/backfill-zero-organization-is-not-ambiguous.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): the seed-tenancy backfill stops reporting a duplicate-minting hazard on a zero-organization first boot (#12395) - -The `#8686` split diagnostic guarded on `organizationIds.length !== 1`, which folded -two opposite conditions into one loud warning. With **several** organizations the -owner of an untenanted row is genuinely underdetermined and the warning is right. -With **none** there is no second partition at all: every object runs exactly one -`__global__` counter, so the line's claim that the named objects "run two autonumber -counters and can mint the same `unique` identifier twice" was false precisely when a -fresh install read it. (The `organizationLastValue: 0` it reported alongside is the -split probe's `LEFT JOIN` finding no second row, not a second counter at zero.) - -Zero organizations is now its own state — `no-organization-yet`, named after and -matching the 0 / 1 / several line `objectql`'s `resolveSystemWriteOrganization` -already draws — logged at `info` rather than `warn`. It is not silenced: the split -is still reported, because the observation is real even though the hazard is not. -It self-heals at the first sign-up, when the `sys_organization`-insert handoff runs -the same repair against a settled database. - -Two things this deliberately does not change. An organization probe that **failed** -still takes the loud path and now says so — an unreadable probe returns the same -empty array as a genuine zero, and reading it as "no organizations yet" is the -confusion `objectql` fixed in `#9261`. And the repair threshold is untouched: data -is still modified on exactly `organizationIds.length === 1` and nothing else. - -The affected-object list is also now described as what it is — a snapshot taken when -the probe ran. The probe runs at `kernel:ready`, which a boot can reach while an -over-budget inline seed is still writing in the background, so a first boot can name -fewer objects than the settled database holds. diff --git a/.changeset/batch-capability-and-overlay-boot-seeds.md b/.changeset/batch-capability-and-overlay-boot-seeds.md deleted file mode 100644 index 0d6e7cb5fd..0000000000 --- a/.changeset/batch-capability-and-overlay-boot-seeds.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -**Perf:** the declared-capability boot seed and the environment permission-set overlay reconciler each pay ONE batched existence read instead of one per item, and stop re-writing rows that already match (#11096, #11097). - -Both were read-then-write reconcilers over a set known in full before their loop started, and both had the shape #10946 removed from the permission-set and position seeders next door: - -- `bootstrapDeclaredCapabilities` issued a `SELECT … WHERE name = ? LIMIT 1` per declared capability, then an `UPDATE` on its own row whether or not anything had changed; -- `reconcilePermissionSetProjection` projected every environment-scope `permission` overlay in a per-name loop, each iteration issuing its own existence `SELECT` inside `upsertEnvPermissionSet` plus an unconditional `UPDATE`. - -On a local file database these loops are invisible. On the remote libsql/Turso database every hosted environment runs, each leg is its own sequential HTTP request, and the capability set is typically the largest of the identity axes — it is the union of every capability every declared package contributes, not a count bounded by the number of permission sets. - -Both now hoist one chunked `{ name: { $in: [...] } }` read out of the loop through `buildExistingByName`, which keeps the tri-state judgement that makes hoisting safe: **a read that could not ANSWER is not the answer "none of them exist"**. A batched read fails for the whole set at once, so collapsing those two would make a boot during a brief outage try to re-create everything; the seeders now decline the names they could not read, and say so. - -**The write-skip is an equality test, and the reconciliation leg is pinned.** A row whose stored value genuinely differs still gets its `UPDATE` — a reconciler that skipped writes outright would show a perfect round-trip count while silently reconciling nothing, so every counting test added here is paired one-for-one with a drift test over the same fixture, and both pairs were ablated to confirm the drift half fails when the write is removed. - -Two behaviour repairs the write-skip REQUIRED, both on the environment door — not optional polish, but corrections the equality test itself demands, verified by ablation (each one made a specific test fail when reverted): - -- **`customized` is now compared, not just written.** The flag is provenance rather than definition, so `recordDiffersFromBody` deliberately does not compare it; skipping on the facets alone would have stopped maintaining a flag the Setup list badges on and the reset action reads. It gets its own comparison term, against the same `managed_by:'package'` condition the write uses. -- **A newly created environment-authored record is no longer born badged "customized".** The INSERT used to stamp the caller's raw overlay opinion (`!!customized`) while the UPDATE branch's rule stamps `false` for a non-package row — those two disagree for any fresh `managed_by:'admin'` row created while its overlay is still active. Before this changeset, that disagreement was invisible: every boot re-wrote every record unconditionally, so the very next reconciliation pass silently overwrote the wrong value back to `false`. Once writes are equality-gated, that disagreement stops being invisible and becomes a REAL, PERMANENT one-boot-late corrective `UPDATE` after every such creation — the "steady state" round-trip count is not actually flat without this fix. Confirmed on this branch: reverting it to `!!customized` fails `#11097 — env overlay reconciliation: round trips > does not grow the steady-state round-trip count` and `#11097 — drift STILL reconciles > only the DRIFTED overlay is written` (both start seeing a real `UPDATE` on the boot immediately after any overlay-backed admin row is created). - -`projectPermissionMutation` also syncs the in-memory evaluator registry on an unchanged record, not only on a write. That sync is not a database round trip, and the evaluator resolves permission sets registry-first — gating it on "a write happened" would have left a steady-state boot enforcing the stale declared body while the record and Setup showed the overlay. - -⚠️ **This is a behaviour change beyond the write COUNT**, flagged explicitly: today, a brand-new environment-authored permission set with no package baseline can be observed `customized: true` for the one boot between its creation and the next reconciliation pass (or, on the live write-through door, self-heals within the same request). After this changeset it is never observed `true`. The change is required for the round-trip fix's own steady-state claim to hold on this path — the two are not separable — but it is a resulting-STATE change, not merely a write-count change, and is called out here for that reason. - -⚠️ **No curve number is claimed for either axis.** The hosted `bootstrap-curve.mjs` rig lives in `objectstack-ai/cloud` and neither of these axes has ever been measured on it. What is established is that the code shape is the one measured at slope 4.0000 / R² = 1.000000 on the two sibling loops in #10946, and that the round-trip COUNT is now flat in the number of declared items — which is what the new tests assert, in counts, never in wall time. diff --git a/.changeset/batch-curated-capability-existence-read.md b/.changeset/batch-curated-capability-existence-read.md deleted file mode 100644 index c26f5d8cd1..0000000000 --- a/.changeset/batch-curated-capability-existence-read.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Seed the curated platform capabilities with ONE batched existence read, and stop -rewriting rows that already match - -`bootstrapSystemCapabilities` built its whole definition set in memory and then -issued a separate `SELECT … WHERE name = ? LIMIT 1` per definition, followed by -an `UPDATE` that fired whether or not `label`/`description` had changed. On a -local file database that loop is invisible; on the remote libsql/Turso database -every hosted environment runs, each leg is its own sequential HTTP request, -competing for the same boot request budget as everything else. On a stock -installation that is 8 reads plus 8 writes, every `kernel:ready`, to store bytes -already there. - -The curated half's existence read is now one batched `$in`, and the reconcile is -equality-gated. On a steady-state rebuild the curated half costs **1 round trip** -instead of 16, and the write gate sits after the derived-ownership guard, so it -removes the redundant `UPDATE` from **both** halves. - -**The #8470 predicate travels inside the batched query, not applied to its -answer.** The curated half does not ask "is there a row with this name" — it asks -for the platform's own organization-less row (`managed_by: 'platform'` + -`organization_id: null`), and since `sys_capability.name` became unique per -ORGANIZATION those are different questions. Batching the wide question and -filtering afterwards reads every organization's row for every curated name — a -set bounded only by the number of organizations — against a page capped at one -row per name, so the page truncates, and a truncated page reads as "absent", -which inserts. Both harms are pinned as tests rather than argued: without the -predicate the shared name resolves to an organization's row, and two curated -names whose platform rows demonstrably exist come back absent. - -**An unreadable database now declines instead of guessing.** Hoisting a read out -of a loop changes what a failure means: per item a failed read fell through to an -insert the unique index refused, for that one name; batched, one failure speaks -for the whole set. `unknown` is therefore never read as "absent" — the affected -definitions are left entirely alone, counted in the new `unreadable`, and warned -once. This also retires a misdiagnosis: an unreadable database used to make this -half attempt an insert per curated name and then report a `blockedCurated` -collision for each, describing a blocking row nobody ever saw. - -`CapabilitySeedResult` gains `unchanged` and `unreadable`. Reporting "wrote -nothing because nothing differed" separately from "wrote nothing because the -writes stopped working" is what keeps the round-trip count from being satisfiable -by an implementation that simply stopped reconciling. - -**The derived half keeps its per-item read**, and not because it is the smaller -one — it is the half that grows. Its lookup is cross-organization by -construction, and `skippedAuthored` and the `platformStampedInOrg` anomaly signal -are computed from the lowest-id row installation-wide; narrowing it to the -platform bucket answers a different question and would silently reverse part of a -maintainer ruling, while batching it unnarrowed needs an unbounded read. Filed -rather than taken. - -No speedup is claimed. The hosted boot-curve rig lives in another repository and -its axes are permission sets / positions / objects, not this one. What is -established here is the round-trip count and the identity of the row each leg -reads and writes, both pinned in-repo. diff --git a/.changeset/boolean-aggregand-ruled-answers.md b/.changeset/boolean-aggregand-ruled-answers.md deleted file mode 100644 index 1996e27e6d..0000000000 --- a/.changeset/boolean-aggregand-ruled-answers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -Boolean aggregands now answer the ruled #11249 contract on every SQL dialect. On Postgres, `sum`/`avg`/`min`/`max` over a declared `boolean` field are lowered with a cast (`avg(cast("flag" as int))`) instead of reaching the server as `avg("flag")` — which PostgreSQL refuses with SQLSTATE `42883`, so those aggregations previously failed with `DATABASE_ERROR`/500. On every dialect, `min`/`max` results over a declared boolean are now presented as JSON booleans (`false`/`true`) at the driver boundary — previously MySQL (`tinyint(1)` storage) answered `0`/`1`. `sum`/`avg` answer arithmetic (`3` / `0.5` over a 3-true/3-false column); `count`/`count_distinct` are unchanged, and `min`/`max` over an empty window still answer `null`. diff --git a/.changeset/break-glass-guard-self-service-target.md b/.changeset/break-glass-guard-self-service-target.md deleted file mode 100644 index b84f1c4d19..0000000000 --- a/.changeset/break-glass-guard-self-service-target.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -`/delete-user` no longer lets a body-supplied `userId` win over the resolved actor - -`/delete-user` is the vendor's self-service delete: its contract names no -target, the subject IS the authenticated caller. The break-glass -last-local-credential guard's target resolution on that route still preferred -a body-supplied `userId` whenever one was present, so any authenticated -caller could steer the guard's own refusal at a user other than themselves. - -The guard's target on `/delete-user` is now the resolved actor unconditionally -— `body.userId` is never consulted for that route, only as a prior fallback. -`/admin/remove-user` and `/admin/ban-user` are unaffected: target-naming is -their own contract and is untouched here. For every caller acting on -themselves, nothing changes — the same lookup runs and the same outcome -(refuse the last local credential, admit everything else) is returned. diff --git a/.changeset/build-json-advisory-parity.md b/.changeset/build-json-advisory-parity.md deleted file mode 100644 index b363ac7fd9..0000000000 --- a/.changeset/build-json-advisory-parity.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Carry the capability-provider (#3366) and package-docs (ADR-0046) warnings in -the `os build --json` payload, so its `warnings` list matches -`os validate --json` on the same tree - -`os build --json` reported a strictly smaller `warnings` list than -`os validate --json` did for the same stack. #11643 closed the gap for the -undeclared-authoring-key findings; two lists were still behind it — the #3366 -installable-provider hints (an unknown capability token, or a provider that is -absent but addable with `pnpm add`) and the ADR-0046 package-docs advisories. -A CI job gating on `os build --json` therefore read an empty advisory list for -a stack that names a typo'd capability and ships a doc whose frontmatter tags -were silently dropped, while the identical job gating on `os validate --json` -read both. - -Measured over one temp project at `origin/main` `589758d22`, both commands -exiting 0: - -``` -os build ⚠ requires: "zzz_unknown_capability_token" is not a known platform capability — check for a typo. - ⚠ src/docs/advparity_guide.md: Frontmatter `tags:` … is not a list this reader understands … -os validate --json warnings: [ {doc record}, {token,message}, "No apps or plugins defined …" ] -os build --json warnings: [] ← both lists dropped -``` - -`compile.ts` computed both and then rendered them **inside** the -`if (… && !flags.json)` print blocks, which put them structurally out of reach -of the payload: computed, then discarded, for the one audience `--json` exists -to serve. This is the fourth measured instance of that shape in these two files -(#10953, #11174, #11643), and it takes the established fix — hoist the -formatting to the computation site so one list feeds both faces and they cannot -report different sets. - -**Order and shape are mirrored from `os validate --json`, not chosen here.** -That payload reads `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, -...capProviderWarnings, ...structuralWarnings]`; `os build --json` now emits -that list minus its last member. Doc advisories ride as the issue records -`collectAndLintDocs` returns and capability hints as `{ token, message }`, -which is what validate ships for each, so a consumer reads one shape per class -from either command rather than learning two. - -**No new key.** Both lists land in the `warnings` key the payload already -declared — "the whole registry's advisory set, in the shape `os validate --json` -reports", as its own comment has always said. The payload's top-level key set is -unchanged and pinned as unchanged. - -**`structuralWarnings` is deliberately not included.** `os validate` derives -four structural advisories ("No objects defined", "No apps or plugins -defined", and two manifest ones) from `collectMetadataStats`; `os compile` -calls that same helper but computes none of them, in any face. That makes it a -missing computation rather than a dropped list, and whether a command that -writes an artifact should raise them is a judgment rather than a mechanical -port. It is split out as #11896 and pinned as the only remaining residue between -the two payloads, so the question stays visible and a fifth genuinely dropped -list cannot hide in the gap. - -Text output is unchanged. diff --git a/.changeset/build-json-failure-payload-warnings.md b/.changeset/build-json-failure-payload-warnings.md deleted file mode 100644 index fa6a2b8195..0000000000 --- a/.changeset/build-json-failure-payload-warnings.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os build --json` carries the computed advisory lists on every failure exit, not the success payload alone (#11772) - -**Machine-contract widening on the `--json` failure payloads.** A consumer that -today branches on `warnings` being ABSENT from an `os build --json` failure -payload sees a different shape after this change. - -## What was wrong - -The text face prints its advisory blocks before the gates that can stop the -run — the #11529 author-time advisories at step 3b, the #3786 undeclared -authoring-key findings at 3d — and both end in `— re-run with --json for the -full list`. But `warnings` lived on the TERMINAL SUCCESS payload only (plus, -for `ruleAdvisories` alone, the author-time-rules failure). On a tree with 60 -undeclared authoring keys *and* a package-docs error: - -``` -os build Undeclared authoring keys (60) … 50 rows … - … and 10 more … — re-run with --json for the full list -os build --json {"success":false,"error":"docs validation failed","issues":[…]} - ^ the 60 keys nowhere -``` - -The remedy the notice named returned a payload that did not contain the list, -and the author could not reach the withheld entries by any route until an -unrelated later failure was fixed — the "the remedy named is unreachable" -shape of #11643 and #11391. - -## Which exits gain the field - -All nine failure exits of `os build --json`. Six already had a payload of their -own; three more were found while enumerating (the filing card's table listed -six). `warnings` is now present on every one, alongside each exit's existing -keys, which are unchanged: - -| exit (step) | existing keys | `warnings` before | after | -| --- | --- | --- | --- | -| `strict-body: missing body` (2b) | `issues` | absent | `[]` | -| protocol parse failure (3) | `errors` | absent | `[]` | -| `author-time rules failed` (3b) | `issues` | `ruleAdvisories` | unchanged | -| `capability provider preflight failed` (3c) | `issues` | absent | rule + capability | -| `access matrix drift` (3e) | `changes` | absent | rule + key + capability | -| `docs validation failed` (3f) | `issues` | absent | all four lists | -| `--no-runtime-bundle` refusal (4b) | `error` | absent | all four lists | -| `runtime bundle failed` (4b) | `error` | absent | all four lists | -| thrown / caught (bottom) | `error` | absent | what the run had computed | - -The success payload is unchanged in content: its -`[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings]` -spread — `os validate --json`'s order minus its trailing `structuralWarnings` -— moved to a single `warningsSoFar()` site that every exit now reads, so the -member order cannot drift between exits. - -## What a consumer keying off its absence should do instead - -⛔ `warnings` is no longer a signal of which exit produced the payload. Read -`success` (and `error` / `errors`) for that; a consumer that inferred "this is -a failure payload" from a missing `warnings` must switch to `success === false`. - -⛔ `warnings: []` on a failure payload does NOT mean "this tree raises no -advisories". It means **this run stopped before those advisories were -computed** — the two early exits above (`strict-body`, protocol parse) run -before any advisory step, so their list is empty by construction. A consumer -that needs the full advisory set for a tree must read it from a run that -reaches at least the gate that computes it, or from `os validate --json`. - -✅ `warnings` is always an array on every `os build --json` payload, success or -failure, so it can be read unconditionally — that shape constancy is the point -of the change (maintainer ruling 2026-08-25, option 1 of three; option 2, -"carry them only where the text face printed them", was rejected as the hardest -contract to declare). - -Advisories stay CARRIED, never recomputed: each list is still computed at -exactly the step that owns it, so an exit upstream of a step legitimately -reports that list empty and no failure path pays for a computation it did not -already do. diff --git a/.changeset/build-json-undeclared-key-parity.md b/.changeset/build-json-undeclared-key-parity.md deleted file mode 100644 index a313adf267..0000000000 --- a/.changeset/build-json-undeclared-key-parity.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Carry the undeclared-authoring-key warnings in the `os build --json` payload, -so its `warnings` list matches `os validate --json` on the same tree - -`os build --json` reported a strictly smaller `warnings` list than -`os validate --json` did for the same stack, and the missing members were -exactly the "your key was dropped at load" ones (#3786 / ADR-0087). A CI job -gating on `os build --json` therefore could not see the class of warning that -silently discards authored metadata — while the identical job gating on -`os validate --json` did. - -Measured over one temp project at `origin/main` `4ceae8ab0`, three faces of one -authored stack whose field carries an undeclared key nested in `visibleWhen`: - -``` -os build ⚠ Undeclared authoring keys (1) — dropped at load (#3786) -os validate --json warnings: [ {rule record}, "…zzzUnknownKey…" ] -os build --json warnings: [ {rule record} ] ← the dropped list -``` - -`compile.ts` computed the findings and then formatted them **inside** the -`if (… && !flags.json)` print block, which put them structurally out of reach -of the payload: computed, then discarded, for the one audience `--json` exists -to serve. `os validate --json` had this exact defect on its own face and fixed -it by mapping the findings through `formatUnknownAuthoringKey` at the -computation site; `os build` now does the same, so one list feeds both faces -and they cannot report different sets. - -**No new key.** The findings land in the `warnings` key the payload already -declared — its own comment has always said "the whole registry's advisory set, -in the shape `os validate --json` reports" — carried as formatted strings -beside the authoring-rule records, which is byte-for-byte the heterogeneous -shape `os validate --json` already ships. Consumers reading `warnings` off -either command now read one shape for one class of warning. The payload's -top-level key set is unchanged and pinned as unchanged. - -This also makes an existing promise true. The truncation notice added in -17.2.0 ends with "re-run with `--json` for the full list"; that pointer was -honest about the authoring-rule advisories and would have been false about the -undeclared-key list, which is why that change left the second list without a -notice. - -Text output is unchanged. diff --git a/.changeset/build-warning-truncation-notice.md b/.changeset/build-warning-truncation-notice.md deleted file mode 100644 index cce705900e..0000000000 --- a/.changeset/build-warning-truncation-notice.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os build` says how many author-time warnings it withheld, instead of stopping -dead at 50 - -The author-time advisory printer emitted a fixed 50 detailed entries and then -stopped, with nothing in the output saying the list had been cut. Measured on -`objectstack-ai/hotcrm` with the published 17.1.0 CLI: two `objectstack build` -runs over the same tree, before and after a five-warning fix, printed 50 -detailed entries each — 184 output lines and 52 warning lines both times — -while the summary line counted 80 and then 75. The two numbers disagreed and -nothing explained why. - -The defect is the **silence**, not the cap. Truncated output that carries no -notice is not merely incomplete, it is indistinguishable from complete: an -author who reads the report and sees their file is clean has read a list that -stopped early. Because advisories are ordered by surface (pages, then views, -then flows), a repo whose page warnings alone exceed the cap keeps every `view` -and `flow` advisory permanently invisible — and fixing warnings then makes new -ones *appear*, which reads as a regression caused by the fix. - -The cap stays, and over it the output now names the exact remainder: - -``` - ⚠ … and 30 more author-time warning(s) not shown (50 of 80) — re-run with --json for the full list -``` - -At or under the cap no such line appears, and the detail entries themselves are -byte-for-byte what they were. The pointer is `--json`, which already publishes -the whole set under `warnings` — an existing complete-output path rather than a -new flag. No new verbosity tier, no paging, no configuration surface. - -`os validate` was checked at the same time and does **not** truncate its -advisory list: it prints every warning it collected. Only the `build`/`compile` -printer had the cap. diff --git a/.changeset/builtin-column-collision-warning.md b/.changeset/builtin-column-collision-warning.md deleted file mode 100644 index 2dcc688fdd..0000000000 --- a/.changeset/builtin-column-collision-warning.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): name the storage a declaration on a builtin column name loses, instead of discarding it in silence (#12015) - -`initObjects` emits `id`, `created_at` and `updated_at` itself and then skips any -declared field colliding with one — `if (builtinColumns.has(name)) continue;`, with -no warning, no throw and no record anywhere that the author's declaration had been -dropped. Measured on live PostgreSQL 16.13: an object declaring -`id: { type: 'text' }` boots green and gets `id varchar(255)` — `table.string('id')`, -not TEXT. Measured here on SQLite: the same substitution, and a declared -`maxLength: 12` on that field binds nothing. The driver is right to own its primary -key and audit stamps; the defect was that it disagreed with the author in silence — -the declared-≠-enforced shape that bites hardest on AI-authored metadata, where the -mismatch surfaces much later as data behaving oddly. - -Every DDL path that drops such a declaration now says so, naming the field, the -object, the attributes that were lost and what the platform's column actually is: - -- **create** — `while creating table "…"`, said before the CREATE runs, so the - author hears it even when the CREATE goes on to fail for an unrelated reason; -- **ADD COLUMN diff** — `while syncing existing table "…"`; this path drops the - declaration for a different reason (the builtin is already in the table, so the - diff never proposes it), and it is the path a stock upgrade takes; -- **rotation shard** — `while syncing shard "…"`, covering both the shard-create and - shard-column-sync branches. - -A warning on one path with silence on the others just moves the trap, so each path -carries its own call and its own pin: a regression to a silent `continue` on one path -fails by name rather than being absorbed by a sibling. - -**Only the STORAGE half is reported, because only the storage half is lost.** A -declaration on a builtin column name still carries `label` (and the locales generated -from it), `readonly`, `searchable` and the ADR-0113 write contract in `required` — all -honoured on the platform's column exactly as on any other. So the diagnostic fires -only when the declaration asks for storage the platform's own column does not deliver -(a differing `type`, a `maxLength`, `unique`, `defaultValue`, `storage.notNull`, a -`multiple` shape…) and stays silent when it does not: `created_at: { type: 'datetime', -defaultValue: 'NOW()' }` describes precisely what lands, and says nothing. -`id: { type: 'number' }` — an author expecting a numeric key — still fires, as does -`id: { type: 'text' }`. The storage/presentation split is one table -(`builtin-column-collision.ts`) pinned against `FieldSchema.shape`, so a field key -added later is classified deliberately instead of defaulting into silence. - -**Grade: `patch`, and deliberately.** Nothing about the accept set moves — every -object that booted before still boots, the DDL emitted is byte-identical, no public -type or metadata key changes, and the only observable difference is a line in the log -for storage that was already being discarded. The platform still owns `id` / -`created_at` / `updated_at`: this changes what the driver **says**, never what it -**does**. diff --git a/.changeset/bulk-write-refusal-message-parity.md b/.changeset/bulk-write-refusal-message-parity.md deleted file mode 100644 index dfb6ccced0..0000000000 --- a/.changeset/bulk-write-refusal-message-parity.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -Serve a sandboxed hook's own refusal sentence on the bulk write routes, instead -of the QuickJS debug wrapper - -A hook's `throw new Error('删除被阻断…')` is a deliberate business rule, and -`classifyDataError`'s sandbox unwrap door exists precisely so the end user sees -only that sentence — the ` '' threw: ` prefix "belongs in -server logs", in the door's own words. Six write routes never reached the door. -Measured against the real route handlers: `PATCH /api/v1/data/:object/:id` -answered `Opportunity is closed.` while `POST …/batch`, `…/createMany`, -`…/updateMany`, `…/deleteMany` and `…/:id/clone` answered -`hook 'guard' threw: Error: Opportunity is closed.` — one hook, one refusal, a -different sentence depending on which route the caller happened to use. - -The branch is `resolveErrorResponse`'s declared-status passthrough, which is -checked *before* it delegates to `mapDataError` and answered its 4xx arm from -`error.message`. It now reads the business text through `sandboxBusinessMessage` -— the unwrap door's own two conditions (a non-empty string `.innerMessage`, and -not a `isScriptFaultMessage` crash) named once so the two doors ask the same -question. - -**Not a reorder.** The passthrough's own docblock argues the ordering: handing a -declared 5xx to `mapDataError` re-labels it from the message TEXT (the -overlay-delete fault comes back `404 OBJECT_NOT_FOUND` and stops being logged), -so the arm stays exactly where it is and keeps deciding the status. Only the -sentence it reads changes. #5437/#5582's unconditional 5xx prose withhold is -untouched — a sandbox refusal declaring a 5xx still answers with the generic -text, pinned on both spellings. - -What this restores is an invariant the same docblock already asserts. Its #7525 -paragraph says an error declaring `statusCode` instead falls to `mapDataError`, -"So the two doors already agree on the wire answer." For a sandbox refusal that -was false — `statusCode` was unwrapped and `status` was not — which is the -two-spellings asymmetry this card was filed on. The doors agree again, pinned -door-to-door across the whole 4xx band rather than asserted in a comment. - -**Bump level: `patch`, argued rather than defaulted.** The change is to message -TEXT on shipped routes, so the level is not automatic. It is a patch because -nothing about the envelope's contract moves: same status, same `code`, same -field set, no request newly accepted or refused. The delta is that one string -loses a debug prefix that this boundary already declares must never be on the -wire, and that the single-row routes never emitted — so no client could have -been reading it uniformly in the first place. Keying on the prefix would mean -substring-matching prose that is localised and deliberately reworded over time, -which is the practice the ADR-0112 `code` vocabulary exists to remove. - -`POST /api/v1/analytics/dataset/query` — the seventh row — needed its own -repair: it builds a `{ code, message }` envelope inline and touches neither -door. It now imports the same `sandboxBusinessMessage` rather than re-deriving -the unwrap, so the analytics face and the `/data` face cannot answer one refusal -two ways. Both of its client emissions are covered (the declared-4xx envelope -and the `500 ANALYTICS_QUERY_FAILED` fallback); `logError` still receives the -whole error and `looksLikeInternalErrorLeak` still reads the raw text, so the -operator's copy and the leak heuristic are untouched. - -`POST …/import` and `GET …/export` exit through `handleRouteError` like the bulk -routes, so they are repaired by the same change — measured rather than assumed. - -**Measured and deliberately NOT repaired here**, each recorded so it is not -rediscovered as new: the record-share routes (`…/:id/shares`, list/grant/revoke) -are a third branch again — `respondSharingError` classifies by -`message.startsWith(CODE)` and its fallback interpolates `error.message` into a -hand-built `500`, ignoring a declared `status`/`code` entirely. And on the -analytics route an *undeclared* hook refusal answers `500` where `/data` answers -`400`; only the sentence was corrected, the status disagreement is a separate -defect. Both are filed as their own issues. diff --git a/.changeset/cli-auth-command-examples-resolve.md b/.changeset/cli-auth-command-examples-resolve.md deleted file mode 100644 index d350d92842..0000000000 --- a/.changeset/cli-auth-command-examples-resolve.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `register`/`whoami`/`logout` examples no longer spell `os auth ` in live `--help` output (#11221) - -`static override examples` is printed verbatim as part of oclif's `--help`. `register.ts`, -`whoami.ts` and `logout.ts` live at the **root** of `packages/cli/src/commands/`, so oclif's -pattern-strategy loader registers them as `register` / `whoami` / `logout` — but their -`examples` spelled an `os auth ` shape that has never resolved. A user copy-pasting -straight out of `--help` hit `Error: Command auth:whoami not found.` (exit 2), the same dead -command #10927 fixed in `packages/cli/README.md` and #10967 fixed for the `environments` -topic, this time on the root auth-family commands. - -Measured against the built CLI (`packages/cli/bin/run.js`) before the fix: `os auth whoami`, -`os auth register` and `os auth logout` each exited 2 with `Error: Command auth: not -found.`, while the bare `os whoami` / `os register` / `os logout` each exited 0 and printed -help — so the examples named the one spelling that could not work. All seven `examples` -entries now say the bare, registered spelling. - -The exported default class on each file is renamed to match its real, file-path-derived -command id (`AuthRegister` → `Register`, `AuthWhoami` → `Whoami`, `AuthLogout` → `Logout`). -oclif derives a command's id purely from its file path, never from the class name, so this -changes no runtime resolution — confirmed by rebuilding the CLI and re-running `--help` on -all three. The rename also brings them onto this package's measured convention: every other -root-level command class is exactly the PascalCase of its filename. `login.ts` keeps -`AuthLogin` — its `examples` were already correct (`$ os login`), so it is outside this -card's file surface; that lone remaining class-name holdout is reported, not swept. - -`environments.test.ts`'s `#10967` pin carried a deliberately self-retiring `EXCLUDED` entry -for each of these three files, asserting the defect was *still present* so the exemption -could not outlive its cause. This fix removed the last unresolved entry, that assertion went -red exactly as designed, and the three entries are retired — the map is now empty and all -three files are scanned by the main assertion like every other command source. diff --git a/.changeset/cli-auth-config-guidance-resolves.md b/.changeset/cli-auth-config-guidance-resolves.md deleted file mode 100644 index 72afe18452..0000000000 --- a/.changeset/cli-auth-config-guidance-resolves.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): the "no stored credentials" error stops telling a stuck user to run `os auth login` (#11313) - -`readAuthConfig()` (`packages/cli/src/utils/auth-config.ts`) throws the one instruction a user -gets at the moment they are **already stuck**: they have no stored credentials, the command -they wanted has just failed, and this string is what tells them how to recover. It said -`os auth login`, which does not resolve — `login.ts` sits at the **root** of -`packages/cli/src/commands/`, so oclif's pattern-strategy loader registers it as `login`, and -no `auth` topic has ever existed. The second failure reads as "the tool is broken", not as -"a typo in a help string", which is why this is graded on its own terms rather than as another -stale-`examples` docs nit (#11221, #10967, #10927 were all `examples` arrays and README prose -— a user reading *ahead*). - -Measured against the built CLI (`packages/cli/bin/run.js`, after building the package and its -dependency closure) before the fix: `os auth login --help` → `Error: Command auth:login not -found.` (exit 2), `os login --help` → exit 0. Loading the built oclif `Config` enumerates 61 -registered ids, **zero** containing `auth`, and no `auth` topic — with `login`, `logout`, -`register`, `whoami`, `dev` and `serve` all present as the control that the zero is a real -absence rather than a broken probe. The message now says `os login`, the spelling `login.ts`'s -own `examples` already used. - -One further invocation in the same file is corrected in the same pass: -`AuthConfig.activeEnvironmentId`'s doc comment said `os projects switch`, and `projects` was -renamed to `environments` in v5.0 with no aliases (ADR-0006) — the same enumeration shows no -`projects` topic and no id containing `project`, while `environments switch` is registered. It -is fixed rather than excluded because the new pin scans the whole file, and an exclusion is how -a line stops being checked without anyone deciding to stop checking it. - -The pin (`packages/cli/src/utils/auth-config.test.ts`) asserts the **property**, not the new -spelling: every command invocation this file documents must resolve to an id the CLI actually -registers, with the id set re-derived from `src/commands/**` using oclif's own path→id -algorithm. A pin on the literal text `os login` would still pass on the day someone renames -`login.ts`; this one reds. It has two legs — the real `readAuthConfig()` driven into its real -ENOENT branch against a redirected `$HOME`, so what is checked is the message a user actually -reads, and a source-wide scan so a guidance string added to this file later is held to the same -property without anyone remembering to extend the pin. `environments.test.ts`'s `#10967` pin -reads `static override examples` via AST and structurally cannot see a thrown-error string, -which is why this class needed its own pin rather than an extension of that one. diff --git a/.changeset/cli-command-contribution-retired.md b/.changeset/cli-command-contribution-retired.md deleted file mode 100644 index 04f61d2fcf..0000000000 --- a/.changeset/cli-command-contribution-retired.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the orphan `CLICommandContributionSchema` export — the manifest surface it described is a tombstone (#12007, ADR-0049) - - - -**BREAKING** export removal, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescription is registered -under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` -(`kernel/CLICommandContribution`) + the D3 semantic entry -`cli-command-contribution-retired` — where `os migrate meta` users will -look). - -`CLICommandContributionSchema` described a "CLI Command Contribution -declaration in the manifest" and claimed to be "retained for backward -compatibility and for describing command metadata in plugin manifests" — but -after #10724 tombstoned `manifest.contributes.commands`, no manifest surface -could legally carry these entries: the exported schema advertised a shape -whose only declared carrier rejects it. The manifest never referenced this -schema even before the tombstone (its inline `commands` item schema was an -independent duplicate), and it had zero consumers outside spec's own test and -generated artifacts, measured at the retirement's base commit with positive -controls in objectstack, objectui (pinned sha) and cloud — the exported -orphan-value-schema class (#3950: an exported schema with no consumer reads -as a capability). - -FROM → TO: - -- `CLICommandContributionSchema` / `CLICommandContribution` → *(removed — no - declarative replacement, because no declarative surface ever carried it)*. - CLI commands are registered through oclif's native plugin discovery: the - plugin package declares an `oclif` section in its own `package.json` — - `OclifPluginConfigSchema` / `OclifPluginConfig` (same module) describe that - live surface and survive unchanged. - -One-line fix: delete the import (nothing ever read the declaration); if you -describe a plugin's CLI commands, declare the `oclif` section in the plugin's -`package.json` — `OclifPluginConfigSchema` validates it. - -The retirement kit: - -- whole-def deletion (route 3 — no carrier key, no authored document, so no - tombstone and no D2 conversion; the #11825 / #8715 shape): - `kernel/CLICommandContribution` in `RETIRED_DEFS_BY_MAJOR[18]`, plus the D3 - semantic entry `cli-command-contribution-retired` -- pin test (`kernel/cli-command-contribution-retirement.test.ts`): zero - holders for both retired names on every public entry, survivors pinned - (`OclifPluginConfigSchema` / `OclifPluginConfig` — the live `package.json` - `oclif` surface) -- the module docblock's Commander.js migration prose is KEPT — it is cited by - the `contributes.commands` tombstone (full-file deletion was explicitly not - the shape) -- zero authored occurrences in objectstack, objectui or cloud (measured at - dispatch, re-verified at claim), so no in-repo source changes ride along diff --git a/.changeset/cli-environments-entity-noun.md b/.changeset/cli-environments-entity-noun.md deleted file mode 100644 index e9b24d5777..0000000000 --- a/.changeset/cli-environments-entity-noun.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): the `os environments` family calls the entity an environment, not a project, in every string it prints (#12153) - -Per ADR-0006 the v5.0 rename `project` → `environment` has no aliases, and AGENTS.md -states "Project now only means the npm/monorepo sense". #10967 (PR #11227) renamed the -**command** (`os projects …` → `os environments …`) across these same five files; the -**entity noun** inside the strings oclif prints was left behind. A user ran -`os environments switch ` and the tool answered `✓ Active project: …`. - -25 user-visible string literals in `packages/cli/src/commands/environments/` are swapped -to the post-rename noun. No behaviour, no flag or argument names, no exit codes, and no -`--format json` / `--format yaml` payloads change — those are produced by -`formatOutput(res, …)` straight from the control-plane response and are untouched. - -| where | count | printed by | -| --- | --- | --- | -| `static override description` | 5 | `os environments --help` | -| flag / arg `description` | 7 | each command's own `--help` | -| success-path console output | 9 | `list` · `bind` · `create` · `show` · `switch` | -| `examples` arg placeholder (`` → ``) | 3 | `os environments bind --help` | -| the `switch` id-not-found error | 1 | `os environments switch` on a bad id | - -The five `static override description` strings now read as -`content/docs/deployment/cli.mdx`'s command table has described them since the rename -("List environments visible to the current session", "Provision a new environment", …), -so the shipped `--help` and the shipped docs agree for the first time. - -What is deliberately NOT renamed, because it is API surface in other packages rather than -CLI wording, and each needs its own decision: `client.projects.*` (the `@objectstack/client` -SDK method names), the `res.project` / `res.projects` response fields, the locals bound -directly from them, and the docblock comments in these files. diff --git a/.changeset/cli-i18n-flow-screen-bucket.md b/.changeset/cli-i18n-flow-screen-bucket.md deleted file mode 100644 index b4ee112ede..0000000000 --- a/.changeset/cli-i18n-flow-screen-bucket.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -`os lint` and `os i18n extract` gain the flow/screen bucket — a screen-flow -copy gap is reportable, and the `flows..screens..` skeleton is scaffoldable, -for the first time - -#11287 gave the bundle a `flows` group and a resolver that applies it. Nothing -on the CLI side walked it: `COVERAGE_SOURCE` had no flow bucket, so the -`i18n/missing-*` family could not report a screen-flow copy gap **at all**, and -`os i18n extract` never wrote the keys, so an author had no way to discover the -vocabulary. Measured on #11287: HotCRM reported **0 `i18n/missing-*` issues** on -a tree whose six screen dialogs rendered English in all four locales. An app -whose i18n gate is green is green because the surface is invisible to it. - -The shared walker (`collectExpectedEntries` — one definition of what is -translatable, feeding both the gate and the extractor) now harvests, per flow: - -``` -flows..label -flows..screens..title -flows..screens..fields..label -flows..screens..fields..placeholder -``` - -Screens are keyed by `FlowNode.id` and fields by `ScreenFieldConfig.name` — the -identifiers the runner already holds at render time, not a second naming -scheme. Missing keys report as `i18n/missing-flow`, per locale, with the same -opt-in rule as every other bucket: a project that declares no locales and ships -no bundle still reports nothing. - -The copy keys are **imported** from `@objectstack/spec/system` -(`FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`), never restated. They -are exported precisely so the extractor and the resolver cannot drift; the -schema-to-list agreement is pinned spec-side in `translation.test.ts`, and the -list-to-walker agreement is pinned here. - -Two seeding rules follow what the reader actually sees rather than which key -the author filled in. A screen's `title` falls back to the node `label`, -because the executor builds the wire title as `config.title ?? node.label` and -one bundle key covers whichever the runner draws. A field's `label` falls back -to its `name` as a *derived* seed: the skeleton stays usable while the gate -demands no translation for a string nobody authored. diff --git a/.changeset/cli-json-failure-payload-conversions.md b/.changeset/cli-json-failure-payload-conversions.md deleted file mode 100644 index d9100bae33..0000000000 --- a/.changeset/cli-json-failure-payload-conversions.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os validate --json` and `os build --json` carry the computed `conversions` on every failure exit, not the success payload alone (#12125) - -**Machine-contract widening on the `--json` failure payloads.** A consumer that -today branches on `conversions` being ABSENT from an `os validate --json` or -`os build --json` failure payload sees a different shape after this change. - -## What was wrong - -`conversionNotices` is filled by the `onConversionNotice` sink handed to -`normalizeStackInput`, which runs at **step 2** — above the schema parse and -above every later gate in both commands. The notice was therefore already in -hand when any failure exit fired, and was then discarded: `conversions:` was -published on the terminal SUCCESS payload alone. - -An ADR-0087 D2 conversion notice is the one advisory class that carries an -**expiry** — `retiresIn` names the protocol major where the old shape stops -loading. So a CI job gating on `os validate --json` / `os build --json` could -not see that its tree depends on a conversion about to retire for as long as -the tree also tripped any unrelated gate — the notice was withheld exactly -while the tree was broken, which is when an author is most likely editing it. - -This is the same "computed, then dropped on a failure exit" shape as the -`warnings` family (#11643 / #11391 / #11772 / #12047), one field over. #12079 -added `warnings` to all nine `os build` failure exits and deliberately left -`conversions` untouched, so closing those cards did not close this one. - -## What changed - -`conversions` is now present on every `emitJson` exit of both commands — -6 in `validate.ts` (5 failure + success), 10 in `compile.ts` (9 failure + -success) — alongside each exit's existing keys, which are unchanged. - -| command | exit | `conversions` before | after | -| --- | --- | --- | --- | -| `os validate --json` | protocol parse failure | absent | the computed notices | -| `os validate --json` | author-time rules failed | absent | the computed notices | -| `os validate --json` | capability provider check | absent | the computed notices | -| `os validate --json` | package docs failed | absent | the computed notices | -| `os validate --json` | thrown / caught | absent | what the run had computed | -| `os build --json` | all nine failure exits | absent | what the run had computed | - -The success payloads are unchanged in content. - -Notices are **carried, not recomputed**: the fix is a pure scope change — the -sink array is declared above the `try` so the catch-all exit can read it — and -`normalizeStackInput` still runs at exactly step 2. A run that throws in -`loadConfig`, above step 2, therefore reports `[]` honestly. - -⭐ Note the two fields differ on `os build`'s two earliest exits. For `warnings`, -`--strict-body` and the protocol parse are empty by construction (nothing -advisory is computed that early); step 2 is **above** both, so `conversions` is -populated there. The field was measured per exit rather than inherited from the -sibling change. - -## What a consumer keying off its absence should do instead - -⛔ `conversions` is no longer a signal of which exit produced the payload, nor -of success. Read `valid` (validate) / `success` (build), and `error` / -`errors`, for that; a consumer that inferred "this is a failure payload" from a -missing `conversions` must switch to the explicit status field. - -⛔ `conversions: []` on a failure payload does NOT mean "this tree converts -nothing". It means **this run stopped before the conversion layer ran** — a -config that fails to load reports `[]` by construction. A consumer that needs -the true conversion set for a tree must read it from a run that reaches at -least step 2. - -✅ `conversions` is always an array on every `os validate --json` and -`os build --json` payload, success or failure, so it can be read -unconditionally — that shape constancy is the point of the change (maintainer -ruling 2026-08-25 on #11772/#12047, option 1 of three, applied here under the -same-family rule; option 2, "carry it only where the text face printed it", was -rejected as the hardest contract to declare). - -✅ Each entry keeps its structured fields — `conversionId`, `surface`, `from`, -`to`, `path`, `toMajor`, `retiresIn` — on failure exits exactly as on the -success payload, so a CI job can gate on `retiresIn` without a second run. - -Exit codes are untouched: every failure exit still exits 1. `--strict` on -`os validate` still reads the text face's own list, which folds conversion -notices in, so `os validate --json --strict` reaches the same verdict it did -before. - -`warnings` and `conversions` remain **separate fields**. Whether the two should -be folded into one is a live question raised on #12125 and not settled by the -ruling; this change deliberately mirrors the `warnings` shape rather than -merging either field into the other. diff --git a/.changeset/cli-metadata-stats-runtime-row-and-translations.md b/.changeset/cli-metadata-stats-runtime-row-and-translations.md deleted file mode 100644 index 0e56111803..0000000000 --- a/.changeset/cli-metadata-stats-runtime-row-and-translations.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -**Fix:** the `Runtime:` row of the `os validate` / `os info` / `os compile` summary is no longer dropped on a stack with no plugins, and `MetadataStats` no longer counts a metric nothing prints (#11172). - -Two separate holes in one function, `printMetadataStats` in `packages/cli/src/utils/format.ts`. Both were measured against the real CLI (`bin/run-dev.js validate`, `NO_COLOR=1`) on a stack declaring nothing. - -**1. `Runtime:` vanished at zero.** The row was rendered *outside* the `sections` loop, as a standalone `if (stats.plugins > 0 || stats.devPlugins > 0)` after the loop closed, so the whole summary was: - -``` - Data: 0 Objects - UI: 0 Apps - Logic: 0 Flows - Security: 0 Positions 0 Permissions -``` - -with no `Runtime:` line at all. That is the same "reads as never asked, not as zero" shape #10504 and #10952 removed from the four sections — a stack that declares no plugins is indistinguishable from a summary that simply does not report on the runtime. It now prints: - -``` - Data: 0 Objects - UI: 0 Apps - Logic: 0 Flows - Security: 0 Positions 0 Permissions - Runtime: 0 plugins -``` - -`Runtime:` was **folded into the `sections` array** rather than fixed where it stood. Being outside the loop was not incidental to the defect: it is why #10952's `zeroFallback` mechanism was structurally unable to reach this row, and a zero case hand-rolled beside the loop would have been a second, un-enforced copy of the same invariant — while `zeroFallback` is a *required* field on the array's element type precisely so the next row cannot be added without naming what it prints at zero. The per-item `> 0` filter this row already applied is the same filter the loop applies, so the only thing that had to be carried across was its fragment style, and it is carried exactly: the shipped non-zero rendering stays `Runtime: 2 plugins, 1 devPlugins` — comma-joined, fully dim, lowercase item names — rather than being restyled into the sections' ` ` two-space shape. The ruling was about the row's presence at zero, not its typography. - -`plugins` is the row's zero signal: `devPlugins` is a dev-only overlay on it, so `Runtime: 0 devPlugins` would have reported the narrower fact and stayed silent about the broader one. A row with one non-zero peer still reports only that peer (`Runtime: 4 devPlugins`), exactly as `Security:` behaves. - -**2. `translations` was counted on every run and read by nothing.** `MetadataStats` declared `translations: number` and `collectMetadataStats` populated it with `count(config.translations)`, but no render path ever read it — a stack with 40 translation bundles reported them nowhere in the summary, at *every* value rather than only at zero. The field is removed implementation-first (zero readers); giving it a rendered home, in `UI:` or a new `i18n:` row, was considered and explicitly not taken. - -The invariant that replaces it is enforced from both ends: TypeScript already requires `collectMetadataStats` to populate every field `MetadataStats` declares, and a new pin requires every field it collects to reach the rendered output. Declared ⇒ collected ⇒ rendered — a metric counted on every `os validate` and shown nowhere cannot satisfy the chain, whatever it is called, so the pin fails for the next unread metric as well as for this one. - -**One externally visible consequence beyond the summary text.** All three commands spread the whole `stats` struct into their `--json` payload, so `os validate --json`, `os info --json` and `os compile --json` no longer carry a `stats.translations` key. That field was undocumented (the CLI docs describe `--json` for these commands but declare no payload shape for `stats`), carried no schema, and has no reader anywhere in the repo — a repo-wide search for `stats.translations` returns zero consumers. The other 18 keys are unchanged. diff --git a/.changeset/cli-readme-drop-os-studio.md b/.changeset/cli-readme-drop-os-studio.md deleted file mode 100644 index cb373beb36..0000000000 --- a/.changeset/cli-readme-drop-os-studio.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -docs(cli): drop the `os studio` row from the README command table — the CLI ships no such command (#11180) - -`packages/cli/README.md`'s **Development** command table listed -`` | `os studio [config]` | Launch Studio UI with development server | ``. The CLI has no -`studio` command and has not had one: the oclif command set is pattern-derived from -`packages/cli/src/commands/**`, and loading the built CLI's own `Config` enumerates 60 -registered ids with **zero** matching `studio` (control: `dev`, `serve`, `login`, `logout`, -`register`, `whoami` are all present in the same enumeration, so the check is not vacuous). -Running it confirms the same from the outside — `os studio --help` exits 2 with -`Error: Command studio not found.` - -The row is deleted rather than rewritten. Studio is not reached by a CLI command at all — -it is served by the console at `/_console/studio` after `os dev` or `os serve`, both of -which the same table already lists — so a replacement row would reintroduce the category -error that made this one wrong: a Commands table is a list of commands, and a browser route -is not one. diff --git a/.changeset/cli-serve-anchor-optional-import-at-app.md b/.changeset/cli-serve-anchor-optional-import-at-app.md deleted file mode 100644 index 39a133bf79..0000000000 --- a/.changeset/cli-serve-anchor-optional-import-at-app.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os serve` resolves app-declared optional service packages from the app, not the CWD (#11185) - -`serve` takes its config as an **argument**, so `objectstack serve /srv/app/objectstack.config.ts` -is a supported invocation and the app being served need not be the directory the operator -stood in. Every host-anchored optional load nevertheless used `process.cwd()` as its -resolution base, so with that invocation the CLI read the wrong `package.json`: a package the -app really does declare, and really does carry in its own `node_modules`, came back -`undeclared`, fell through to the framework-side fallback, and boot died — - -``` -Cannot find package '@objectstack/service-cluster': the host app does not declare it. - host app: /tmp/os-neutral-cwd-jXHXdF ← the CWD, not the app - (fallback resolution also failed: Cannot find package '@objectstack/service-cluster' - imported from …/packages/types/dist/node.mjs) -``` - -Measured on the released EE 4.1.0 image as `OS_CLUSTER_DRIVER=redis` ⇒ migrate exits 1 ⇒ the -whole stack cannot start. This is the same class as cloud#1013 and #10645 with the base wrong -for a different reason: those fixed the **importer** at these load sites (bare `import()` → -`importFromHost`); this fixes the **base** that importer is handed. - -`serve` now resolves the config path and the app root in one call (`anchorServedApp`), so the -anchor cannot be written too late or left out by a future author — the absolute config path -every later line needs is produced by the same call that sets it. Every host-anchored load in -the file defaults to that root, which is what generalises the repair to the next app-declared -optional service rather than fixing this one instance. The alternative route — declaring -`@objectstack/service-cluster*` in `packages/cli`'s own manifest — was rejected: it would make -the open-core CLI take a published dependency on packages it never imports, still leave every -third-party or future optional service broken, and change nothing for an app whose config is -addressed by path. - -The adopted root is the config's directory **only when that directory holds a `package.json`**, -and the CWD otherwise. `readHostDeclaration` reads a manifest — reachability is deliberately -not the contract (#4719) — so a directory with no manifest declares nothing and anchoring -there could only turn a working boot into an `undeclared` refusal. No layout that resolves -today resolves differently after this. - -The #4719 declaration gate is untouched: a package present in the app's `node_modules` but -absent from its `package.json` is still refused. The refusal's remedy (`host app: …`) now -names the app being served instead of an unrelated directory the operator happened to be in. diff --git a/.changeset/cli-serve-host-importer-caller-base.md b/.changeset/cli-serve-host-importer-caller-base.md deleted file mode 100644 index bba1a4bb07..0000000000 --- a/.changeset/cli-serve-host-importer-caller-base.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -**Fix:** `os serve`'s host importer now states its own resolution base, so a package the served app does not declare resolves from `packages/cli` instead of from `@objectstack/types` (#11157). - -`createHostImporter` has two legs. The **declared** leg resolves out of the served app's `node_modules` (#4719; #11185 fixed *which* app that is). The **undeclared** leg falls back to "the importing package's own resolution" — and which package that is depends entirely on where the `import()` is physically written, because Node ESM resolves a bare specifier against the module containing the call. #10943 turned that into an explicit parameter, `options.fallbackImport`, so a caller can hand in its own `import()`. `@objectstack/verify` (`bootStack`) and the `packages/qa/dogfood` enterprise probe both pass theirs; `serve`'s `importFromHost` did not, so it advertised the CLI's resolution and actually used `@objectstack/types`', which under a pnpm-isolated layout sees only `@objectstack/spec`. - -**Measured accept-set delta**: the undeclared fallback now reaches exactly what `packages/cli` itself declares, and nothing else. Re-measured with `import.meta.resolve` from a probe in each package — `chalk`, `@objectstack/plugin-auth` and `@objectstack/plugin-audit` resolve from `packages/cli` and not from `@objectstack/types`; every specifier `serve` itself routes through the helper (`@objectstack/service-cluster` and its drivers, `@objectstack/service-i18n`, `@objectstack/organizations`, `@objectstack/service-ai`, `@objectstack/service-ai-studio`) resolves from **neither**, which is why this was harmless in every shape that ships today. The #4719 declaration gate on the declared leg is untouched: a package that is merely reachable is still refused, and no app gains a way to load something it has not declared. - -**Two user-visible consequences.** A `plugins: [...]` entry naming a package the app does not declare but the CLI ships now resolves through the host importer rather than a separate local `import()` — same module, one attempt instead of two. And the undeclared-package diagnostic drops its "the caller did not pass `fallbackImport`" note, which `@objectstack/types` emits only for callers that withhold their base; the note existed so this gap would report itself, and it has now been closed rather than silenced. - -`Serve.importConfigPlugin`'s three-branch shape collapses to two in the same change. The undeclared branch kept a local `import()` *because* the helper's fallback resolved from the wrong package; with the base threaded, that branch and the re-entry branch are the same call, so the declaration is read once — by `readHostDeclaration` inside the helper — instead of being asked there and again here. Behaviour was measured case by case first: the app's declared copy still wins, a declared-but-uninstalled package still reports the install remedy, a package present-but-throwing still propagates as a crash (both paths gate on the one shared `isModuleNotFoundError`), and a package resolvable nowhere still produces the #4719 "declare it in that app's package.json" text. diff --git a/.changeset/cli-serve-port-drift-notice.md b/.changeset/cli-serve-port-drift-notice.md deleted file mode 100644 index b449b04b44..0000000000 --- a/.changeset/cli-serve-port-drift-notice.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os serve` announces a shifted port, naming the one you asked for and the one it took (#12543) - -In development (`os dev`, `--dev`, or `NODE_ENV=development`) `os serve` hops to -the next free port when the requested one is taken, so several example apps can -run side by side. That behaviour is unchanged and deliberate — production still -refuses to drift (#11113). What changed is that the hop is no longer silent. - -Previously the only trace of a shift was the ready banner printing the port that -was *bound*; nothing said it was not the port that was *asked for*, so every -reader had to already know the requested port and compare the two by hand. A -boot that shifts now prints, before anything else: - -``` - ⚠ Port 32869 is in use — serving on 32871 instead. - Development auto-shift: 32869 was not free, so this server took - the next one that was (32871). Anything still pointed at 32869 — a - proxy, an OAuth callback URL, another terminal, a test harness — is - talking to whatever holds 32869, not to this server. -``` - -The notice is written to **stderr**, like every other `os serve` diagnostic: -`stdout` carries JSON-RPC frames whenever the stdio MCP transport is mounted, so -nothing but protocol may go there. It prints only when the bound port actually -differs from the requested one — an ordinary boot on a free port is unchanged, -byte for byte. diff --git a/.changeset/cli-truncation-remainder-notices.md b/.changeset/cli-truncation-remainder-notices.md deleted file mode 100644 index 6bd1be511b..0000000000 --- a/.changeset/cli-truncation-remainder-notices.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os build`, `os validate` and `os init` say how many diagnostics they withheld - -Nine more renders across the three authoring commands cut their list at a fixed -cap and printed nothing saying so — the `--strict-body` refusal list, the -author-time rule failures, the undeclared-authoring-key findings, the -access-matrix drift, the package-doc errors, and both halves of `os init`'s -scaffold self-test. The defect is the **silence**, not the cap: truncated -output that carries no notice is indistinguishable from complete output, so an -author who reads it and sees no further problems has read a list that stopped -early. Two of them even stated the true total in their own header and then -showed fewer rows, so the report gave two numbers that disagreed and explained -neither. - -On the gating lists it also undoes the thing `os validate` went out of its way -to provide. Its own comment records why every failing rule reports at once: -"the command used to exit at the first failing gate, so an author with three -unrelated problems fixed them in three round trips and could not see how deep -the hole went". Past the cap that is exactly what came back, one cap-width at a -time, with each round of fixes revealing a new batch that reads as fresh -breakage. - -Every cap stays. Over it the output now names the exact remainder: - -``` - ⚠ … and 30 more author-time rule failure(s) not shown (50 of 80) — re-run with --json for the full list -``` - -**The pointer is verified per site, and two notices deliberately omit it.** -`--json` publishes each of these lists at the very exit whose text face carries -the notice, so re-running really does return the complete set. `os init` -declares no `--json` flag at all, so both of its notices state the remainder -and name no remedy — a notice whose remedy does not work is worse than a silent -cut, because it sends the author down a path that returns the same truncated -view. - -At or under a cap, nothing new is printed and the rendering is byte-for-byte -what it was. diff --git a/.changeset/client-auth-set-initial-password.md b/.changeset/client-auth-set-initial-password.md deleted file mode 100644 index 275358633d..0000000000 --- a/.changeset/client-auth-set-initial-password.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/client": minor -"@objectstack/plugin-auth": patch ---- - -**SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method. - -`AuthPlugin` has mounted this route on the raw Hono app for as long as the SSO-onboarding flow has existed, but `packages/client/src` built the URL nowhere — measured zero for both `setInitialPassword` and `set-initial-password`, against four sibling auth members returning non-zero on the same corpus, so the absence was an absence and not a broken search. Its only caller was `@object-ui/auth`'s `createAuthClient`, whose three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are all expressed on `ObjectStackClient`, and whose sibling branch in the very same Console password card — `changePassword` — has been ledgered `sdk` throughout. - -The method is shaped exactly like its namespace siblings (`this.getRoute('auth')` + `this.fetch`, `POST` with a JSON body, returning the parsed envelope), because the difference between it and `changePassword` is a **server-side** one and belongs there: better-auth registers `setPassword` with no HTTP path of its own (server-only `auth.api.setPassword`), so ObjectStack wraps it in an authenticated mount that requires a session and refuses with 409 `PASSWORD_ALREADY_SET` when a credential already exists. Callers that already have a password use `changePassword`, which verifies the current one. - -**Nothing about the route's behaviour moves.** Its accept/reject logic, its admit set and its server-side guards are untouched — this is a client binding to an existing mount, not a widening of what the mount allows. - -**Its `AUTH_ROUTE_LEDGER` row lands with it**, because the two halves are one statement and neither is true alone. `plugin-auth` gains `{ route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }` — the ninth mount of the #10534 census, whose disposition was escalated rather than guessed and which the maintainer ruled `sdk` (option C, 2026-08-22) and then ruled should land in one PR (2026-08-23). Without the row, the method's URL matched only the dispatcher's `* /auth/**` prefix family, and `client-url-conformance.test.ts` bounds wildcard-only matches at zero on purpose; with it, the same URL resolves to an enumerated route. The row also brings the `check:auth-mount-ledger` pending-disposition entry down — the exemption that carried this route while the question was open is deleted, which is that ratchet working rather than being relaxed. diff --git a/.changeset/client-meta-saveitem-query-options.md b/.changeset/client-meta-saveitem-query-options.md deleted file mode 100644 index 76e4d3b4b7..0000000000 --- a/.changeset/client-meta-saveitem-query-options.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/client': minor ---- - -`meta.saveItem` accepts the query-string options bag its route already reads — -`force`, `packageId`, `mode` — on both clients - -The Phase 3a-destructive gate refuses a metadata save with -`409 DESTRUCTIVE_CHANGE` and ends the message `— re-submit with ?force=true to -proceed.` Both REST `PUT` doors read `?force` off the query string and thread -it, so that sentence is true of an HTTP caller. It was **false of a -first-party SDK caller**: `meta.saveItem(type, name, item)` built a bare path -and a body and sent no query string at all, on either declaration. A caller -who did literally what the refusal prescribed got the identical refusal back, -and the only way to act on it was to abandon `@objectstack/client` for raw -`fetch`. - -Three parameters are newly reachable, and they are exactly the three -`PUT /api/v1/meta/:type/:name` reads: - -- **`force?: boolean`** — `?force=true`, the destructive-change opt-in the 409 - message names. Only the opt-IN is spelled on the wire: `false` and - `undefined` both omit the parameter rather than sending `?force=false`. - That is a hazard avoided, not tidiness — the door refuses a *repeated* - `force` because a repeated value arrives as an array and a non-empty array - is truthy, so an opt-OUT that reached the wire twice would switch the guard - ON. -- **`packageId?: string`** — `?package=`, binding the saved row to a - software package (`sys_metadata.package_id`). Named `packageId` to match the - sibling `getItem` / `getItems` options on the same object. -- **`mode?: 'draft' | 'publish'`** — `?mode=draft`, staging the write as a - pending draft. `'publish'` is the default said out loud and deliberately - sends nothing, since the door acts on `mode=draft` alone. - -**Backward compatible.** The bag is optional and an options-less call builds a -byte-identical URL to before — `''`, not a trailing `?`. Existing -three-argument callers are unaffected, and pins measure that rather than -assuming it. - -Both declarations move together — the unscoped `ObjectStackClient.meta` and -the environment-scoped `ScopedProjectClient.meta` — sharing ONE exported -`SaveMetaItemOptions` type and ONE query builder rather than a literal copied -into each. They are the same method on two clients reaching one pair of routes -(the scoped mount is the same route registration replayed under -`/environments/:environmentId`, so it reads the same three parameters), and -every divergence measured between these twins so far has been closed as a -defect. A bag spelled twice is the next one waiting to be introduced. - -The branch was selected by measurement, not preference: the SDK is the real -metadata-write path for both surfaces the ruling named. The CLI's `os meta -register` goes through `client.meta.saveItem` and the CLI has no raw-HTTP -metadata-save path at all; Studio reaches it from 21 production call sites -across `@object-ui/app-shell`, `plugin-designer`, `data-objectstack` and the -console app — including the object and field designers, where dropping a field -and saving is precisely what raises the destructive 409. diff --git a/.changeset/client-precise-sdk-return-types.md b/.changeset/client-precise-sdk-return-types.md deleted file mode 100644 index 05e1a4990b..0000000000 --- a/.changeset/client-precise-sdk-return-types.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/client': minor ---- - -Bind the SDK's erased return types to the `@objectstack/spec` contracts the package already depends on - -**This is a NARROWING of published return types.** 41 methods that resolved to `any` (or to an -envelope carrying an `any[]`) now resolve to the contract type the route actually answers, and 12 -fixed-shape `automation.*` methods gain a constrained generic in place of ``. Nothing -changes at runtime — no request, response, unwrapping or error path is touched — but code that -compiles today against these methods can stop compiling. `any` is assignable to everything and -admits every property read, so the previous declaration accepted assignments, property reads and -parameter forwarding that a precise type refuses. - -What a consumer could stop compiling against, per family: - -- **`automation`** — `get`/`getFlow` are `FlowParsed`; `runs.get`/`getRun` are `ExecutionLog`; - `runs.list`/`listRuns` are `{ runs: ExecutionLog[]; hasMore: boolean }`; `execute` and `resume` - are `AutomationResult`; `getScreen` is `{ runId: string; screen: ScreenSpec }`; `listActions` and - `listConnectors` carry `ActionDescriptor[]` / `ConnectorDescriptor[]` instead of `any[]`. ⚠️ The - biggest practical break is `AutomationResult.screen`, `.runId`, `.status` and `.summary` being - **optional**: a completed run carries no screen, so `result.screen.nodeId` must become - `result.screen?.nodeId`. The six flat aliases and their `ScopedProjectClient` mirrors move - together. ⚠️ `` became `` on those twelve: an explicit type argument - still works when it narrows the platform shape (`getFlow`), - but one naming an unrelated type is now refused — including where TypeScript used to infer it - from the assignment's own annotation. -- **`approvals`** — `recall` / `revise` / `resubmit` are `ApprovalRecallResult` / - `ApprovalSendBackResult` / `ApprovalResubmitResult`; `remind` is - `{ request: ApprovalRequestRow; notified: number }`; `requestInfo` and `comment` are - `{ request: ApprovalRequestRow }`. These join `reassign` and `listActions`, which were already - typed this way beside them. -- **`shares` / `shareLinks`** — `shares.list` is `RecordShare[]`, `shares.grant` is `RecordShare`; - `shares.rules.list` / `save` / `get` are `SharingRuleRow`(`[]`) and `rules.evaluate` is - `SharingRuleEvaluationResult`; `shareLinks.create` / `list` are `ShareLink`(`[]`). -- **`reports`** — `list` / `save` / `get` are `SavedReport`(`[]`), `run` is `ReportRunResult`, - `schedule` / `listSchedules` are `ReportSchedule`(`[]`). -- **`security`** — `describeDelegableScope` is `DelegableScope`; `explain` is `ExplainDecision` (the - `z.input` form `ISecurityService.explain` declares and the route relays verbatim — **not** the - post-parse `ExplainDecisionParsed`, since no parse runs on that path); the three - `suggestedBindings` methods carry their `{ suggestion, … }` / `{ suggestions, synced }` envelopes. - The suggestion ROW stays `Record` by contract, but `bindingCreated` and - `synced.{created,confirmedObserved,pruned}` stop being erased. -- **`email` / `datasources.external`** — `email.send` is `SendEmailResult` (branch on `status`). - ⚠️ The four federation methods are **envelope-wrapped** and the obvious binding is the wrong one: - `listTables` answers `{ tables: RemoteTable[] }`, not `RemoteTable[]`; likewise - `{ draft: ObjectDraft }`, `{ object: ImportObjectResult }`, `{ catalog: ExternalCatalog }`. - `validate` is a bare `SchemaValidationReport`. -- **`ScopedProjectClient.packages.list`** — `{ packages: InstalledPackage[]; total: number }`. - -Four methods deliberately keep `Promise` and say so in their docblocks: `automation.create` / -`automation.update` echo an unvalidated request body, and `search` / `data.clone` answer shapes -declared inline in the implementation rather than in `@objectstack/spec`. Those are missing -*contracts*, not missing annotations, and authoring them belongs to the spec package. The -caller-supplied generics on `data.*` and `actions.*` are unchanged — there the payload really is -the caller's. diff --git a/.changeset/client-saveitem-ifmatch-header.md b/.changeset/client-saveitem-ifmatch-header.md deleted file mode 100644 index f79835ad06..0000000000 --- a/.changeset/client-saveitem-ifmatch-header.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): `meta.saveItem` can send the `If-Match` OCC header it already told callers to send (#11713) - -`saveItem`'s own docstring has always named the ADR-0008 optimistic-concurrency -protocol: the resolved `version` is the token, echo it back as the `If-Match` -request header on the next write to the same item, and a concurrent edit is -reported as `409 METADATA_CONFLICT` instead of silently overwriting. Both REST -`PUT` doors read `if-match` and thread it as `parentVersion`, so that sentence -was true of a raw-HTTP caller. It was **false** of a first-party SDK caller: -neither `saveItem` declaration accepted a header, an `ifMatch`, or anything -that became one — so an SDK caller who did exactly what the docstring said had -nowhere to put the token and their concurrent edit overwrote anyway, answered -`200`. Declared, not enforced, with no signal at the call site. - -**What is new:** `ifMatch?: string` joins the `SaveMetaItemOptions` bag that -`#11391` landed, on **both** `saveItem` declarations — the unscoped -`ObjectStackClient.meta` and the environment-scoped -`ScopedProjectClient.meta` — wired to the `If-Match` request header through a -single shared builder, the same way the three query parameters go through one -shared query builder. The twins cannot drift. - -```ts -const saved = await client.meta.saveItem('object', 'customer', doc); -// …later, guarded against a concurrent edit: -await client.meta.saveItem('object', 'customer', next, { ifMatch: saved.version }); -// a stale token now answers 409 METADATA_CONFLICT instead of overwriting -``` - -Purely additive and opt-in. Only a non-empty token reaches the wire: -`undefined` and `''` both omit the header entirely — the `init` handed to -`fetch` carries no `headers` key at all — so every existing call is -byte-identical and last-write-wins remains the default, on the wire and on the -door. Unlike the bag's `mode`, `ifMatch` reaches **both** save doors: the -compound-name twin `PUT /meta/:type/:section/:name` reads `if-match` and strips -ETag-style quotes exactly as the single-segment door does. - -Aligned deliberately with the other first-party client: the same member name, -the same header, and the same truthy guard as `MetadataClient.save` in -`@object-ui/data-objectstack` — two first-party clients, one behaviour. diff --git a/.changeset/client-sdk-example-typecheck.md b/.changeset/client-sdk-example-typecheck.md deleted file mode 100644 index e50e8be6f3..0000000000 --- a/.changeset/client-sdk-example-typecheck.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/client-react": patch -"@objectstack/client": patch -"@objectstack/spec": patch ---- - -fix(client-react): correct two broken TSDoc `@example` blocks the SDK docs shipped verbatim (#10969) - -TSDoc `@example` blocks are preserved into the published `dist/*.d.ts` (confirmed by -building and reading the emitted declarations), so they reach consumers directly in their -editor's hover tooltip — copying one is the intended usage. Two were actively wrong: - -- `useAutoRefresh`'s example read `data.map(...)`. `data` is a `PaginatedResult` (or - `null`), which has no `.map` — copied verbatim, this throws once the query resolves. - Fixed to `data?.records.map(...)`. -- `useMetadata`'s example called `client.meta.getObject(...)`, a method that does not - exist on the client (only `getItem`/`getItems`/`getView` do). Fixed to the real - `client.meta.getItem('object', ...)`, matching `useObject`'s own implementation. - -While in there: every other `@example` on this surface (18 more, across -`client-react`'s `data-hooks.tsx`/`metadata-hooks.tsx`/`realtime-hooks.tsx`/`context.tsx` -and `client`'s `index.ts`) is now genuinely self-contained and copy-paste-able — each -previously omitted the `import` for the hook or type it demonstrated, and three -`realtime-hooks.tsx` examples wrote a literal `useQuery(...)` (three dots) as a prose -placeholder, a syntax error once copied. - -**`@objectstack/spec`: dev tooling only, nothing published changes.** The gate that now -type-checks the surface above (`check:skill-examples`, `packages/spec/scripts/`) lives in -this package but is not part of it — `scripts/` is outside `@objectstack/spec`'s publish -`files` allowlist (confirmed via `check:published-files`), so no consumer-visible surface -moves. Named here only because the fixed-version group requires every package with a -source diff to be covered by a changeset; the actual version bump is a byproduct of the -group moving together, not a claim that spec shipped something new. - -No exported type, function signature, or runtime behaviour changed on any of the three — patch. diff --git a/.changeset/client-unannotated-return-erasure.md b/.changeset/client-unannotated-return-erasure.md deleted file mode 100644 index 78e1627a0c..0000000000 --- a/.changeset/client-unannotated-return-erasure.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): bind the three verifiable methods of the unannotated return-type erasure population to their spec contracts (#11925) - -**Return-type narrowing on a published SDK.** No runtime change — the value each -method resolves to is byte-identical before and after. Only the DECLARED type -moved, off `any`, which is precisely why a runtime test cannot observe it and -the pins for it are type-level. - -`any` is assignable to everything and admits every property read, so for each -method below a consumer's code could stop compiling where it previously did -not: assigning the result to an unrelated annotation, reading a property the -bound type does not declare, or forwarding the value to a differently-typed -parameter. - -## What changed, per family - -**`client.packages.list` → `{ packages: InstalledPackage[]; total: number }`** -(was `{ packages: any[]; total: number }`). A consumer stops compiling if it -reads any key off a row that `InstalledPackage` does not declare. Note in -particular `source` (`'database' | 'registry' | 'both'`): the REST surface -spreads it onto each row, the dispatcher surface does not, and it is therefore -deliberately NOT declared — code reading `pkg.source` off this result compiles -today and will not after. `total` and the array envelope are unchanged. - -**`client.packages.update` → `InstalledPackage`** (was `any`). This method -declared no envelope before, so nothing about the shape claim changed; a -consumer stops compiling if it reads an undeclared key off the returned row, or -assigns the result somewhere `InstalledPackage` does not fit. - -**`ScopedProjectClient.packages.get` → `{ package: InstalledPackage }`** (was -`{ package: any }`). The `{ package }` envelope is unchanged; only the member -narrowed. A consumer stops compiling if it reads a key off `.package` that -`InstalledPackage` does not declare — again including `source`, which this -route does send and which stays undeclared for consistency with its already -bound `list` sibling. - -## What deliberately did NOT change - -The other 36 methods in the measured population keep their erased `any`, each -with a docblock stating why and pointing at the issue that carries it: -`meta.*` history/diagnostics (9) and eight `packages.*` routes have no published -response contract to bind to (#12038); `client.packages.get` has two mounted -surfaces that emit different envelopes and `install`/`enable`/`disable` declare -an envelope no surface emits (#12034); the 15 cloud `projects.*` methods call a -control plane that speaks snake_case while the `@objectstack/spec/cloud` rows are -camelCase, so binding to them would compile and be false (#12036). - -No consumer loses anything by those staying `any` — they are exactly as -permissive as before. diff --git a/.changeset/clone-permission-set-carries-all-facets.md b/.changeset/clone-permission-set-carries-all-facets.md deleted file mode 100644 index e8d37e3b2f..0000000000 --- a/.changeset/clone-permission-set-carries-all-facets.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Make `clone_permission_set` carry the system permissions, row-level security -and tab permissions it was silently dropping - -The Clone action POSTs its `params` values to `/api/v1/data/sys_permission_set`, -so the params list *is* the payload. It named two of the six definition facets a -`sys_permission_set` row carries — `object_permissions` and `field_permissions` -— leaving `system_permissions`, `row_level_security` and `tab_permissions` -absent from the body. `permissionSetBodyFromRow()` then read each one through -`parseMaybeJson(undefined, …)` and filled the empty default, so cloning a set -that grants `setup.access`, or one carrying row-level security policies, -produced a clone with none of them: record created, success toast fired, and the -missing half discoverable only by diffing the two records. - -The three now travel, in the same JSON-string shape the two listed columns -already used. Nothing about what the door ACCEPTS changed — `permissionSetBodyFromRow()` -already read all six columns; what changed is what the action SENDS. - -This became urgent one commit ago. The save door now refuses an in-place edit of -a package-declared permission set **and its refusal message tells the admin to -clone**, which made this action the platform's own recommended remedy while it -was still dropping three facets — an admin following that instruction lost -grants quietly. The failure direction was fail-closed (fewer grants), which is -why it was quiet. - -`admin_scope` is **deliberately not copied** (maintainer ruling 2026-08-24). -Putting an ADR-0090 D12 delegated-admin authority onto a brand-new -organization-owned set on the admin's behalf is a privilege decision, not a -field copy. The Clone dialog now says so in its description, so the omission -reads to the admin as a decision rather than as the same silent drop — grant a -scope deliberately on the new set if it needs one. - -Pinned by `packaged-permission-set-lock.test.ts` pin 6, which assembles the -clone payload by READING the action's params list rather than restating it, and -asserts each facet by identity against a non-empty value — the empty default -(`[]` / `{}`) is exactly what a "present" assertion would have accepted. Its -control proves the exclusion is live: the base fixture carries a real -`admin_scope`, and the clone still has none. diff --git a/.changeset/component-node-visibility-guidance-post-5505.md b/.changeset/component-node-visibility-guidance-post-5505.md deleted file mode 100644 index ba37e77c74..0000000000 --- a/.changeset/component-node-visibility-guidance-post-5505.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): `COMPONENT_NODE_VISIBILITY_GUIDANCE` no longer claims a hoisted `properties` visibility key is evaluated by nothing (#11033) - -The `COMPONENT_NODE_VISIBILITY_KEYS` key-set guard's `prescription` — the text -emitted to an author when a visibility key (`visible` / `visibleWhen` / …) is -written inside `properties` instead of on the component node — closed with: - -> Inside `properties` it is hoisted onto the node by the renderer but evaluated -> by nothing — the component renders unconditionally, which is a visibility -> gate that silently does not gate. - -That was true when it was written and is false since objectui#5505 -(`c86185eb5`, merged 2026-08-21): `SchemaRenderer`'s node-level `visibleWhen` -evaluator now binds `record`, so the hoisted value IS evaluated by the -node-level gate. Post-#5505 the props-level and node-level forms evaluate the -same value over the same `RecordContext` and compose as an idempotent AND — -there is no gate that silently fails to gate. - -The prescription now states that truth instead, and keeps its move-it-up -advice resting on the reason that still holds: `visibleWhen` at the node, -beside `type` and `id`, is the ADR-0089 canonical spelling — a layer-discipline -argument, not an inertness one. - -Message text only. No accept/reject verdict changes, no schema shape changes, -and no runtime behaviour changes — both gates already evaluated the value -identically before and after this change. diff --git a/.changeset/compound-meta-door-mode-draft.md b/.changeset/compound-meta-door-mode-draft.md deleted file mode 100644 index 511d300334..0000000000 --- a/.changeset/compound-meta-door-mode-draft.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): `?mode=draft` now stages on the compound-name metadata write door - -`PUT /api/v1/meta/:type/:section/:name` — the compound-name door, the one you -reach with a name like `views/all_leads` — built its `saveMetaItem` request -field by field and `mode` was not one of the fields. Its single-segment twin -`PUT /api/v1/meta/:type/:name` has read that parameter all along. The parameter -was never refused here, only dropped, so the request was answered `200` and -published **live**. Both doors now read it. - -**Two behaviour changes, and both can be observed by an unchanged caller.** - -**1. `?mode=draft` on this door changes OUTCOME, not acceptance.** The request -was accepted before and is accepted now; what moved is what it does. - -| Request | Before | After | -| --- | --- | --- | -| `PUT /meta/object/crm/task?mode=draft` | `200`, `"state":"active"` — the live row overwritten, nothing staged | `200`, `"state":"draft"` — a staged row written, the live row untouched | - -If you send `?mode=draft` to a compound name today and rely on the write going -live — for instance because you never call `POST /meta/:type/:name/publish` — -those writes stop taking effect immediately and start waiting for a promotion. -Drop the parameter to keep publishing straight away. `mode=publish`, an -unrecognised `mode=`, an empty `mode=` and no `mode` at all are all unchanged: -they publish, exactly as before. The spelling test is the twin's, `draft` -case-insensitive. - -⚠️ **The draft you can now stage has no per-item REST promotion door in this -arity.** `POST /meta/:type/:name/publish` is mounted for single-segment names -only, while its read twin `GET /meta/:type/:section/:name/published` is mounted -for both — so a compound-named draft is writable and readable over REST and not -promotable there. Until that route exists, promote through -`POST /packages/:id/publish-drafts` (whole-package) or the runtime dispatcher's -own `meta.publish` verb. Tracked in #11932; this release does not change it. - -**2. A repeated `?mode` is now REFUSED where it was accepted.** This narrows -what the door takes. `?mode=draft&mode=draft` arrives as an array; the -`typeof === 'string'` test is false for it, so before this change it fell back -to publishing live under a `200`. It is now answered `400` -`{ "error": { "code": "VALIDATION_ERROR" } }` and nothing is written — the -#6877 guard this door already applied to `force` and `package`, extended to the -parameter it just gained, and the same answer the single-segment twin has given -for a repeated `mode` since #6877. A single occurrence encoded as an array -(`?mode=draft` once) is still accepted; the guard unwraps rather than -blanket-refusing. - -Nothing else on the door moved: `?force`, `?package`, the `meta-envelope` -write face, the `manage_metadata` gate and the `501` envelope are untouched, -and the single-segment twin is untouched. diff --git a/.changeset/console-description-drop-fabricated-framework.md b/.changeset/console-description-drop-fabricated-framework.md deleted file mode 100644 index 0234950c90..0000000000 --- a/.changeset/console-description-drop-fabricated-framework.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/console': patch ---- - -npm `description` no longer names the fabricated `@objectstack/framework` - -`@objectstack/console`'s manifest `description` is what npm renders on the -package page, so it was the most-read of the three sites that named a package -nobody can install. The earlier pass corrected the other two — this package's -README and `packages/cli/src/utils/console.ts` — but left the manifest alone -because a published manifest was outside that change's declared file surface. -This is the third and last live site. - -The correction is the same one the README took: the mechanism is real and only -the name was wrong. There is no umbrella `@objectstack/framework` package (404 -on the public registry, and fabricated — nothing in this tree presents it as -enterprise or private, it is presented as the *default public* install). What -actually pins the two together is that `@objectstack/cli` declares -`@objectstack/console` as a dependency and both ship at one version from the -Changesets `fixed` group. - -```diff --Prebuilt Console SPA pinned to this @objectstack/framework release. Source of truth: … -+Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: … -``` - -"framework release" survives as the common noun the README already uses; what -is dropped is the `@objectstack/` scope that turned it into a package name. The -`@object-ui/console` source-of-truth half is unchanged — it was accurate. - -Text only: no code, no exports, no behaviour change. It carries a changeset -rather than the publish-nothing exemption because the manifest `description` -ships inside the npm tarball and is a published artifact. diff --git a/.changeset/console-readme-resolution-order.md b/.changeset/console-readme-resolution-order.md deleted file mode 100644 index 6fdd200661..0000000000 --- a/.changeset/console-readme-resolution-order.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/console": patch ---- - -Correct the published `@objectstack/console` README's account of how the CLI -resolves the Console SPA (#11262). `README.md` is in this package's `files` -array, so it is the page npm renders. - -The README claimed `resolveConsolePath()` "prefers `@objectstack/console` and -falls back to `@object-ui/console` when present — so cloud's Docker overlay -(which `cp -r`s its build over `node_modules/@object-ui/console`) keeps -working." Both halves are false against -`packages/cli/src/utils/console.ts`: - -- There is no `node_modules` fallback to the `@object-ui/console` npm - package. Strategy 1 (`require.resolve('@objectstack/console/package.json')` - from the app and from the CLI itself) and strategy 2 (direct - `/node_modules/@objectstack/console`) each require the resolved - `package.json` to be named `@objectstack/console`. The legacy package is - never consulted; the function's own header says so. -- `@object-ui/console` survives in exactly one branch — strategy 3, the - sibling-repo dev fallback, which matches the `name` field of a checked-out - `../objectui/apps/console` **source** tree. That is a source-checkout - probe, not a package resolution, and the corrected text keeps the two - apart. -- The trailing consequence was the load-bearing half: cloud and objectos - Docker images overlay into `@objectstack/console`'s `dist/`, so the README - was telling an operator their overlay works by a mechanism that no longer - exists. Replaced with the real overlay target. - -Documentation only — no runtime, type or export change. The removal of the -npm-package fallback was deliberate and is not restored here. diff --git a/.changeset/controlled-by-parent-chain-composition.md b/.changeset/controlled-by-parent-chain-composition.md deleted file mode 100644 index fc9e2cd353..0000000000 --- a/.changeset/controlled-by-parent-chain-composition.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -fix(plugin-security): `controlled_by_parent` composes across a chain — a child whose master is itself derived is no longer readable and writable org-wide (#11082) - -**BREAKING** access tightening, shipped as `minor` under the repo's -launch-window convention. It denies reads and writes that previously -succeeded — which is the whole point: they were never authorized by any -declaration, and the app author could not tell. - -`controlled_by_parent` (ADR-0055) resolves a detail's access from its master. -#5386 made that resolution fold in the master's ownership and its -`sys_record_share` grants, not just the master's RLS policies. It did not -recurse, and both halves it composes answer "no restriction" for a master that -is **itself** `controlled_by_parent`: - -- the RLS half is `null`, because a derived object authors no policy — - declaring `controlled_by_parent` *is* its policy; -- the sharing half is `null` too: `plugin-sharing`'s `buildReadFilter` opts out - of every model that is not `private`, and `effectiveSharingModel` maps - `controlled_by_parent` to `public`. - -Composed: `null`. The derivation's master query then ran as **system** with an -empty predicate and returned **every master row**, so a two-level chain was -enforced at level one and org-wide at level two. The write half failed through -a separate mechanism with the same result: the master gate asks `canEdit` on -the master row, `checkEdit` returns `abstain` for a `public`-mapped model, and -`abstain` is not `deny` — so it answered `true` for every master row. - -Both halves now walk the chain. The read derivation composes the master's own -`controlled_by_parent` filter as a third layer, and the write gate runs its -three master-edit legs on each hop until it reaches a master that governs its -own rows. The master set is therefore point-for-point equal to what a direct -read of the master returns, at every level, which is the equality #5386 -established for one level. - -This is **not** a blanket refusal for chained declarations: a detail whose -whole chain is reachable stays readable and writable, and the single-level case -is unchanged. Two guards bound the walk and both fail **closed**, never to "no -restriction": a metadata cycle is refused, and so is a chain deeper than 8 -links (a cost ceiling, not a supported-length statement — termination is -already guaranteed by the cycle guard). - -What an app may observe: a detail under a `controlled_by_parent` master that -was reachable before is now reachable only if the caller can reach the whole -chain above it. Apps whose masters are `private`, `public_read` or -`public_read_write` — every `controlled_by_parent` object authored in this -repo — are unaffected. - - diff --git a/.changeset/create-objectstack-honest-pm-probe.md b/.changeset/create-objectstack-honest-pm-probe.md deleted file mode 100644 index e244cd353f..0000000000 --- a/.changeset/create-objectstack-honest-pm-probe.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"create-objectstack": patch ---- - -fix(create-objectstack): stop reporting a failed pnpm probe as a deliberate npm choice (#11616) - -`detectPackageManager()` was `try { execSync('pnpm --version') } catch { return -'npm' }`, so every failure mode collapsed into one answer. `npm install` in the -scaffolder's output meant either *this machine has no pnpm* or *the probe -threw*, and nothing — no log line, no message — could tell the two apart. - -That second case is reachable on an ordinary developer machine, not just in -theory: `pnpm --version` resolves through Corepack and therefore depends on the -directory it runs in. Measured on one machine, one binary, two directories — -`10.31.0` inside a repo that pins `packageManager`, `10.33.0` outside it, where -Corepack has to resolve, and may have to fetch, a version nothing pinned. A -user who has pnpm installed but is on a slow or offline network was silently -told to run npm. - -The probe now reports why as well as what: - -- `probe: 'ok'` — pnpm answered, so pnpm is used (unchanged, silent). -- `probe: 'absent'` — no pnpm on PATH at all, so npm is a real choice - (unchanged, silent). -- `probe: 'failed'` — pnpm **is** on PATH and the probe still threw. npm is - used exactly as before, and the run now says so, naming the underlying - failure: `pnpm is installed but \`pnpm --version\` failed (); using - npm as a fallback.` - -**Which package manager a run uses is unchanged in all three cases** — it is -still pnpm if and only if the probe succeeded. The PATH lookup that separates -`absent` from `failed` runs only after the decision is already made and feeds -the message alone, so a miss there can change a warning's wording and never the -tool's behaviour. The only output that moves is one warning in a case that was -previously silent and wrong. diff --git a/.changeset/cross-schema-fk-qualification.md b/.changeset/cross-schema-fk-qualification.md deleted file mode 100644 index da49b7e68a..0000000000 --- a/.changeset/cross-schema-fk-qualification.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/objectql': minor ---- - -Cross-schema foreign keys are now qualified instead of shipping an unusable bare name (#11377). - -`IntrospectedForeignKey` (driver-sql) gains an optional `referencedSchema`, present when — and -only when — the referenced parent table lives outside the introspecting session's resolution -scope (Postgres: the parent's schema is not on `current_schemas(false)`; MySQL: the parent's -database differs from `DATABASE()`; SQLite never sets it — no schemas, and a foreign key cannot -cross an ATTACHed database). `referencedTable` stays a bare name always — the qualification is a -separate key, never a conditional spelling. - -`convertIntrospectedSchemaToObjects` (objectql) reads the new key: a foreign key whose target -carries `referencedSchema` is loudly skipped and flagged through the new `options.logger` -(default `console`) instead of being wired to the bare name — which either resolved to nothing -or to a same-named table in the current schema, silently. The column is kept as a plain field so -its data stays visible. Foreign keys with in-scope targets keep producing identical lookup -fields. diff --git a/.changeset/crypto-posture-deployment-signal.md b/.changeset/crypto-posture-deployment-signal.md deleted file mode 100644 index b6ea4dcd68..0000000000 --- a/.changeset/crypto-posture-deployment-signal.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/service-settings": minor ---- - -**Security:** `LocalCryptoProvider` selects its crypto posture from the deployment signal only. A test-runner variable inherited by a spawned server can no longer disarm the production key refusal (#11352). - -`detectMode` read `env.VITEST` as a vote for `'test'` posture: - -```ts -if (env.VITEST || env.NODE_ENV === 'test') return 'test'; -``` - -`'test'` is not a softer flavour of `'production'`. It is the branch that takes an ephemeral key, never touches disk, and **never refuses to boot** — and that refusal is the reason the class exists: minting a key at boot makes every previously-written `sys_secret` value (encrypted settings, `secret` fields, datasource credentials) undecryptable after the next restart or on another node, invisibly at encrypt time. So one runner variable decided whether a security gate ran at all. - -Runner variables are **inherited**. Vitest sets `TEST`, `VITEST`, `VITEST_MODE`, `VITEST_WORKER_ID` and `VITEST_POOL_ID` on its worker, and every process that worker spawns with `{ ...process.env }` receives them. Measured on this repo: a real `os serve` spawned that way booted with production auth and **test** crypto. `packages/cli/test/serve-node-env-production-default.e2e.test.ts` — a pin whose entire subject is *"unset `NODE_ENV` means production"* — ran that way for its whole life, and nothing said a word, because a gate that does not run prints nothing. It surfaced only incidentally, while closing the sibling `TEST` leak into better-auth's origin check one layer down. - -**What changes for you.** A process that boots with `NODE_ENV=production`, no `OS_SECRET_KEY`/`OS_DEV_CRYPTO_KEY`, no persisted key file, no `OS_CRYPTO_AUTOKEY` — and a runner variable in its environment — now **refuses to start** instead of running on an ephemeral key. That is the documented fail-loud guarantee arriving where it was previously skipped, not a new restriction: supply the key the refusal names. - -``` -OS_SECRET_KEY=$(openssl rand -hex 32) -``` - -**What does not change.** In-process unit tests still get `test` posture — ephemeral key, disk never touched. The `VITEST` read is deleted rather than narrowed because vitest sets both variables on the same worker (`prepareVitest()`: `process.env.VITEST = "true"; process.env.NODE_ENV ??= "test";`, repeated as `NODE_ENV: process.env.NODE_ENV || "test"` in each worker's env). In-process the two spellings are indistinguishable; they differ only for an **inheriting child**, which is precisely the defect. `NODE_ENV` remains the one signal, and a deployment that declares itself a test deployment still gets test posture. - -The whole class is now gated: `pnpm check:runner-env-posture` refuses `TEST`, `VITEST`/`VITEST_*` and `JEST_WORKER_ID` anywhere in product source, so the next author is told at authoring time rather than by an operator whose secrets stopped decrypting. `NODE_ENV` is deliberately not banned — it describes the deployment, and a deployment may declare itself a test deployment; a runner may not declare it on the deployment's behalf. diff --git a/.changeset/datasource-driver-handle-introspection-contract.md b/.changeset/datasource-driver-handle-introspection-contract.md deleted file mode 100644 index 7aa7cedd8b..0000000000 --- a/.changeset/datasource-driver-handle-introspection-contract.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/service-datasource": minor ---- - -feat(service-datasource): `DatasourceDriverHandle.introspectSchema` declares the spec introspection contract, so a mis-shaped custom driver fails to compile naming the wrong field (#11381, option C of the #11123 ruling) - -**BREAKING** for TypeScript hosts that build custom external-datasource -drivers, shipped as `minor` under the repo's launch-window convention for -breaking changes. - -`DatasourceDriverHandle.introspectSchema` — the seam every host-built driver -crosses, since the framework deliberately ships no driver-by-id registry — -was typed `Promise`. The `isPrimary` → `primaryKey` retirement -(#11124, shipped in 17.2.0) named the compiler as the channel that reaches -every affected consumer, but against an `unknown` return that channel -provably never fired: a host driver spelling the per-column primary-key flag -`isPrimary`, or returning `{ tables }` with no `dialect`/`introspectedAt`, -compiled clean, and the mis-shape surfaced only as a federated table whose -records silently could not be located or updated. - -The member now declares `Promise` — the one introspection -contract in `packages/spec` (`contracts/schema-diff-service.ts`). A -mis-shaped driver is refused at compile time, at the offending field: -`Property 'primaryKey' is missing in type '…' but required in type -'IntrospectedColumn'`, and on a fresh literal additionally `'isPrimary' does -not exist in type 'IntrospectedColumn'`. A driver that already returns the -spec shape — or a richer declared type extending it, the driver-sql / -objectql pattern (table-level `primaryKeys`, per-column `maxLength`) — -compiles unchanged. - -Runtime behaviour does not change. The `primaryKeyReader` compatibility belt -in `ExternalDatasourceService` keeps absorbing the retired spelling from -producers no compiler reaches (drivers already built against older versions, -plain-JS drivers, casts). Removing that belt is #11123 option B — a later, -separate step gated on this tightening being released and the retirement -being published — and is not part of this change. - - diff --git a/.changeset/declare-live-rest-config-keys.md b/.changeset/declare-live-rest-config-keys.md deleted file mode 100644 index 653779f70f..0000000000 --- a/.changeset/declare-live-rest-config-keys.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/rest': patch ---- - -fix(spec,rest): give `api.enableSearch` a declared seat, and stop reading runtime-honoured config keys through `as any` (#11983) - -`api.enableSearch` was a live REST config key with no declared seat: -`RestServer.normalizeConfig` read it through `(api as any)` and honoured it -(`enableSearch: false` really unmounted the search endpoints), but no schema in -`packages/spec` declared it. Because `RestApiConfigSchema` is not `.strict()`, -its own parse **stripped** the key — measured: -`RestApiConfigSchema.parse({ version: 'v1', enableSearch: false })` returned an -object with no `enableSearch` property at all — so any consumer of the parsed -config silently got search turned back on for a deployment that turned it off -(the ADR-0104 silent-strip class). It also forced #11637's construction-time -parse to be validation-only, discarding the parsed value. - -- `RestApiConfigSchema` now declares - `enableSearch: z.boolean().default(true)` beside `enableOpenApi`, with the - runtime's existing default. The opt-out now survives the key's own - contract's parse (pinned), and a TypeScript author can write - `api: { enableSearch: false }` without a cast. -- `packages/rest`'s `normalizeConfig` drops all three `as any` reads: the - newly declared `enableSearch`, the already-declared - `metadata.maskObjectFields` (its declared seat landed separately; the cast - was stale), and the long-declared `enableOpenApi` (stale residue from - before its declaration). `NormalizedRestServerConfig.api.enableSearch` is - now a required boolean like its siblings. - -No runtime behavior changes: defaults are identical (`enableSearch` on, -masking on per ADR-0106 D8, OpenAPI on); this change moves the keys from -cast-reachable to declared = enforced. diff --git a/.changeset/declare-search-clone-response-contracts.md b/.changeset/declare-search-clone-response-contracts.md deleted file mode 100644 index 2fb9ee7dea..0000000000 --- a/.changeset/declare-search-clone-response-contracts.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/client': minor ---- - -Declare the `search` and `data.clone` route response contracts, and bind the SDK to them (#11924) - -Two of the four SDK routes #8140 had to leave as deliberate `Promise` holes now have real -contracts. Their shapes were always stable and server-produced — they were declared inline on the -implementation (`@objectstack/metadata-protocol`'s `searchAll` / `cloneData`), reachable from no -spec export — and per the maintainer ruling on #11924 they are now declared in `@objectstack/spec` -exactly as produced, with conformance coverage on both the producer and the mounted route (#3877: -no route-ledger `responseSchema` row is filled without conformance coverage; both rows are filled -as part of this change). - -**`@objectstack/spec` (additive):** - -- `SearchAllResponseSchema` / `SearchAllHitSchema` (+ `SearchAllResponse` / `SearchAllHit` types, - `@objectstack/spec/api`) — the WHOLE body of `GET /api/v1/search`, answered bare: - `{ query, hits, totalObjects, totalHits, truncated }` with hits of - `{ object, id, title, snippet?, record }`. ⚠️ Deliberately distinct from `SearchResult` / - `SearchHit` in `@objectstack/spec/contracts`, which type the per-object `ISearchService.search` - (hits of `score` / `document`) — reaching for that same-named neighbour was the near-miss trap - #8140 left a compile-time guard against, and the guard stands unchanged. -- `CloneDataResponseSchema` (+ `CloneDataResponse`, `@objectstack/spec/api`) — the whole 201 body - of `POST /data/:object/:id/clone`: `{ object, id, sourceId, record }`, `CreateDataResponse`'s - structural sibling plus `sourceId` (`id` is the NEW record's, `sourceId` the copied record's). - No `droppedFields` member — unlike `createData`, the clone producer emits none. - -**`@objectstack/client` (return-type narrowing, same nature as the #8140 batch):** `search` is -now `Promise` and `data.clone` is `clone(…): Promise>` -(a new exported interface mirroring `CloneDataResponseSchema`, beside `CreateDataResult`). Nothing -changes at runtime — no request, response, unwrapping or error path is touched — but code that -compiled against the previous `any` returns (arbitrary property reads, assignments to unrelated -types) can stop compiling; in particular a result assigned to the per-object `SearchResult` is now -refused at compile time, which is the trap the erasure used to hide. - -The `automation.create` / `automation.update` pair is explicitly NOT declared here — it returns to -the decision inbox with a consumer-survey reading per the same ruling. diff --git a/.changeset/declared-vs-enforced-permission-set-drift.md b/.changeset/declared-vs-enforced-permission-set-drift.md deleted file mode 100644 index 3103d5214a..0000000000 --- a/.changeset/declared-vs-enforced-permission-set-drift.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/rest': minor ---- - -Surface "declared ≠ enforced" on package-declared permission sets, and give -operators a sanctioned, audited way to discard a stale environment overlay. - -Field report: an rc→GA upgraded environment can freeze a package's -permission set at a stale snapshot while the shipped artifact keeps -shipping grant changes — silently, with only a boot log counter as a -signal. Two independent mechanisms can cause this, and either (or both -together) can be live on one row: - -- **overlay shadow** — a Studio permission-matrix save on a package-declared - set materializes a `sys_metadata` overlay that shadows every later package - edit to that set, forever, surviving redeploys and restarts; -- **provenance skip** — a `sys_permission_set` row whose `managed_by` column - predates package provenance tracking is treated as environment-authored - and never reconciled with the package. - -`sys_permission_set` now carries `drift_status` / `drift_detail`, recomputed -every boot, naming the set and the cause — a new "Needs Attention" Setup -list view surfaces only sets that actually differ from their shipped -artifact (an in-sync set is never flagged; `drift_status` stays `null`). - -A new "Discard Overlay" Setup action (`POST -/api/v1/security/permission-sets/:id/discard-overlay`) removes a stale -overlay and resyncs the record to the current artifact synchronously — the -supported, audited counterpart to the raw-SQL remediation the field report -had to use. It targets package-declared sets only: a set with no current -package declaration is refused, so a genuinely environment-authored set can -never be discarded by name collision. - -Boot-time auto-adoption of legacy rows and a bulk `os meta -adopt-permission-sets` command remain out of scope (2026-08-20 maintainer -ruling) — the manual SQL adoption recipe stays documented for the rc→GA -provenance-skip case; see the ops runbook. diff --git a/.changeset/delivery-not-eligible-gloss-both-surfaces.md b/.changeset/delivery-not-eligible-gloss-both-surfaces.md deleted file mode 100644 index 9cfc404898..0000000000 --- a/.changeset/delivery-not-eligible-gloss-both-surfaces.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`DELIVERY_NOT_ELIGIBLE`'s ledger gloss now describes every surface that raises it, not just `redeliver` - -The `ERROR_CODE_LEDGER` entry for `DELIVERY_NOT_ELIGIBLE` glossed the code as -*"delivery row is in a non-terminal state"*. That named one refusal on one -surface, and the code has since been reused on a second: `INotificationOutbox.ack` -refuses any row that is not `in_flight`, which covers an unclaimed `pending` row -**and** an already-terminal one. So the old wording was not merely incomplete — -it was backwards for half the code's uses, describing terminal rows as the -acceptable ones when `ack` refuses exactly those. - -The reuse itself is the ruled shape, not a defect: one concept — *this delivery -row's state does not permit the requested operation* — on two delivery surfaces, -with a second near-synonym code rejected for the vocabulary sprawl ADR-0112 -exists to prevent. Only the comment lagged. - -The gloss is now stated per surface, because the two refuse opposite halves of -the state space and no single status predicate covers both: - -- **`IHttpOutbox.redeliver`** (`HttpRedeliverError`) refuses a row that is NOT - terminal — `redeliver` means send this again, so it wants - `success`/`failed`/`dead`. It also raises the same code when the producer's - `RedeliverGuard` refuses or itself throws (fail-closed: "we could not check" - must never read as "allowed"), and when the terminal re-check at the write - misses because a dispatcher tick re-claimed the row mid-call. -- **`INotificationOutbox.ack`** (`NotificationAckError`) refuses a row that is - not `in_flight` — both the unclaimed `pending` row (the ack-as-cancel trap) - and the already-terminal one — plus the `SqlNotificationOutbox` compare-and-set - read-back that shows the claim was lost mid-ack. - -Comment only. No code is registered or removed, no wire value changes, and no -acceptance or refusal behaviour moves — `packages/spec` publishes -`src/**/*.zod.ts`, so the corrected gloss ships to consumers reading the ledger. diff --git a/.changeset/derived-capability-existence-read-batched.md b/.changeset/derived-capability-existence-read-batched.md deleted file mode 100644 index 5b6cf7b5cf..0000000000 --- a/.changeset/derived-capability-existence-read-batched.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -perf(security): batch the derived half of `bootstrapSystemCapabilities`, unnarrowed (#11520) - -`bootstrapSystemCapabilities` reconciles two halves. #11451 batched the CURATED -half into one `$in` read carrying the #8470 predicate and left the DERIVED -half — the union of every `systemPermissions` string that nothing declares — -reading one row at a time, so a rebuild cost `1 + derived` round trips. - -That residue was filed rather than fixed for a reason that has since expired. -Two objections stood: narrowing the derived read to the platform bucket answers -a different question and reverses ruled ground, and batching it *unnarrowed* -needed an unbounded read. #11518 removed the second one — `readNamePage` now -asks for one row more than its page budget and reports the overflow as -`truncated` = "could not answer", degrading loudly to the per-item read — so -the wide batched read became bounded without becoming a different question. - -The derived half now consults its own `buildExistingByName` index, built with -**no predicate**: the read emits `{ name: { $in: … } }` under `seedCtx()` -(`{ isSystem: true }`, the same context the per-item read used), and unscoped -`resolveOwnOrganizationRow` returns the FIRST row with no bucket filter — so -the index resolves to the same lowest-`id` row installation-wide that -`tryFind(…, 1)[0]` returned under #4363's `ORDER BY id ASC`. A steady-state -rebuild costs 2 reads at every derived size instead of `1 + derived`. - -⛔ The first objection still stands and is now pinned rather than only -documented: the derived read is **not** narrowed to `organization_id: null`. -Doing so would silence #8751's `platformStampedInOrg` anomaly signal in exactly -the case its doc says it is counted for, and would seed the platform bucket in -the case #8552 ruled must be left alone. A new test asserts the derived read's -key set is `name` and nothing else. - -One behaviour change, in the direction #10946 chose deliberately for the -curated half: a derived name whose existence read **cannot answer** is now -DECLINED (counted in `unreadable`) instead of being read as absent. The old -`tryFind` swallowed a failed read into `[]`, which routed the name to its -insert branch — a duplicate placeholder wherever the read failed but the write -did not, refused only where the unique index happens to exist, and silent -either way because the `blockedCurated` diagnostic is curated-only. The -`unreadable` counter and its summary warning now cover both halves; the warning -reports the whole definition set as its total rather than the curated count. diff --git a/.changeset/destructive-409-force-parity-compound-door.md b/.changeset/destructive-409-force-parity-compound-door.md deleted file mode 100644 index 2d39239e1a..0000000000 --- a/.changeset/destructive-409-force-parity-compound-door.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/rest": minor -"@objectstack/runtime": patch -"@objectstack/metadata-protocol": patch ---- - -**Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095). - -`saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one. - -The maintainer ruled a **split**, and the two halves are not the same fix: - -- **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON. -- **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first. - -For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes. - -The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them. diff --git a/.changeset/dialect-getter-knex-canonical-spellings.md b/.changeset/dialect-getter-knex-canonical-spellings.md deleted file mode 100644 index 538d06f905..0000000000 --- a/.changeset/dialect-getter-knex-canonical-spellings.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`SqlDriver` now recognises knex's own **canonical** client spellings, so `client: 'postgres'` and `client: 'sqlite'` no longer silently lose every dialect-specific behaviour (#11550). `SqlDriverConfig` is `Knex.Config & {…}`, so every client name knex accepts was already declared valid, while `isPostgres` / `isSqlite` enforced two literals each — and `postgres` is the canonical name of the dialect whose registered aliases are `pg` and `postgresql`, `sqlite` likewise for `sqlite3`. Nothing failed on the unrecognised spellings; the driver just emitted the wrong SQL. The sharpest case: `nowColumnDefault` fell through to a bare `CURRENT_TIMESTAMP` default on a `DATE` column, which resolves the calendar day in the **server's** timezone — the exact defect ("a UTC-12 server records YESTERDAY") that method's Postgres branch exists to remove. The three getters, the connect-timeout table, the pg wire-protocol set and the MySQL UTC session pin now derive from one identity source per dialect family instead of four hand-written lists that had already drifted apart. What an already-recognised spelling (`pg`, `postgresql`, `sqlite3`, `better-sqlite3`, `mysql`, `mysql2`) resolves to is unchanged, and `redshift` / `cockroachdb` keep pg **wire** recognition without gaining SQL-**emission** identity — that remains an open support-scope decision (#11756). diff --git a/.changeset/discovery-version-third-producer.md b/.changeset/discovery-version-third-producer.md deleted file mode 100644 index 3670aa940a..0000000000 --- a/.changeset/discovery-version-third-producer.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): `getDiscovery()` serves a derived `version`, not the hardcoded `'1.0'` literal (#11235) - -`ObjectStackProtocolImplementation.getDiscovery()` filled `DiscoverySchema`'s "System -Identity" `version` field with the constant `'1.0'`. The other `DiscoverySchema` producer -— `HttpDispatcher.getDiscoveryInfo()` in `@objectstack/runtime` — filled the *same* field -with its own constant `'1.0.0'` until #10993 derived it. Two producers of one field -disagreeing with each other is what proves neither literal was ever a contract value: if -`version` were a contract, two producers would not each invent their own constant; if it -is not, it should not be hardcoded. That argument needs no opinion about what `version` -"should" be. - -It now resolves the same way its sibling does: an injected `OS_RUNTIME_VERSION` build -stamp, falling back to this package's own installed version, and `'unknown'` only if both -are unavailable — honest about not knowing rather than a plausible-looking constant. One -stamp, one meaning: a deployment that sets `OS_RUNTIME_VERSION` now gets the same answer -from both discovery producers and from `GET /health`, so the two can no longer drift. - -The resolver is a package-local ~10-line copy of `packages/runtime/src/runtime-version.ts` -rather than a shared import: `@objectstack/runtime` depends on -`@objectstack/metadata-protocol`, not the reverse, so importing it would invert the -dependency direction, and hoisting a helper into `@objectstack/types`/`@objectstack/core` -would widen two packages' published surface for two call sites (declined at #11235 -triage). `tsup.config.ts` gains `shims: true` for the same reason -`packages/runtime/tsup.config.ts` carries it — esbuild empties `import.meta` in a CJS -bundle, so without the shim `require('@objectstack/metadata-protocol')` would have fallen -through to `'unknown'` on every consumer. - -No schema shape changed, no field was added, and no export was widened — only where one -field's value comes from. Patch, matching the sibling fix. diff --git a/.changeset/dispatcher-meta-org-scope-url-spelling.md b/.changeset/dispatcher-meta-org-scope-url-spelling.md deleted file mode 100644 index 87f12fb139..0000000000 --- a/.changeset/dispatcher-meta-org-scope-url-spelling.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -The dispatcher `/metadata` transport folds the URL segment before deciding -organization scope — `/metadata/translations/:name` no longer writes to a -different partition than `/metadata/translation/:name` - -Two maps that must agree did not. `protocol.saveMetaItem` folds the path -segment through `canonicalizeMetaRequestType` → `META_URL_TO_SINGULAR`, the -**complete** spelling map, for storage. The dispatcher handed the same string -**raw** to `organizationIdForMetaWrite`, whose `declaresOrgOverride` tolerates -only the manifest-collection spellings — incomplete by design. - -For the two URL-only spellings of `allowOrgOverride: true` types the two -answers diverged. `translation` has no manifest collection key at all; -`email_template`'s is the camelCase `emailTemplates`, so the snake_case plural -the registry derivation adds is URL-only too: - -``` -PUT /metadata/translation/:name → org-scoped row (correct) -PUT /metadata/translations/:name → env-wide row (the defect) -PUT /metadata/email_template/:name → org-scoped row (correct) -PUT /metadata/email_templates/:name → env-wide row (the defect) -``` - -Storage folded both spellings to the same canonical type, so the rows differed -in `organization_id` alone: one item in two partitions, addressed by spelling. -Measured end-to-end through the real dispatcher, protocol and repository — -writing an item under both spellings left **two** `sys_metadata` rows where -there should be one, and the env-wide one is shadowed by every read the -org-active author makes. Persisted, receipted 200, served by nothing. - -`GET /metadata/:type/:name/published` is the smaller second site of the same -class. After the layered overlay consult misses, the fallback reads the -code/package store, which is keyed by canonical type; handed the raw segment it -answered **404** under a recognised plural for an item the singular twin -answered **200** for. - -Both sites now fold through `canonicalMetaUrlType` at the boundary — the -correction the REST `/meta` doors already carry, and the one -`metadata-url-spelling.ts` mandates ("folding happens at the boundary and only -there; the layers below keep reading the single canonical singular"). ⛔ Not by -widening `declaresOrgOverride`: a predicate below the boundary consuming the -URL spelling contract is the repair that module's header forbids. - -Only the scope **argument** is folded. The request `type` stays the raw -segment, exactly as the REST doors leave it — the protocol boundary folds it -itself, and two pre-folds would hide a drift between them from the protocol's -own tests. A type the contract does not map (a plugin-registered kind such as -`webhook`) still reaches the store verbatim: the fold is a lookup, never a -spelling guesser. - -⚠️ Whether real callers reach this transport with plural spellings has **not** -been measured. The REST transport was the measured, user-visible surface; this -one is corrected so the class is closed on both transports rather than one. diff --git a/.changeset/dist-freshness-refusal-derives-package-label.md b/.changeset/dist-freshness-refusal-derives-package-label.md deleted file mode 100644 index c168523726..0000000000 --- a/.changeset/dist-freshness-refusal-derives-package-label.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the dist-freshness refusal derives its package label and build remedy from `pkgDir` (#11250) - -`inspectDistFreshness()` / `inspectBundleFreshness()` in `packages/spec/scripts/lib/dist-freshness.ts` -already take an arbitrary `pkgDir`, but their refusal `cause` strings and the `pnpm --filter build` -remedy line hardcoded `packages/spec` / `@objectstack/spec` regardless of it. Every real caller passed -`SPEC_DIR` until #10969 gave `check:skill-examples` a second surface (`packages/client-react` / -`packages/client`) — confirmed live: a stale-dist refusal on that surface named `packages/spec` while -`packages/spec` was freshly built and `client`/`client-react` were the actually-unbuilt packages, so -following the printed remedy verbatim rebuilt an already-fresh package and re-red identically. - -Both cause strings now interpolate a `packages/` label derived from `pkgDir`'s own path (falling -back to the raw path when the shape doesn't match), and the build-remedy line now reads -`package.json#name` from `pkgDir` (falling back to the same label when it's missing or unparsable). The -freshness verdict itself, and every caller's own `rerun` argument, are unchanged — this is diagnostic -text only. diff --git a/.changeset/doc-when-predicate-cel-fence.md b/.changeset/doc-when-predicate-cel-fence.md deleted file mode 100644 index 8820238254..0000000000 --- a/.changeset/doc-when-predicate-cel-fence.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -Judge `visibleWhen` / `readonlyWhen` / `requiredWhen` examples in the docs corpus -as CEL — where the enclosing structure says which layer they are about - -`{/* os:check */}` blocks are type-checked by `tsc --noEmit`, and every CEL -string is the same type as every other CEL string, so -`visibleWhen: "record.status != 'closed' && user.hasRole('admin')"` type-checked -perfectly. `hasRole` is a CEL function that exists nowhere — it is in no stdlib -registry and on no contract — so the predicate faults at runtime, and a -field-level `visibleWhen` fault is fail-**open**: `resolveFieldRuleState` -evaluates visibility with `fallback: true`, so the element the author wrote the -predicate to hide is shown to everyone who copies the page. That is not a -hypothetical shape — a shipped doc taught it (#11034 fixed the instance). - -`check:doc-formula-expressions` gains this as a third scan surface rather than a -second gate, because two gates with opinions about one contract is the thing -Prime Directive #12 exists to prevent. The verdict is imported whole: syntax, the -unknown-function catch and the bare-reference rule come from -`@objectstack/formula`'s `validateExpression`, and the closed-root rule comes -from `fieldRuleRootIssue` — the same two the metadata walk applies to the same -slot, in the same order, in the same words. - -**The layer is decided first, and a layer that cannot be decided is skipped and -printed.** `visibleWhen` is one key spelling several unrelated contracts, and the -binding root really does differ: an object field binds `record` + `previous` -(+ `parent`), a per-option predicate binds `record` plus the host predicate scope -including `current_user`, a page component binds the user roots and `app`, and a -flow-screen field **flattens its own field names to top level**. A gate keyed on -the key alone would have gone red on -`content/docs/automation/flows.mdx`'s correct `visibleWhen: -'createOpportunity == true'` and on `content/docs/ui/pages.mdx`'s correct -`'sales_manager' in current_user.positions` — and a gate whose reds are wrong is -worse than no gate, because it teaches people to add ignores. - -So admission is structural and schema-backed, never keyed on the key: a -`Field.*({ … })` factory call, or a raw field definition carrying `type:` inside -an object-literal `fields:` **map**. The map-versus-array test is the load-bearing -half and it is read off the schemas — `ObjectSchema.fields` is -`z.record(name, FieldSchema)` while `FormFieldSchema` and `ScreenFieldConfigSchema` -are both `z.array(…)`, so a `fields:` map is the object-field layer and nothing -else, and a `fields:` array is exactly the case that cannot be told apart. - -**The skip list is printed and counted on every run, including green ones.** A -gate that skips in silence is the same false-green one level up, so the summary -names every skipped site and why. Measured on the corpus as it stands: 23 -text-level `*When:` occurrences, of which 13 are admitted and judged, 7 are -listed as skipped, and 3 are the ADR quoting `field.zod.ts`'s schema -(`visibleWhen: ExpressionInputSchema.optional()`) rather than authoring a -predicate. Three of those seven were invisible to an AST-only walk — a bare -`visibleWhen: "…"` line at statement position is a labelled statement, not a -property — so a text-level tripwire reconciles the two counts and any site the -parser never surfaced is listed rather than dropped. - -`@objectstack/lint` newly exports `fieldRuleRootIssue` and -`FIELD_RULE_BOUND_ROOTS`. The field-rule root decision was a closure inside -`validateStackExpressions` — correct while it had one caller, and exactly how a -second caller comes to own a dialect of a rule instead of the rule. Behaviour is -unchanged: the metadata walk now calls the extracted function and its 2271 tests -pass untouched. diff --git a/.changeset/docs-category-index-cards-declared-pages.md b/.changeset/docs-category-index-cards-declared-pages.md deleted file mode 100644 index f2ba123598..0000000000 --- a/.changeset/docs-category-index-cards-declared-pages.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the generated docs category index cards the pages its `meta.json` declares (#11260) - -`content/docs/references/security/index.mdx` carded four of the five pages the -`meta.json` beside it declares. The fifth, `misc`, was generated and routed in -the sidebar — and unreachable from the one page whose job is to reach it. - -Both files come out of one `gen:docs` run, from two enumerations of "the pages -of this category" that disagreed about exactly one bucket: - -- `meta.json` was built from the pages the run **emitted**, which is where a - published schema that no `.zod.ts` accounts for lands (`security/` declares - two in plain `.ts` files, so they fall to the `misc` catch-all); -- the card grid was built from the `.zod.ts` files **on disk**. - -`misc` has no `.zod.ts` behind it by definition — the generator says so twice, -and `sourcePathFor` returns nothing for it precisely so the page prints no -invented "Source:" line — so it was *structurally* absent from the second -enumeration. The card loop never considered it, which also means the -`wasEmitted` guard that the loop's own comment leaned on ("This aligns the -index with `meta.json`") never ran for it. The comment was wrong in the shape -that reads as verified: it named the invariant while the code held it by -coincidence, for the 13 categories where two independent enumerations happen to -agree. - -The grid now iterates the list `meta.json` was built from, and keeps the -`wasEmitted` guard — a `.zod.ts` whose schemas are all unrepresentable in JSON -Schema still cannot be carded into a dangling 404. Because both files now read -one list, that guard can no longer thin the grid silently: a declared page the -run did not emit stops the build naming it. The stated invariant is true by -construction rather than by coincidence, which closes the class instead of -special-casing `misc`. - -The regenerated output is one line: `security/index.mdx` gains its `misc` card -(with no "Source:" line, correctly). The other 13 category grids are -byte-identical. - -An all-`misc` category — every published schema in the catch-all — would have -rendered an **empty** `` grid under the old loop, with every zod-derived -slug filtered out and `misc` never considered. No such category exists in the -repo, so no emitted file can pin it; the rule moved into -`scripts/lib/category-index.ts` so it can be asserted directly, and an empty -grid is now unreachable from a non-empty declaration. diff --git a/.changeset/docs-graphql-stale-references.md b/.changeset/docs-graphql-stale-references.md deleted file mode 100644 index 7bc9a8a910..0000000000 --- a/.changeset/docs-graphql-stale-references.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Docs:** removes stale GraphQL references and stale hand-typed header provenance from generated and hand-kept protocol docs (#10834, #10833). - -GraphQL was retired as a product surface some time ago: `packages/spec/src/api/` has zero GraphQL sources, the `/graphql` HTTP route was removed from the dispatcher (out of the product plan, #2462 follow-on), and `graphql` was never actually a `CoreServiceName` — it only ever existed as a stray entry in this table and in metadata-protocol's discovery table (see the comment above `SERVICE_PROVIDER_TABLE` in `core-services.zod.ts`). Two places in the package still asserted otherwise: - -- The generated `content/docs/references/index.mdx` API Protocol blurb read "REST/GraphQL contracts, …". The source is `CATEGORY_BLURBS.api` in `packages/spec/scripts/build-docs.ts`; fixed there and regenerated with `gen:docs` — no hand-edit to the generated `.mdx`. -- The hand-kept `packages/spec/llms.txt` (no generator; ships in the npm tarball per `files`) listed an `IGraphQLService` contract (execute, subscribe) under Service Contracts. `IGraphQLService` is declared nowhere in `packages/**/src` — verified before removal. Deleted the row rather than marking it `**DEPRECATED**` like the neighbouring `IUIService` row: that precedent fits a contract that has a replacement to point readers at; GraphQL has none — it's out of the product plan, not superseded by another contract — so a deprecation note would invent a migration path that doesn't exist. - -Also dropped this file's hand-typed `Schema Count` / `Last Updated` header lines (`171 Zod schemas, 191 test files, 5,157 tests`, `2026-02-12`) rather than refreshing them. Measured against the current tree: `packages/spec` now publishes 1,585 schemas (per the freshly generated `content/docs/references/index.mdx` root index) across 418 `*.test.ts` files — both roughly an order of magnitude past what the header claimed. Since this file has no generator (confirmed by the filer) and nothing re-verifies these numbers on change, a refreshed count would start drifting again on the very next PR that touches the package; removing the assertion is more honest than restating a number this file has no mechanism to keep true. Whether `llms.txt` should be generated at all is a larger follow-up left to the PM, not decided here. - -Graded rather than skipped: `llms.txt` ships in the `@objectstack/spec` npm tarball (`files`, enforced by `check:published-files`), so this prose change reaches consumers the same way the precedent in #10669 (`skill.tools` docblock) did. diff --git a/.changeset/docs-multishape-variant-subtables.md b/.changeset/docs-multishape-variant-subtables.md deleted file mode 100644 index 19835654ec..0000000000 --- a/.changeset/docs-multishape-variant-subtables.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): reference pages carry every variant's `.describe()` text when a property opens more than one object shape (#12316) - -The designed-and-measured remainder of #11601. #12309 gave a property that -opens exactly ONE nested object shape a `### Nested Shape:` table, and refused -a property whose type is a union of TWO OR MORE object shapes — there was no -single "the shape of this property" for a heading to name, and naming one -would have meant a variant index, i.e. a second addressing notation. Those rows -kept the collapsed signature cell, and the cell has no description column, so -their nested `.describe()` text stayed exactly as unreachable as #11601 found -it. - -**The population, re-measured on `origin/main@7bd6447`.** 28 of the 8604 -rendered property rows open two or more shapes, and **all 28** carry describe -text on at least one variant: `ui/App.navigation`, -`ui/NavigationArea.navigation` and `ui/NavigationContribution.items` with nine -variants each, `data/ConditionalValidation.then` / `.otherwise` and -`system/CRDTMergeResult.state` with five, `system/ChangeSet.operations` / -`.rollback` with seven, down to four two-variant rows. The census is unchanged -from the one #11601 recorded when it deferred them. - -**What is rendered now.** One sub-table per variant, each under the same -`### Nested Shape:` heading in the same position — no new grammar, and no new -heading level, so the single-h1 invariant and the module-header numbering are -untouched. The accessor gains a SELECTOR segment, spliced in where the union -sits in the wrapper stack rather than appended to the finished path, so -`StateMachine.on[string][option 3][number]` reads left to right as *the record -value, its third option, an element of it* — the reading `[string][number]` -already had. - -Two spellings, in preference order: - -- **`[type='sidebar']`** where the union has a discriminant — a key every - shape-bearing variant pins to a *different* literal. It states what the - author writes to select that variant, in the same `formatLiteral` spelling - the Type cell prints two lines above, and it is stable: reordering the union - or adding a tenth variant moves no existing heading and breaks no existing - anchor. 22 of the 28 rows have one. -- **`[option 2]`** for the six that do not — deliberately the word the union - branch has printed under `### Union Options` since long before this, rather - than a bare `[2]`, which in a stack of `[number]`/`[string]` segments would - read as a tuple index into the property's own type. It counts position in the - union *including* arms that open no shape, so the number is checkable against - the `string | { … } | { … }[]` cell the table sits under. - -The distinctness half of the discriminant test is load-bearing: a union whose -arms pin the same `const` has no discriminant by this rule, because answering -one would emit two identical headings — two identical anchors on one page, the -defect the `Schema.key` qualification exists to prevent. - -Every bound #11601 set still holds. Depth stays at `SHAPE_DEPTH_LIMIT = 1` — -what was lifted is the multi-shape *refusal* at level 1, not the budget, so a -shape nested inside a variant is exactly as unreachable as it was inside a lone -shape. "Only where there is text to publish" is now decided **per variant**, -which is the same rule one level finer: `ui/FormView.submitBehavior` opens four -shapes and one carries prose, so it gets one table, not four. And a variant -table still relocates no vocabulary — it is a third position for those keys. - -A property opening exactly one shape gains no selector segment, because the -stamp is conditioned on the union's own yield and not on the row's: all 1469 -single-shape headings #11601 published are byte-identical. The regenerated tree -is **purely additive** — 15 files, **+1397 / -0** lines, no reordering. diff --git a/.changeset/docs-nested-item-shape-tables.md b/.changeset/docs-nested-item-shape-tables.md deleted file mode 100644 index 132459d05d..0000000000 --- a/.changeset/docs-nested-item-shape-tables.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): reference pages carry a nested item shape's `.describe()` text instead of collapsing it into a signature cell (#11601) - -`build-docs.ts` renders a property whose type is an inline object as a -one-line signature — `{ label: string; icon?: string; visibleWhen?: string | -object; value?: string; … }[]` — into a table cell that has **no description -column**. Every `.describe()` an author wrote on a key of that shape was -therefore unreachable from the reference page: not truncated, not marked, -absent. `page:tabs`'s item-level `visibleWhen` carries a ~600-character -contract note whose whole point is that its evaluation environment is **not** -the page-component `visibleWhen` of the same name, and -`content/docs/references/ui/component.mdx` rendered that row with an empty -Description cell. - -The loss was invisible from both sides. `check:docs` compares generated output -with committed output, so it is green forever on prose neither side contains — -measured on the tree before this change, adding a `.describe()` to a nested -item key produced a **zero-line** `gen:docs` diff. - -**The population, measured on the emitted tree.** 1293 property rows across -566 published schemas and 13 of 14 categories open a nested shape; 1208 of them -have at least one key carrying describe text, 7502 described keys in total, -~473 KB of authored prose that reached no page. - -**What is rendered now.** A property that opens exactly one shape, and whose -shape has at least one described key, gets a `### Nested Shape:` table directly -under the Properties table — the same position, addressing and heading level -the `### Allowed Values:` relocation has used since #6225, so the page gains no -second grammar. The heading names the shape with a TypeScript indexed accessor -(`PageTabsProps.items[number]`, `Object.fields[string]`), which is a real -spelling rather than a sigil invented for the docs. - -Four bounds, each measured rather than chosen: - -- **One level**, matching the `SHAPE_DEPTH_LIMIT` budget a cell already spends. - A nested table opens no table of its own. -- **Only where there is text to publish.** A shape whose keys carry no - describe text keeps its cell; a table there would restate the cell in more - space. -- **A union of two or more object shapes keeps its cell.** There is no single - "the shape of this property" to name — the same reason `formatPropertyType` - refuses to relocate a vocabulary out of `Enum<…>[]`. -- **A nested table does not relocate vocabularies.** It is a second position - for those keys, so it elides them the way a `{ … }` summary does. Without - this rule the 288-member `ApiError.code` vocabulary was re-listed under every - nested `error` shape — 20,260 bullet lines across the tree, `api/metadata.mdx` - alone +6097. - -Tombstoned keys are rendered in a nested table, unlike in the cell above it: -`retiredKey()` puts the whole `[REMOVED]` migration prescription in -`description`, and a signature has no column to carry it. - -The regenerated tree is **purely additive** — 143 files, +14195 / -118 lines, -and every one of the 38177 pre-existing lines is still present byte for byte -(the 118 are re-ordering around the inserted sections, not removal). diff --git a/.changeset/docs-root-meta-declared-pages.md b/.changeset/docs-root-meta-declared-pages.md deleted file mode 100644 index a79adecf96..0000000000 --- a/.changeset/docs-root-meta-declared-pages.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the generated docs root sidebar lists categories from the same declared page list `meta.json` and the category index already agree on (#11482) - -`build-docs.ts` writes the docs tree from what should be one answer to "which -categories/pages exist", but the ROOT `content/docs/references/meta.json` (§3 -— the sidebar's top-level category list) was still answering it a third way: - -- a category's own `meta.json` (§2) is built from the pages the run - **emitted**; -- that category's `index.mdx` card grid (§2.5) reads the SAME declared list - (#11260) — no longer a second, independently-derived enumeration; -- the root `meta.json` (§3), until now, filtered on `categoryZodFiles` — the - `.zod.ts` files found **on disk** — a third, independent enumeration. - -A category whose published pages all come from plain `.ts` files rather than -`.zod.ts` ones (the `misc` catch-all class `security/misc` proves is real) has -zero `.zod.ts` files while still publishing a page, a `meta.json` and an -`index.mdx`. The old filter would drop such a category from the sidebar even -though it is fully generated and routed everywhere else — a folder complete on -disk and unreachable from the nav. - -**No category is in that state today** — all 14 have at least one `.zod.ts` -file — so this was a latent defect with no live instance, and the regenerated -root `meta.json` is byte-identical. The filter now reads `categoryMetaPages`, -the same map §2.5 already reads, so all three files answer from one list -instead of three that happen to agree today. The rule moved into -`scripts/lib/root-meta.ts` (`rootCategoryDirs`), pinned directly with the -all-`misc`-category shape that has no instance in the repo — the same move -#11260 made for the category card grid, for the same reason: the defect's -output is an ABSENT sidebar entry, which `check:docs` cannot see any more than -it could see an absent card, and the edge that has no live instance cannot be -pinned from emitted output at all. diff --git a/.changeset/doctor-organizations-hint-pinned.md b/.changeset/doctor-organizations-hint-pinned.md deleted file mode 100644 index 94a89c0021..0000000000 --- a/.changeset/doctor-organizations-hint-pinned.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -chore(cli): pin the multi-org runtime name `os doctor` prints, so a roster rename cannot drift it silently (#12464) - -`doctor.ts`'s `TENANCY_POSTURE_FIX_HINTS` repeated `serve`'s `isolated` posture -sentence verbatim, carrying its own bare `@objectstack/organizations` literal -under no check at all. PR #12463 single-sourced and pinned every operator-facing -occurrence in `serve.ts`; this copy was outside that card's file surface and was -correctly left alone. The defect it left behind is the same class: a roster-key -rename would leave `os doctor` naming a package that boot no longer resolves, -**with every gate green** — the roster pin only ever sees the declaration, and -nothing read this hint table's text. - -The `isolated` hint now interpolates a module-level `ORGANIZATIONS_RUNTIME_PKG` -in `doctor.ts`, and a new sibling test pins it on two legs: the **rendered** -bullet compared whitespace-included against text built from that declaration -(the #12463 shape — asserting what the operator sees, never that a constant -appears in source), and a **roster** leg asserting that declaration IS a key of -the spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES`. The roster leg is the load-bearing -one: without it the hint and its expectation move together under a rename and -nothing goes red. - -**This deliberately does not single-source the spelling, and the constant's -docblock says so at the site.** The literal is still declared three times (the -roster key, `Serve.ORGANIZATIONS_RUNTIME_PKG`, and now this const). The roster -cannot supply the name — it is keyed BY package name, its row type carries no -`package` field by design, and its own header records that it is "not a -resolution registry" — and importing `serve`'s export into a diagnostic command -would be a worse coupling than the duplication it removes. What changes is that -this copy can no longer drift in silence. The duplication ends properly when a -shared tenancy-hint table lands (tracked at #12492); the docblock carries that -deletion condition. - -**No behaviour change.** The declared value is byte-identical to the literal it -replaces and the rendered bullet is unchanged. diff --git a/.changeset/drift-detect-multi-value-base-type-mismatch.md b/.changeset/drift-detect-multi-value-base-type-mismatch.md deleted file mode 100644 index bf8b6eef9f..0000000000 --- a/.changeset/drift-detect-multi-value-base-type-mismatch.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -Report a multi-value field left on a stale `varchar`/`text` column, instead of -letting it silently corrupt every array written to it - -A field that gains `multiple: true` materialises as a `json` column on a fresh -database, but `initObjects` is additive-only: on a database created while the -field was single-value, nothing is missing, so nothing is added and the old -`varchar`/`text` column is kept forever. The write path stringifies the array -for a json field on every non-SQLite dialect; the read path relies on the -driver's column-type-based decoding, which a stale textual column defeats. The -array goes in as the literal `["id1","id2"]` and comes back as a **string** — -so a hook copying the value into a child record's single-lookup column writes -that whole string as one id. User-filed production report, repaired by hand on a -live database. - -Until now the schema-drift detector said **nothing** about it. Measured on the -pre-fix tree against live Postgres 16.13 and MySQL 8.0.46: after the metadata -change and a reboot, `detectManagedDrift()` returned `[]` and the boot logged -zero `[schema-drift]` lines, while the very next write stored -`["user_A","user_B"]` into a `character varying(255)` column and read it back -with `typeof === 'string'`. The action vocabulary had no "the base type is -wrong" entry at all — only `relax`/`tighten_not_null`, `widen`/`narrow_varchar`, -`drop_column`, `drop_column_default` and the index ops. - -The divergence is now **detected and reported**, naming the table, the column, -the declared type, the physical type and the exact statement an operator runs by -hand — dialect-correct, and executed against both live servers by the suite -rather than merely printed. ObjectStack does **not** change the column: an -`ALTER TABLE … TYPE json USING …` over existing rows with an index drop and -rebuild is a destructive migration over shipped data, and whether the platform -should perform it is a separate, open decision. The new `manual_column_type_change` -op deliberately has no reconciler arm; `applyMigrationEntries` reports it as -skipped, which is the intended contract while that decision is open. - -Reported at severity `error` and category **`needs_confirm`**, and the category -is load-bearing rather than cosmetic. Every database this finding describes is -already serving — that is the premise of the report — and the artifact-pinned -boot gate refuses a boot for `category === 'destructive'` and nothing else -(`severity` it never reads). Measured both ways: a `destructive` entry returns -`ok=false` from that gate, this entry returns `ok=true`. Spelling it -`destructive` would have turned every affected deployment into a crash-loop on -its next restart — the report of the corruption becoming the outage. - -SQLite is deliberately excluded, and the exclusion is a measurement rather than a -scoping convenience: the same stale column reads back as a real `['x','y']` -array there, because SQLite's read path `JSON.parse`s regardless of what the -column calls itself. There is no corruption to report, and reporting it anyway -would put a permanent `error` finding on every long-lived SQLite development -database. A stale `integer`/`timestamp` column is excluded for the mirror-image -reason — the server already refuses that write loudly, so there is no silence to -break. - -Also fixed, same defect class: a multi-value field that *also* declared -`maxLength` used to produce `narrow_varchar` at severity `error`, category -**destructive** on both enforcing dialects — a finding that refuses the -artifact-pinned boot and invites `os migrate apply --allow-destructive` to -rewrite the column to `varchar(50)`, the exact opposite of the repair it needs. -`createColumn` returns at its `multiple` branch before `maxLength` is ever read, -so the emitter never asks for that width; the differ no longer does either. The -single-value width branch is untouched and pinned as untouched. diff --git a/.changeset/drift-multi-value-column-names-remedy-command.md b/.changeset/drift-multi-value-column-names-remedy-command.md deleted file mode 100644 index d1ca89503b..0000000000 --- a/.changeset/drift-multi-value-column-names-remedy-command.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -The stale multi-value column warning now names `os migrate multi-value-columns`, -instead of telling operators ObjectStack will never fix the column - -The finding that reports a multi-value field left on a stale `varchar`/`text` -column opened its remedy with **"ObjectStack will NOT change this column for -you. Migrate it by hand"** and then printed raw SQL. That was true when it was -written and became false the moment `os migrate multi-value-columns` shipped: -there is now an operator-run command that does exactly this, with a dry run as -the default, a confirmation prompt, and a post-run re-detection that exits -non-zero if the finding has not cleared. Operators were being sent to hand-write -DDL on a production table while the safer route sat one command away, unnamed. - -The message now leads with the command and keeps the hand-run statement after it -for anyone without the CLI. Both surfaces an operator meets this on pick the -change up, because both print `message` verbatim: the boot warning -(`[schema-drift] …` on every restart) and `os migrate plan`. - -What has **not** changed is what the finding gates. It stays `severity: 'error'`, -`category: 'needs_confirm'` — the artifact boot gate refuses a boot on -`category === 'destructive'` and on nothing else, and every database this finding -describes is already serving, so making the report louder must never be the thing -that stops one from starting. No load-time or write-time refusal was added; the -platform still never migrates the column on its own, per the ruling that it warns -and ships an explicit operator-run migration rather than altering a customer's -production table unattended. - -The dialect-specific statement stays embedded **verbatim**, which is a contract -rather than formatting: a `ManagedDriftEntry` carries no dialect, so the CLI -command recovers one by testing which dialect's statement the message contains. -That coupling is now pinned from the emitting side as well as the consuming one. diff --git a/.changeset/driver-sql-aggregate-backend-fault-envelope.md b/.changeset/driver-sql-aggregate-backend-fault-envelope.md deleted file mode 100644 index 61b416782f..0000000000 --- a/.changeset/driver-sql-aggregate-backend-fault-envelope.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -fix(driver-sql): `aggregate()` joins the enveloped read exits — a dialect error it -cannot attribute now leaves as `DATABASE_ERROR` / 500 instead of raw (#11455) - -`SqlDriver.aggregate()` executed its statement **bare**. Every dialect error the -backend raised left the driver as the backend's own error object: a `code` from -the backend's vocabulary, **no `status`** at all, and a message opening with the -compiled statement. `find()` and `count()` have carried the terminal ADR-0112 -envelope since #8931; this third read door was simply never given it. - -Measured on live PostgreSQL 16.13. The driver maps a `boolean` field to a real PG -`boolean` column and `SQL_AGGREGATE_FUNCTIONS` lowers the arithmetic aggregates to -a bare function name with no cast, so an ordinary analytics shape — a rate measure -over a flag column — reached the server as `avg("flag")`: - -``` -sum(flag) => THREW code=42883 status=undefined - msg=select sum("flag") as "n" from "…" - function sum(boolean) does not exist -``` - -A raw `42883` is on no list `@objectstack/rest` reads, so with `status` undefined -a caller-shaped mistake was logged as an **unhandled server fault**, and the -statement's shape travelled to the caller with it. - -`aggregate()` now composes the same `backendStatementFaultError` its two siblings -do: `DATABASE_ERROR` / 500, asserting exactly one thing — *the backend would not -run this statement* — with the dialect's own diagnostic written to the **server -log** rather than the caller's message, and the original error kept as a -non-enumerable `cause` so `isMissingTableError` and every other cause-following -predicate stay truthful. - -**No new error code.** ADR-0112 D3/D4 closed the `StandardErrorCode` vocabulary, -and D2's 2026-08-18 amendment retired three members on the reasoning that an -unreachable-but-declared code teaches a branch that can never fire. The code here -is the catalogued member the sibling read exits already answer with. - -**This is the envelope half only, and it decides no contract.** Whether the -platform should *answer a number* for an arithmetic aggregate over a boolean (by -casting in the lowering) or *refuse* is #11152's question, and #11249's for -`min`/`max`. Nothing here pre-empts it: the envelope is raised from the **exit**, -not from recognising `42883` or any wording, so it holds whichever way that card -is ruled — and the three dialects' arithmetic answers are deliberately left -unpinned (measured 2026-08-24: SQLite and MySQL's `tinyint(1)` both answer, -Postgres refuses). - -Unchanged, and pinned as controls: the precise refusals this door already -composed — an undeclared function (`INVALID_QUERY` / 400, #5907), a -`count_distinct` with no `field` (`INVALID_QUERY` / 400, #6409), a -per-aggregation `filter` (`NOT_IMPLEMENTED` / 501, #10576) — are all raised while -the statement is *built*, upstream of the guarded execution, so none can be buried -under the generic envelope. The accept set does not move: every condition that now -takes the envelope failed before this change and fails after it. diff --git a/.changeset/driver-sql-introspect-fk-schema-scope.md b/.changeset/driver-sql-introspect-fk-schema-scope.md deleted file mode 100644 index 5439ed1832..0000000000 --- a/.changeset/driver-sql-introspect-fk-schema-scope.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): scope the Postgres `introspectForeignKeys` catalog read to the session's own schemas (#11201) - -The Postgres arm queried `information_schema.table_constraints` with -`tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ?` and **no `table_schema` -predicate at all**. Those views span every schema the session has privilege on, -independently of `search_path`, so a table name that exists in more than one schema had -all of their foreign keys merged into a single answer — including foreign keys from -schemas the session can never reach unqualified. - -That is a wrong answer rather than a missing one, and it is consumed as fact: -`introspectSchema` hangs the result on the table it just listed, and from there it reaches -federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift -comparison. A phantom foreign key makes a drafted federated object reference a table it -does not reference. - -The fix is the pin the rest of the family already carries — -`AND tc.table_schema = ANY (current_schemas(false))` — spelled and placed exactly as -`introspectUniqueConstraints` spells it, which in turn follows `introspectSchema`'s own -table listing. `introspectForeignKeys` was the last unscoped introspection arm; the two -`pg_index`-based arms (`introspectIndexes`, `introspectPrimaryKeys`) reach the same scoping -from the other side by resolving the name to an OID through `regclass`. No interface shape -and no accepted input changes: a same-named table in another schema simply stops -contributing foreign keys it never should have contributed. - -Measured on a live PostgreSQL 16.13. The regression pin -(`sql-driver-11201-introspect-fk-schema-scope.test.ts`) builds the collision the repo's own -live-PG isolation (#9350, one schema per test file in one database) already makes routine: -two same-named tables in two schemas, each with a different foreign key. It first asserts -the pre-fix predicate really sees both constraints — so the interesting assertion, an -absence, cannot go green on a fixture that never collided — then requires the arm and -`introspectSchema` to return only the current schema's. Reverse-verified: with the -predicate reverted the pin fails with the neighbour's foreign key present in the answer. - -The MySQL arm of the same method was checked and is not affected: it already pins -`TABLE_SCHEMA = DATABASE()`. SQLite has no schemas. diff --git a/.changeset/driver-sql-keyed-text-maxlength.md b/.changeset/driver-sql-keyed-text-maxlength.md deleted file mode 100644 index a7339c01e6..0000000000 --- a/.changeset/driver-sql-keyed-text-maxlength.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -**Fix:** a text-family field that a declared index keys on is emitted as `varchar(maxLength)` instead of an unbounded `TEXT`, so the index MySQL previously refused can actually be created (#11374). - -`createColumn` mapped the whole text family (`text` / `textarea` / `html` / `markdown`) to an unbounded `TEXT`, ignoring the field's own declared `maxLength`. MySQL refuses a `TEXT`/`BLOB` column in a key without a prefix length, and the two halves of schema-sync fail *separately*: the `CREATE TABLE` succeeds, then `ALTER TABLE … ADD [UNIQUE] INDEX` fails with `ER_BLOB_KEY_WITHOUT_LENGTH`. The table therefore lands on disk **without the constraint it declared**, and the object stays registered-but-broken. Measured on a live MySQL 8.0.46: **36 of the 44 platform objects** failed schema-sync this way, so a stack whose `default` datasource is MySQL could not stand up its own schema — the dev-admin seed never landed and first sign-in returned `401 INVALID_EMAIL_OR_PASSWORD`. Honouring the declared bound takes that to **12**. - -**The bound is the field's own `maxLength` — nothing is invented.** `schema-drift.ts` already treated `varchar(field.maxLength)` as the expected physical shape of a bounded field (its `widen_varchar` / `narrow_varchar` ops say so in as many words); this is the emitter finally agreeing with the differ. On MySQL that removes a permanent destructive drift finding: `columnInfo()` reports `maxLength: 65535` for a `TEXT` column, so every bounded text field already reported `narrow_varchar` ("metadata caps at 32 chars but the column allows 65535") against a column the driver itself had created. - -**Scope, both halves load-bearing.** The bound is emitted only for a column some declared index **keys on** — a non-indexed `Field.text({ maxLength: 65000 })` stays `TEXT`, because `varchar(65000)` on utf8mb4 is 260000 bytes and would blow MySQL's 65535-byte row limit, turning a working table into an un-creatable one. And only where the bound is **usable as a key part**: `maxLength` absent, or wider than 768 characters (3072 index bytes ÷ 4 bytes per utf8mb4 character — measured: `varchar(768)` takes a unique index, `varchar(769)` is refused with `ER_TOO_LONG_KEY`), leaves the column `TEXT` and the index refused with a message naming the field and the declaration that fixes it. - -**⚠️ Graded `minor`, not `patch`: this changes declared behaviour on newly created tables.** A keyed bounded text column now enforces its declared length where the dialect enforces `varchar` (Postgres and MySQL), so a write longer than `maxLength` that previously landed in an unbounded `TEXT` is now refused — under `STRICT_TRANS_TABLES`, with `ER_DATA_TOO_LONG`. That is the declaration becoming enforced rather than a new restriction, and it is exactly what makes the column indexable, but it is a behaviour change and is named here as one. **Existing tables are unaffected**: schema-sync is additive and never rewrites a column that is already present. - -**A prefix index is deliberately NOT substituted for an unkeyable column.** For an ordinary index that would be a transparent access-path choice, but for a `UNIQUE` one it silently replaces the declared constraint with a stricter one — uniqueness of the *prefix*. Measured on MySQL 8.0.46 with `UNIQUE KEY (token(191))` and two distinct 200+ character tokens sharing their first 191 characters: the second insert was rejected with `ER_DUP_ENTRY` **even though the tokens differ**. On `sys_session.token` that is a valid sign-in refused as a duplicate. The refusal an operator can read is strictly better than a constraint that quietly means something else. diff --git a/.changeset/driver-sql-mysql-fk-introspection-ordinal-order.md b/.changeset/driver-sql-mysql-fk-introspection-ordinal-order.md deleted file mode 100644 index 82aae4191c..0000000000 --- a/.changeset/driver-sql-mysql-fk-introspection-ordinal-order.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): order the MySQL `introspectForeignKeys` read by the key ordinal (#11379) - -`SqlDriver.introspectForeignKeys`' MySQL arm read `information_schema.KEY_COLUMN_USAGE` -with no `ORDER BY`. `ORDINAL_POSITION` is the key ordinal and was selected by neither the -projection nor an order clause, so the row order of a composite foreign key's columns was -whatever the query plan happened to yield. - -That order is load-bearing. `IntrospectedForeignKey` is a flat per-column record with no -ordinal field, so a composite key is expressed as **ordered sibling rows** — `(x, y) -references p (a, b)` is `x -> p.a` then `y -> p.b`, and there is nothing for a consumer to -recover the position from if the rows arrive permuted. The Postgres arm pins this with -`ORDER BY … k.ord`; the MySQL arm was leaving it to the optimizer. - -This is a determinism fix rather than the repair of a wrong answer, and the measurement is -what distinguishes the two. On MySQL 8.0.46, a foreign key declared out of column sequence -— `foreign key (second_col, first_col) references ooo_parent (pa, pb)` — came back in key -order through this predicate with no `ORDER BY` at all. But on the same server, in the -same session, over the same view, the sibling `introspectPrimaryKeys` predicate -(`CONSTRAINT_NAME = 'PRIMARY'`) returned an out-of-sequence primary key in **column** -order — `carrier_code` at ordinal 2 ahead of `shipment_id` at ordinal 1. `KEY_COLUMN_USAGE` -therefore does not preserve the ordinal for free on this server: which of the two orders -you get is decided by the `WHERE` clause, and nothing declared that. The foreign-key -predicate was on the lucky side of a choice nobody made. - -Consumers that read composite foreign keys through `introspectSchema` — federated-object -codegen, the persisted `external_catalog` (ADR-0015), and schema-drift comparison — now get -the declared key order from MySQL by construction rather than by plan choice. diff --git a/.changeset/driver-sql-single-column-unique-introspection.md b/.changeset/driver-sql-single-column-unique-introspection.md deleted file mode 100644 index e7315e8ecf..0000000000 --- a/.changeset/driver-sql-single-column-unique-introspection.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): `introspectUniqueConstraints` reports single-column uniqueness on all three dialects (#11202) - -`SqlDriver.introspectUniqueConstraints` returns a flat `string[]` that -`introspectSchema` folds into a per-column `isUnique` flag, and the three dialect arms -disagreed about what that list meant. SQLite pushed a column only when the unique index -had exactly one column; the Postgres and MySQL arms returned **every member of every -composite constraint**. So for `UNIQUE (a, b)` the same table read through Postgres -claimed `a` alone is unique *and* `b` alone is unique — a claim the constraint does not -make — while through SQLite it claimed neither. - -The divergence was latent rather than active until recently: the Postgres arm's query -selected `c.column_name` with no alias `c` in scope, and the bare `catch {}` the method -carried until #11161 turned every execution into `[]`. Live Postgres had therefore never -once reported a unique constraint through this method. Repairing that query is what put -three dialects into conflict on live systems for the first time. - -Per maintainer ruling 2026-08-23 (option A→B), the flag is now narrowed to -**single-column uniqueness only**: a column is reported iff some unique constraint covers -that column and nothing else. A composite constraint's members are deliberately absent — -a per-column boolean is structurally unable to say "a and b are unique *together*", so -setting it on both members asserts something different and false. Representing composite -constraints is option B and waits for real demand; until it exists, an absent flag on a -composite member means "not single-column unique", never "no constraint". - -All three arms now normalise their rows to a `UniqueConstraintMember` and decide through -one predicate, so a fourth dialect cannot quietly acquire a fourth meaning. The Postgres -arm additionally selects `constraint_schema` and keys constraint identity on -`(schema, name)`: its answer spans `current_schemas(false)` and Postgres auto-names a -unique constraint after the table and column, so two same-named tables in two schemas -produce two different constraints under one name — keyed on the name alone they would -fuse into an apparent two-member constraint and drop a genuinely single-column unique -(the #11201 defect class, one method over). - -Two smaller corrections ride the same rewrite, both in the SQLite arm's handling of -`PRAGMA index_info` rows: an expression-index term (`… ON t (lower(a))`) reports -`name: null`, which the arm used to push into a `string[]` as a literal `null` — it is -now discarded, while still counting toward the index's width so `(d, lower(e))` cannot -read as single-column; and the returned columns are de-duplicated, so a column carrying -both a `UNIQUE` clause and a hand-made unique index is named once. - -No interface shape and no accepted input changes, and `isUnique` is only ever *set* to -`true`, so a column that stops being flagged carries `undefined` exactly as an -unconstrained column always has. The one in-tree consumer is -`introspectedSchemaToObjects` in `@objectstack/objectql`, which turns the flag into a -drafted field's `unique: true` — it is the direct beneficiary: composite members no -longer draft fields declaring a single-column uniqueness the database never enforced. - -Verified on embedded SQLite, including the consumer-visible `introspectSchema` fold; the -live Postgres and MySQL cells are declared through the shared dialect matrix and run in -the `Temporal Conformance (live PG + MySQL)` job. The narrowing predicate is pinned -directly against each dialect's real row shape, so the Postgres and MySQL decision is -measurable without a provisioned server. Reverse-verified by ablation: with the width -filter removed, 9 of the new pins fail — the Postgres and MySQL row-shape cases, the -end-to-end SQLite cell, and the `isUnique` fold. diff --git a/.changeset/driver-sql-update-stamp-precision.md b/.changeset/driver-sql-update-stamp-precision.md deleted file mode 100644 index 099f3e0a85..0000000000 --- a/.changeset/driver-sql-update-stamp-precision.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -fix(driver-sql): stamp `updated_at` at the audit column's own precision on MySQL, so an updated row stops reading as modified BEFORE it was created (#11224) - -`createAuditTimestampColumn` builds the audit columns on MySQL as `DATETIME(3)` -defaulted with `now(3)`, and its docblock says why in as many words -("`CURRENT_TIMESTAMP` has to carry matching precision for a `DATETIME(3)` -default", #3942). `updatedAtStamp()` — the value every UPDATE door writes into -that same column — was a bare `knex.fn.now()`, which compiles to an unqualified -`CURRENT_TIMESTAMP` that MySQL truncates to whole seconds. So the column was -created at millisecond precision on purpose and then written at second precision. - -Measured on live MySQL 8.0.46, against the exact schema the driver produces: - -``` - created_at updated_at delta -before CURRENT_TIMESTAMP 2026-08-23 10:22:36.799 2026-08-23 10:22:36.000 -799 ms -after CURRENT_TIMESTAMP(3) 2026-08-23 10:22:36.799 2026-08-23 10:22:36.802 +3 ms -``` - -Nothing errors. Three silent consequences, in ascending order of damage: - -1. **"Last modified" precedes "created".** Any consumer comparing the two — an - audit answer, a "modified since creation?" badge, a data-quality check — - reads a row that WAS modified as if it were not. -2. **A delta / incremental sync SKIPS the row.** A cursor held at millisecond - precision (`updated_at > cursor`) misses every row whose stamp was truncated - back below it. Measured: all six rows in the new suite's §2 were invisible to - their own cursor immediately after being updated. This is the same - silent-wrong-answer family as #11067 / #11176 / #11223, reached by a fourth - mechanism. -3. **Two updates in the same second are indistinguishable**, so an - `order by updated_at` over them is unstable exactly where it matters most. - -The fix is the expression #11176 had already derived and measured for the UPSERT -door: `now(3)` on MySQL, unchanged elsewhere. Every UPDATE door reads one helper -(`update`, `updateMany`, `rotatedUpdateById`), so all three move together. - -**Postgres and SQLite emit byte-identical SQL to before, and that is a -measurement rather than an assumption.** Postgres' `CURRENT_TIMESTAMP` is -`transaction_timestamp()` at microsecond precision against a `timestamptz` -column; SQLite's stamp is a JS ISO-8601 string that already carries millis. -Neither has anything to truncate. The new suite runs every cell on SQLite AND on -live Postgres AND on live MySQL, and its §5 pins which expression each dialect -gets — so a future "just add `(3)` everywhere" cannot satisfy the ordering -assertions while changing the SQL the other two dialects emit. In the baseline -run against the unfixed driver, the SQLite and Postgres cells were green (7/7 -each) and only the MySQL cell was red (6 of 7). - -**BREAKING**, narrowly, and the reason this is not a patch: the `protected` -`upsertUpdatedAtStamp()` that shipped in 17.2.0 with #11176 is **removed**. It -existed only to hold the precision-matched form for the upsert door without -changing the SQL every `update()` emits — a split that card made deliberately -because it had not measured the UPDATE door. This one measured it, so the pair -collapses back into the single `updatedAtStamp()`, which now carries the matched -precision for both doors. A subclass of `SqlDriver` that OVERRODE -`upsertUpdatedAtStamp()` would otherwise have kept compiling while silently -ceasing to be called, which is precisely the failure mode a release note has to -name out loud. Such a subclass should override `updatedAtStamp()` instead; the -two in-repo subclasses (`SqliteWasmDriver`, `TursoDriver`) override neither and -are unaffected. Nothing else was removed or renamed, no authored metadata -changes, and no public API moves — under this repo's launch-window convention -(breaking changes ship as `minor` while the stack versions in lockstep) `minor` -is the honest slot. - -Stored data is not rewritten. Rows updated before this change keep their -truncated `updated_at`; the ordering invariant holds from the next write onward. - - diff --git a/.changeset/element-form-lint-residue.md b/.changeset/element-form-lint-residue.md deleted file mode 100644 index 857779abc1..0000000000 --- a/.changeset/element-form-lint-residue.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): drop the `element:form` entry from `COMPONENT_FIELD_SPECS` (#9249) - -The whole `element:form` element retired at element grain (ADR-0049 — no -renderer ever shipped for it; the #9220 shape one element over), so every -`ElementFormProps` key is a `retiredKey()` tombstone and no spec-conformant -page carries `fields` on it. The field-binding rule's job (resolve a field -NAME against the object) is not the question a retired key raises: an authored -key is already reported by name with the element-retirement prescription — -which names the live replacement, the object-bound `object-form` block — -through the #5068 props gate, and the binding entry would only add a second -finding about a key that no longer exists — the #5775/#6629 residue class the -package's own `component-field-specs-liveness` gate refuses. diff --git a/.changeset/element-form-retired.md b/.changeset/element-form-retired.md deleted file mode 100644 index 7c5c0ec9a7..0000000000 --- a/.changeset/element-form-retired.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the `element:form` element at element grain (#9249, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`element:form` never had a renderer or reader anywhere — the #9220 -(`element:filter`) shape one element over, recorded by that card's own verdict -sweep. Measured at retirement (objectstack `dd84ddd796`, objectui `3ece13e33`; -cloud per the origin card's two recorded readings at `5f1bf23f` and `a11458b`, -positive controls passing): objectui registers no renderer for it — its -`renderers/basic/elements.tsx` header deferred the element to "owning plugins" -that never materialized — and Studio's designer palette carries it as a -no-renderer `PALETTE_EXCLUSIONS` entry that names the live replacement ("no -renderer — use the object-bound `object-form` block"). The 2026-06 -page-liveness audit already recorded it rendering "Unknown component type". So -the full form contract `ElementFormPropsSchema` declared — `object`, `fields`, -`mode`, `submitLabel`, `onSubmit` (CEL), `aria` — was a capability claim -nothing kept. - -**What is refused:** every authored `element:form` key. All six are -`retiredKey()` tombstones, so authoring one is a `tsc` error and a parse error -carrying the prescription (fully-qualified key, why the element is dead, the -replacement, the `os migrate meta` pointer), dispatched through the KEPT -`ComponentPropsMap` row — deleting the row would demote the type to an -unregistered custom string the #5068 props gate deliberately skips. - -**What stays accepted:** a bare `element:form` node with empty `properties` -(the migrated shape — the open `type` union accepts any string, and deleting -authored page nodes is a layout decision a mechanical conversion must not -make). It renders nothing, exactly as it always did. - -The retirement kit: - -- `retiredKey()` tombstones for all six keys at the schema - (`packages/spec/src/ui/component.zod.ts`); the `PageComponentType` enum - drops the value (de-advertisement — the open string arm still accepts it) -- ADR-0087 registration: six retired-key entries - `ui/ElementFormProps:{aria,fields,mode,object,onSubmit,submitLabel}` and the - D2 conversion `element-form-removed` (protocol 18), wired into the step-18 - chain — `os migrate meta --from 17` strips all six keys from authored - `element:form` blocks (pure lossless deletes; none ever had an effect to - lose) and leaves the bare node -- pin tests (`component.test.ts` — refusal pins assert the prescription per - key; a positive pin parses the bare migrated node clean and asserts nothing - materializes; the kept-map-row pin flips from parse to refusal) -- `packages/lint`'s `COMPONENT_FIELD_SPECS` entry drops (its own changeset); - the component-translation `submitLabel` describes stop naming `element:form` - as carrier (the orphaned-key decision is #10926, out of this card's scope) -- generated baselines/docs follow the schema (authorable surface/defaults, - spec-changes, upgrade guide, reference docs) -- objectui's `elements.tsx` comment and Studio palette-exclusion lines are - cross-repo and already queued under objectui#4935 - -## FROM → TO - -```ts -// before — parsed green; nothing anywhere rendered it, so the page showed -// "Unknown component type" where the author expected a form -{ - type: 'element:form', - properties: { - object: 'lead', - fields: ['name', 'email'], - mode: 'create', - submitLabel: 'Create Lead', - }, -} - -// after — use the object-bound `object-form` block (#7751): rendered, -// designer-publishable, same intent (`objectName`, `fields`, `mode`, -// `submitText`) -{ - type: 'object-form', - properties: { - objectName: 'lead', - fields: ['name', 'email'], - mode: 'create', - submitText: 'Create Lead', - }, -} -``` diff --git a/.changeset/eleven-introspect-contract.md b/.changeset/eleven-introspect-contract.md deleted file mode 100644 index 53f6e8c0ac..0000000000 --- a/.changeset/eleven-introspect-contract.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -The engine-registration road into datasource introspection now meets the compiler (#11493, extending the #11123 ruling from the `DatasourceDriverHandle` seam): `IDataDriver` gains an optional `introspectSchema?(): Promise` member, and `IDataEngine` gains an optional `introspectDatasource?(datasource: string): Promise` member. Both are typed with the spec's one introspection shape (`IntrospectedSchema`, `@objectstack/spec/contracts`). Drivers and engines without introspection stay conformant — the members are optional — while a driver that DOES implement `introspectSchema` with a mis-shaped result (a column flag spelled `isPrimary`, a bare `{ tables }` with no `dialect`/`introspectedAt`) now fails compile at the offending field instead of surfacing at runtime as a federated table whose records cannot be located. diff --git a/.changeset/eleven-introspect-engine.md b/.changeset/eleven-introspect-engine.md deleted file mode 100644 index c3db78e862..0000000000 --- a/.changeset/eleven-introspect-engine.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -`ObjectQL.introspectDatasource()` declares its real return type — the spec's `IntrospectedSchema` (the new `IDataEngine.introspectDatasource?` contract member) — instead of an untyped `Promise`, and the driver lookup inside it drops its `as any` now that `IDataDriver` declares `introspectSchema?`. Type-level only; runtime behaviour is byte-identical (#11493). diff --git a/.changeset/eleven-introspect-plugin.md b/.changeset/eleven-introspect-plugin.md deleted file mode 100644 index 3a41edfd34..0000000000 --- a/.changeset/eleven-introspect-plugin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/service-datasource': patch ---- - -`ExternalDatasourceServicePlugin` types the `'data'` service with the real engine contract (`IDataEngine`, `@objectstack/spec/contracts`) and deletes its private structural `DataEngineLike` re-declaration — the workaround the untyped `IDataEngine.introspectDatasource()` forced (#11493). The introspection fallback branch now probes `getDriverByName?` (the registry member the contract declares) instead of `getDatasourceDriver?`, a spelling no engine in either repository ever had, so the degradation path is reachable for the first time. diff --git a/.changeset/eleven-maps-move-home.md b/.changeset/eleven-maps-move-home.md deleted file mode 100644 index 569dbbbd9d..0000000000 --- a/.changeset/eleven-maps-move-home.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": patch ---- - -Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须 -schema-free」) for `@objectstack/core`'s plural→singular store-key fold. - -`@objectstack/spec`: the `defineStack()` manifest-collection vocabulary -(`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`, -`singularToPlural`) moved to a schema-free module and is now ALSO exported -from the sanctioned schema-free entry `@objectstack/spec/meta-spelling` -(widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared` -keeps the same four symbols as re-exports — no consumer-visible removal. The -manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts -(#8424). - -`@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves -from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so -browser consumers of `@objectstack/core` (every `@objectstack/client` bundle) -no longer link the zod schema closure through the store-key fold. diff --git a/.changeset/engine-findone-predicate-guard.md b/.changeset/engine-findone-predicate-guard.md deleted file mode 100644 index bf712f1cb2..0000000000 --- a/.changeset/engine-findone-predicate-guard.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/objectql": minor ---- - -feat(metadata-core,objectql): publish `assertEngineFindOnePredicate` — the read-side member of the engine-double contract family (#11957) - -`ObjectQL.findOne` applies `limit: 1`, so a query naming no particular record -would return an ARBITRARY row. `requireFindOnePredicate` (#4419) REFUSES that -call. Every in-memory test double in the repo instead read an absent filter as -"match everything" and answered happily, so a production call site that violates -#4419 read as *working* under every unit suite and only failed on a real engine. - -That is measured, not hypothetical. `AuthManager.isBootstrapCreation` probed the -bootstrap population with `findOne({ where: [] })` inside a `try/catch`; on a -real engine that throws, the `catch` read the refusal as "users exist", and the -declared first-run bypass became permanently inert on real deployments — while a -641-line unit matrix over the double stayed green, including a case named -"bootstrap: the very first signup is admitted" (#11767). - -New public API, mirroring the two write-side dispatch predicates -(`assertEngineDeleteDispatch`, `assertEngineUpdateDispatch`) exactly — the -implementation lives in `@objectstack/metadata-core` so that packages -`@objectstack/objectql` itself depends on can reach it, and `@objectstack/objectql` -re-exports every symbol: - -- `assertEngineFindOnePredicate(object, query)` — the line a fake engine's - `findOne` opens with; throws the engine's own message, object name included. -- `resolveEngineFindOnePredicate(object, query)` — the same decision without the - throw, for a double that wants to classify. -- `engineFindOnePredicateRefusalMessage(object)` — the refusal text, so an - assertion pins the producer's wording rather than a paraphrase. -- `ENGINE_FINDONE_PREDICATE_CASES` — the shared conformance case-set, driven - against the REAL engine by - `packages/objectql/src/engine-findone-predicate.test.ts`, so the predicate - cannot drift from `engine.ts` unnoticed. - -Nothing is removed and no existing behaviour changes: the engine's own guard is -untouched, and this publishes the decision it already makes so a double can -import it instead of re-deriving it. diff --git a/.changeset/envelope-audit-shared-home.md b/.changeset/envelope-audit-shared-home.md deleted file mode 100644 index 573787c85a..0000000000 --- a/.changeset/envelope-audit-shared-home.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/cloud-connection': patch ---- - -The canonical-expression-envelope detector for raw-literal `Page` exports gets a shared home in `@objectstack/lint` (#11480). New public API beside `walkPageComponents`: `auditPageExpressionEnvelopes(page, label)` runs the three parse doors (`PageSchema` / `PageComponentSchema` / `ComponentPropsMap`) over one authored page and reports bare-expression findings plus every door's precondition failures; `renderBareExpressionFindings(findings)` renders the actionable red; types `BareExpressionFinding`, `PageEnvelopeAudit`, `EnvelopeAuditDoor`. The detector previously lived package-local to `@objectstack/platform-objects`' gate, which could not reach raw-literal pages shipped by other packages. `@objectstack/cloud-connection`'s two shipped pages are now covered by the same gate, and `MarketplaceInstalledPage` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery sees it. diff --git a/.changeset/field-deletebehavior-reference-only.md b/.changeset/field-deletebehavior-reference-only.md deleted file mode 100644 index c4936c3e5a..0000000000 --- a/.changeset/field-deletebehavior-reference-only.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): materialize `deleteBehavior` only on reference field types (#9784) - -`FieldSchema` no longer materializes the `deleteBehavior: 'set_null'` default -onto non-reference field types (`text` / `datetime` / `number` / every other -non-relational type, `user` included). The key has no meaning there — the -engine's `cascadeDeleteRelations` reads it exclusively on `master_detail` / -`lookup` fields carrying a `reference` — yet the materialized default shipped -in every built app artifact, where a parse-time default becomes an apparent -explicit declaration downstream (the #4447 shadowing mechanism) and reads as -meaningful to AI authors browsing the artifact. - -What changes and what does not: - -- **Bare non-reference fields** parse to output that **omits** `deleteBehavior` - (previously: `deleteBehavior: 'set_null'` materialized on every type). Built - artifacts thin accordingly — measured on the showcase app: 210 fields, the - key drops from 206 fields to 16. -- **`lookup` and `tree`** keep materializing `set_null` byte-identically, at - shape position. -- **`master_detail`** keeps omitting it (the #9689 idempotent-materialization - ruling, unchanged). -- **The accept-set is untouched**: an authored `deleteBehavior` on any field - type parses exactly as before and round-trips verbatim, so artifacts built - by earlier versions (which carry the materialized key on every field) remain - fully legal inputs. `parse(parse(x))` holds across the boundary. - -No authored metadata needs any change: no key is removed, renamed or -re-shaped, and no authoring spelling that parsed before is refused now. diff --git a/.changeset/field-designer-master-detail-delete-behavior-options.md b/.changeset/field-designer-master-detail-delete-behavior-options.md deleted file mode 100644 index 6c89039f91..0000000000 --- a/.changeset/field-designer-master-detail-delete-behavior-options.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The field designer no longer offers a `master_detail` a `deleteBehavior` the schema refuses - -#9689 made `deleteBehavior: 'set_null'` authored on a `master_detail` field a -named parse-time rejection. The two shipped metadata forms that let a Studio -author pick that value did not move with it, so the designer kept offering -"Set null" for a `master_detail` — the author picked it and learned only at -publish, from a 422, that the choice was never legal. Declared-vs-enforced, one -seam earlier than the parse door. - -Both forms failed the same way through **different option sources**, which is -why the repair is not one edit twice: - -- `object.form.ts` declared an inline `options` array carrying all three values - behind a single `visibleWhen` that named both `lookup` and `master_detail`. -- `field.form.ts` declared **no** `options` at all. That is not a narrower - offer — it is the renderer's derived source: with no inline list the - metadata-admin form falls through to the JSON Schema `enum`, which is - `['set_null','cascade','restrict']` and additionally advertises - `default: 'set_null'`. A Zod enum has no per-type narrowing to give, so the - derived path re-offers the refused value by construction, and `master_detail` - can only be served by an explicit list. - -Both now declare the control **twice, with disjoint `visibleWhen`** — `lookup` -keeps all three outcomes, `master_detail` is offered `cascade` and `restrict` -only. Nothing is taken away from `lookup`, where all three remain legal: -`object.form.ts` keeps its three-value list unchanged, and `field.form.ts`'s -`lookup` branch keeps deriving from the enum exactly as before, so its labels -and their translation are untouched. The two branches are mutually exclusive, so -no author ever sees both. - -**Per-option `visibleWhen` was the tighter-looking spelling and was measured and -rejected.** `SelectOptionSchema` does declare it (ADR-0068), so it reads as -existing vocabulary, but it is not reachable from a metadata form in either -direction: the metadata-admin renderer maps `fieldSpec.options` straight to -select items and never consults it, so writing one would ship an ADR-0049 -declared-but-unenforced key; and on the runtime surface that *does* honor it, -the per-option evaluator binds `record` and never `data`, so a `data.`-rooted -predicate there is an unbound identifier whose visibility fails **open**, -keeping the option. Either way the author would still have been offered -"Set null" — a fix that silently does nothing. Field-level `visibleWhen` is the -predicate the renderer already evaluates for every other type-conditional -control in these files, so the split uses it and the form DSL is unchanged. - -The pinning test asserts more than the absent value: it requires **exactly one** -visible control per type, because two declarations of one key can fail by -overlapping (two selects writing one key) as well as by leaving a gap (the -control vanishes for that type). diff --git a/.changeset/file-hydration-tombstone-agreement.md b/.changeset/file-hydration-tombstone-agreement.md deleted file mode 100644 index 90141af4a5..0000000000 --- a/.changeset/file-hydration-tombstone-agreement.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/service-storage": patch ---- - -Record file-field hydration now answers the same question about a `sys_file` tombstone that the download path answers (#11427). `#10246` stopped `GET /api/v1/storage/files/:id` treating a tombstone (`status: 'deleted'` + `deleted_at`) as the last word — it asks the reap guard's own `findFileHolder` and serves the row for as long as something still holds it — but the record read kept the older `status === 'committed'` rule. One `sys_file` row therefore answered `200` at the download endpoint and a bare id inside a record payload, which UI and export render as "this record has no attachment". - -The population is narrow and unchanged in every other respect: `claimFile` already un-tombstones a field file synchronously when a record re-points at it, and attachments-scope files are never reached by field hydration, so what this closes is the residual the reap guard's sweep-time re-verification names — hook races, direct-driver writes, and future trash restore. A tombstone nothing holds still hydrates as a bare id, and a `pending` upload is untouched. - -The predicate is not re-derived in the engine. `ObjectQL` gains `registerHeldFileResolver` (type `HeldFileResolver`), which the storage plugin fills with `findHeldFiles` — the batched form of `findFileHolder`, asking the same union of `sys_attachment` join rows and the `ref_*` ownership columns. Batched because hydration runs over many rows per read: a read with no tombstone costs nothing, and the residual case costs one extra query for the whole read rather than one per file. Engines with no storage plugin keep tombstones un-hydrated exactly as before. diff --git a/.changeset/flat-input-descriptor-mirror.md b/.changeset/flat-input-descriptor-mirror.md deleted file mode 100644 index bdb8f6bce9..0000000000 --- a/.changeset/flat-input-descriptor-mirror.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): the flat-input Proxy mirrors `data`'s own descriptor instead of synthesising one (#12397) - -`installFlatInput` hands a declarative hook a flat-record Proxy over the -engine's `{ data, options, id? }` wrapper. Its `getOwnPropertyDescriptor` trap -answered every key `data` carries with one fixed literal — -`{ configurable: true, enumerable: true, writable: true, value: data[prop] }` — -and never read `data`'s real descriptor. For a key created by ordinary -assignment that synthesis is the truth, which is why it cost nothing for as -long as assignment was the only way a key could arrive. - -#12277 routed `defineProperty` into `data`, so a hook can now put a key on the -record payload with non-default attributes for the first time, and the -synthesis reported the defaults back regardless: - -```js -Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true }); -Object.getOwnPropertyDescriptor(ctx.input, 'k'); // reported enumerable: true — it is not -Object.keys(ctx.input); // …while this correctly omitted 'k' -``` - -Two instruments over one payload, contradicting each other. The trap now -mirrors `data`'s own descriptor. - -`configurable` is the one attribute that cannot be mirrored: the proxy target -is the wrapper, which does not carry the record key, and a proxy may not report -a property its target lacks as non-configurable — a verbatim mirror throws -`TypeError` on any key `data` holds as `configurable: false`, and takes -`Object.keys` and spread down with it, since both reach every listed key -through this trap. It is forced `true`; `enumerable` / `writable` are mirrored. - -Two further observable consequences, both pinned: - -- Reading a descriptor no longer runs author code. The synthesis evaluated - `data[prop]` to fill `value`, so asking a payload that holds an accessor for - its descriptor invoked the getter; a mirror copies `get`/`set` across - untouched. -- `prop in data` is true for the whole prototype chain, so the synthesis - answered for inherited keys too — `Object.getOwnPropertyDescriptor(input, - 'toString')` returned an own, enumerable, writable data property no payload - has ever held, and `Object.hasOwn(input, 'toString')` was `true`. Only an own - key has a descriptor to mirror; inherited keys now report `undefined`, while - `'toString' in input` and the read itself are unchanged. - -Enumeration is untouched: `ownKeys` still lists exactly `data`'s own enumerable -keys and the mirror reports those as enumerable, so `Object.keys`, spread, -`Object.entries` and the sandbox's `unwrapProxyToPlain` see byte-identical -results. What a record payload may hold, how `defineProperty` routes into -`data`, and how the engine persists it are all untouched. diff --git a/.changeset/flat-input-ownkeys-own-key-set.md b/.changeset/flat-input-ownkeys-own-key-set.md deleted file mode 100644 index 5abe17878e..0000000000 --- a/.changeset/flat-input-ownkeys-own-key-set.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the flat-input proxy's `ownKeys` reports the payload's own key set, not its enumerable subset (#12578) - -`installFlatInput` answered the `ownKeys` trap from `Object.keys(data)` — own **enumerable -string** keys. That filtering was incidental to what the trap is for (hiding the wrapper keys -`id`/`options`/`ast`/`data` from `Object.keys`/`for…in`), and it cost a key: an own -**non-enumerable** key on the record payload was absent from `Object.getOwnPropertyNames(input)` -and `Reflect.ownKeys(input)` while `hasOwnProperty` and the descriptor trap both reported it — -and while the engine persisted the row holding it. Measured on the merged ref, for a payload -`{ subject }` a handler had added `k` to with -`Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true })`: - -``` -Object.getOwnPropertyDescriptor(input, 'k') -> own, enumerable:false -Object.prototype.hasOwnProperty.call(input, 'k') -> true -Object.getOwnPropertyNames(input) -> ['subject'] <- not own? -Object.getOwnPropertyNames(persisted row) -> ['subject', 'k'] -``` - -Three instruments, one payload, two answers about own-ness. Newly reachable rather than newly -written: #12277 routed `defineProperty` into the payload, so a handler can put a -non-default-attribute key there for the first time, and #12397 made the descriptor trap mirror -the payload instead of synthesising defaults — which is what gave the third instrument an -opinion to disagree with. - -The trap now reports `Object.getOwnPropertyNames(data)`. **The enumerable face is unchanged**: -`Object.keys`, spread, `Object.entries`, `for…in` and `JSON.stringify` still omit a -non-enumerable key, because each applies the `enumerable` filter itself, one layer up, through -the descriptor trap. Applying it inside `[[OwnPropertyKeys]]` as well did not make those answers -cleaner — it only starved the two surfaces whose entire job is to report the whole set. The -sandbox body face is byte-identical for the same reason: `unwrapProxyToPlain` -(`@objectstack/runtime`) snapshots `ctx.input` as `Object.entries` over this proxy. - -Wrapper keys stay excluded, which is the trap's purpose — achieved by reading `data` and never -the wrapper, not by subtracting those four names, which would hide a genuine payload field named -`id`. **Symbol keys remain unenumerated**: they already reach the payload and already persist, so -publishing them through `ownKeys` is a question about what a record payload may hold rather than -about this trap, and it is left open on #12578 rather than decided here. - -Pinned in `hook-input-ownkeys-agreement.test.ts` as the AGREEMENT of the three own-ness -instruments — not as one trap's output, which is the pin shape that let the halves diverge — with -the wrapper-key and symbol exceptions pinned as deliberate exceptions. Reverse-verified by -ablation: restoring `Object.keys(target.data)` fails exactly 2 of the 6 new cases (32 of 34 green -across the four hook-input suites), and the enumerable-face assertions stay green under the -mutation, which is what proves that half untouched. diff --git a/.changeset/flow-contender-one-renderer.md b/.changeset/flow-contender-one-renderer.md deleted file mode 100644 index bdf53d09df..0000000000 --- a/.changeset/flow-contender-one-renderer.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -fix(service-automation): one renderer for the contested-flow phrase, and the two spellings it had drifted into (#12563) - -`minor`, not `patch`, and not empty: this adds a new export -(`renderFlowContender`) to a published package's public API, and it changes -**shipped operator-facing log text**. Both are real changes a consumer can -observe. - -## What changed - -One event — a flow name claimed by more than one definition — was described to -an operator in three places, each with its own private `const describe` beside -the log call: `flow-precedence.ts`'s precedence warning, `plugin.ts`'s bootstrap -audit, and (in `@objectstack/cli`) the startup banner. Nothing held them equal, -and two axes had already drifted: - -- **Quoting.** `flow-precedence.ts` rendered `package "crm"`; the other two - rendered `package 'crm'`. -- **Absent package id.** The two engine copies interpolated a bare `undefined` - into the sentence; the CLI copy rendered a real fallback. - -The two copies in this package are now one exported renderer. The choice on -each axis was measured, not voted: - -- **Single quotes**, measured against this package rather than across the three - copies: of the interpolated identifiers in operator prose under - `service-automation/src`, 203 are single-quoted and 3 double-quoted — one of - those 3 being this phrase. The sentence already single-quotes the flow name - beside it. -- **A named fallback** (`a code-shipped package (id unknown)`) instead of - `package 'undefined'`. This package's own callers cannot reach that branch - today, because `isCodeArtifactBody` is false on a falsy `_packageId` — but - that is a property of today's callers, not of an exported function. - -## Log text a consumer may be matching on - -`[Automation] Flow name collision: …` (the precedence warning) now renders a -packaged contender as `package 'crm'` rather than `package "crm"`. -`plugin.ts`'s bootstrap `[Automation] flow '' is claimed by …` warning is -byte-identical to before for every input its callers can produce; only its -unreachable absent-id branch changed. - -## Why the CLI still renders its own - -`@objectstack/cli` deliberately keeps its own spelling and takes no value -import of this package for the banner: its engine reads are structural and -feature-detected so a host on an older automation package still boots. The -third copy is held equal by a test-only agreement pin -(`packages/cli/src/utils/format.flow-contender-agreement.test.ts`) that asserts -the banner line through this renderer, so it goes red in both directions. diff --git a/.changeset/flow-expression-function-table.md b/.changeset/flow-expression-function-table.md deleted file mode 100644 index 02c980980c..0000000000 --- a/.changeset/flow-expression-function-table.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -Flow value expressions (`create_record`/`update_record` `config.fields`, `assignment` `config.assignments`) now support a small numeric function table — `round`, `floor`, `ceil`, `abs`, `min`, `max` — with every name and semantic mirrored 1:1 from the `@objectstack/formula` CEL stdlib (no second dialect: `round` is integer-only exactly like CEL's; for N-decimal rounding write `round(x * 100) / 100`, the same pattern CEL authors use). A flow can finally write a computed money value that satisfies its field's declared `scale` (`{round(amount * (1 - discount / 100) * 100) / 100}` → a `scale: 2` currency field). - -Loud diagnostic in the same stroke: an identifier in call position that is not a supported function — `ROUND(...)`, `Math.round(...)`, `(x).toFixed(2)`, or the next name anyone invents — now fails the node with a named `FlowExpressionFunctionError` (guard-marked, so a `fault` edge cannot swallow it) instead of being silently rewritten to `null` and writing the field as `undefined`. Non-call template resolution is unchanged: unresolved plain tokens still become `null`/empty, and `NOW()`/`TODAY()` whole-token macros behave exactly as before. diff --git a/.changeset/flow-input-schema-invalid-code.md b/.changeset/flow-input-schema-invalid-code.md deleted file mode 100644 index 381c1c7111..0000000000 --- a/.changeset/flow-input-schema-invalid-code.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): register `FLOW_INPUT_SCHEMA_INVALID` — the definition-level input-schema refusal becomes a never-dispatched exit with its own ADR-0112 code (#11504, the contract half of the #10025 ruling) - -`AutomationResult.code` gains `'FLOW_INPUT_SCHEMA_INVALID'`, and the code is -registered in the ADR-0112 error-code ledger under `@objectstack/runtime` -beside `FLOW_DISABLED` / `FLOW_NO_START_NODE`. Semantics: a node's static -`config` violates the `inputSchema` its own flow definition declares, so the -engine refuses to dispatch — nothing runs, nothing is written, the result -carries the code and NO `status` (the #9378 never-dispatched class), and a -transport maps it to **422** (unexecutable stored definition, exactly as -`FLOW_NO_START_NODE`). - -Ruled by #10025 (maintainer, 2026-08-20): the refusal is **non-retryable** — -the guard's verdict is a pure function of the flow definition, so re-running -it cannot change the answer. This release ships only the contract vocabulary; -the engine behaviour change is #10025's services half and lands separately. - -**Operator-visible consequence once that services half lands, stated -plainly:** retry accounting and run-log volume change for affected flows. A -`strategy: 'retry'` flow whose node `config` violates its declared -`inputSchema` today burns its whole retry budget (including configured -backoff delays) and writes `1 + maxRetries` identical failed run-log rows; -after the services half it refuses **once**, producing **one** run-log row -carrying `code: 'FLOW_INPUT_SCHEMA_INVALID'` and no `status`. Anything -watching retry counters or paging run history for this exit sees different -numbers for the same flow. diff --git a/.changeset/flow-screen-copy-resolver-family.md b/.changeset/flow-screen-copy-resolver-family.md deleted file mode 100644 index 4bb8d1ea38..0000000000 --- a/.changeset/flow-screen-copy-resolver-family.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**i18n:** the screen-flow copy vocabulary gains its resolver family — `translateFlow`, `resolveFlowScreenTitle`, and the `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS` shared key lists (#7646 recommendation B, the spec half of #11287). - -#7763 declared `TranslationData.flows` (`flows..label`, `.screens..title`, `.screens..fields..{label,placeholder}`) and deliberately left the resolver half unwritten because another change was in flight on `i18n-resolver.ts`. This lands that half in `packages/spec/src/system/i18n-resolver.ts`, mirroring the page family's conventions: - -- `translateFlow(flow, bundle, opts)` — the metadata-document overlay: translates the flow's own `label` and, for every `type: 'screen'` node with an id, the screen heading (`config.title` — written even when the author relied on the node-label fallback, since the executor builds the wire title as `config.title ?? node.label`) and per-field `label` / `placeholder`, key by key across the locale chain with the authored source strings as fallback. Input not mutated; off-spec bundle keys the schema refuses (`description`, `help`) are ignored, never overlaid. -- `resolveFlowScreenTitle(bundle, flowName, screen, opts)` — the piecemeal half for a caller already holding a `ScreenSpec` (`nodeId` addressing, `title` literal fallback). -- `FLOW_SCREEN_COPY_KEYS` (`['title']`) and `FLOW_SCREEN_FIELD_COPY_KEYS` (`['label', 'placeholder']`) — the one list the resolver overlay and the CLI's skeleton extractor (downstream card) both import, pinned against `TranslationDataSchema` so neither can drift. -- Supporting shapes: `FlowLike`, `FlowNodeLike`, `FlowScreenLike`, `FlowScreenFieldLike`, `FlowScreenCopyKey`, `FlowScreenFieldCopyKey`. - -Additive surface widening only — no existing shape changes meaning, and `translateFlow` is deliberately **not** registered in `translateMetadataDocument`'s dispatch table: that registration reaches the REST metadata boundary by itself (`TRANSLATABLE_METADATA_TYPES` drives `@objectstack/rest`), which would stand up a shipped reader of the `flows` group while its liveness ledger rows are `planned`. The runner application (server or client side), the CLI coverage bucket, and the ledger flip to `live` all ride the downstream cards of #11287. diff --git a/.changeset/formfield-constraint-shape.md b/.changeset/formfield-constraint-shape.md deleted file mode 100644 index 5f5311c54d..0000000000 --- a/.changeset/formfield-constraint-shape.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`FormFieldSchema`'s constraint keys converge on the value shapes the object-field surface landed (#12174): `maxLength`/`minLength` tighten from bare `z.number()` to `z.number().int().min(1)` (the #11566/#11949 template — a character length is a positive integer, and `minLength: 0` is a vacuous always-true declaration refused loudly; "no minimum" is expressed by omitting the key), and `precision`/`scale` tighten to `z.number().int().min(0)` (the #8321 template — digit counts). `min`/`max` are unchanged: they are values, not counts, exactly as on the object-field surface. - -The row keys are live, measured in objectui: the spec bridge (`form-view.ts` mapField, objectui#5898) and plugin-form (`sectionFields.ts`) copy all six onto the runtime field, the console FormPage merges `override.maxLength ?? def.maxLength` onto the rendered input (objectui#5595), the fields package builds validation rules from `minLength`/`maxLength`/`min`/`max`, and `precision`/`scale` drive the number widgets — so `maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and `GET /forms/:slug` serves the rows verbatim to anonymous renderers. What newly gets rejected: `0` (length pair only), negative and non-integer values on the four count-shaped keys. Unlike the object-field twins there is no type-conditional applicability gate: a form row references its object field by name and usually omits `type`, so the referenced field's type is invisible at parse time — value shape is checkable here, key placement stays the object field's own schema's job. Already-legal declarations round-trip byte-identically, and absence stays absence — no default materializes. - - diff --git a/.changeset/front-door-examples-compile.md b/.changeset/front-door-examples-compile.md deleted file mode 100644 index 152a81af24..0000000000 --- a/.changeset/front-door-examples-compile.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Fix four self-contained TSDoc `@example` blocks in `packages/spec/src` that did -not compile against the package's own built declarations, and add `os:check` -markers so `check:skill-examples` keeps them honest going forward. - -The package's front-door header (`src/index.ts`) taught a root namespace-import -style (`import { Data, UI, System, Auth, AI, API } from '@objectstack/spec'`) -the root has never exported, and two subpath-import styles that named -`@objectstack/spec/auth` — a subpath that does not exist (the real one is -`@objectstack/spec/identity`). The header now documents three styles that -actually compile: root-level `defineX` factory imports (`defineStack`, -`defineView`, `defineApp`, `defineFlow`, `defineAgent`, `defineTool`, -`defineSkill`, …, which the root really does export), namespace imports via -subpath, and direct subpath imports — all against `@objectstack/spec/identity` -instead of the non-existent `/auth`. - -`src/shared/branded-types.zod.ts`'s `@example` imported `ObjectNameSchema` / -`FieldNameSchema` from the package root; both are exported only from -`@objectstack/spec/shared`. Fixed the specifier. - -No behavior change — TSDoc examples only. diff --git a/.changeset/generator-filename-registry-parity.md b/.changeset/generator-filename-registry-parity.md deleted file mode 100644 index 2573d8b73e..0000000000 --- a/.changeset/generator-filename-registry-parity.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os generate` writes every scaffold as `NAME.TYPE.ts`, with the infix read from -the metadata type registry instead of the harness - -`os g object customer` wrote `src/objects/customer.ts`. The metadata loader -discovers files by globbing each type's own `filePatterns` from -`DEFAULT_METADATA_TYPE_REGISTRY` — `MetadataPlugin._loadFromFileSystem` does -this for **every** registered type, and it is the default `eager` bootstrap -whenever no compiled artifact is configured. Measured across the seven -generators: the bare `NAME.ts` the harness wrote matched **zero** of those -patterns for all seven, while `NAME.TYPE.ts` matched exactly one, every time. -A scaffold that matches nothing still type-checks, still passes `os validate` -and still publishes, and is then never loaded, with no diagnostic at any step -— the silent-strip shape ADR-0063's retirement of `os g agent` closed. - -The previous release closed this for `skill` alone, through an optional -per-generator filename override, on the belief that the other six types were -not filesystem-discovered. The loader says otherwise, so the override is -retired rather than copied five more times: the filename is now derived from -the type's declared pattern, which is also why it reads the pattern rather than -interpolating the type key — `email_template` declares `*.email-template.ts` -and `external_catalog` declares `*.external-catalog.ts`, so an interpolated -infix would re-create the same invisibility for the next generator added. - -Now written: `customer.object.ts`, `customer.view.ts`, `approve.action.ts`, -`customer.flow.ts`, `sales.dashboard.ts`, `crm.app.ts`, -`lead_qualification.skill.ts`. The barrel `index.ts` line is derived from the -filename that was actually written, so it names the real module. - -**Existing files are not renamed and keep working.** A `NAME.ts` file already -on disk matched no `filePatterns` entry before this change either, so nothing -it relies on moves: if your app loads it through the barrel `index.ts` the -generator wrote, that import is untouched. Rename it to its type's pattern if -you also want the loader's own glob to find it. diff --git a/.changeset/global-search-registry-propagate.md b/.changeset/global-search-registry-propagate.md deleted file mode 100644 index ab224e4e67..0000000000 --- a/.changeset/global-search-registry-propagate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Global search (`searchAll`, the producer behind `GET /api/v1/search`) no longer invents an empty registry. The registry read was `registry?.getAllObjects?.() ?? []`, so a host whose registry does not implement `getAllObjects` — a structural omission that never throws — produced a successful "nothing matched" response with `objectsScanned: 0`. A registry that cannot enumerate its objects is never truthfully "no objects" (ADR-0110 D3): both halves of the swallow are removed and the omission now surfaces as the read's own failure, mapped by the REST door's standard error path. Unchanged neighbours: a registry that enumerates and truthfully answers "no objects" still yields the successful empty response, and a blank query still short-circuits before the registry is consulted. diff --git a/.changeset/hash-shadow-unique-key-utf8mb4.md b/.changeset/hash-shadow-unique-key-utf8mb4.md deleted file mode 100644 index 79ad12fad3..0000000000 --- a/.changeset/hash-shadow-unique-key-utf8mb4.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -driver-sql (MySQL): carry an over-long UNIQUE index on a hash-shadow column - -On utf8mb4 InnoDB a key part holds at most 3072 bytes (768 characters), so a -full-value UNIQUE index over a longer column is inexpressible — an OAuth access -token that is a multi-KB JWT cannot be made keyable by any declared bound. -Measured on live MySQL 8.0.46, 7 of 44 exported platform objects failed -`syncSchema` outright and landed registered with their declared uniqueness -absent (Postgres 16.13: 0 of 44). - -Such a UNIQUE index is now carried by a driver-owned `__hash` column — a -`STORED GENERATED` `VARBINARY(32)` holding the full, untruncated SHA-256 of the -key values — with the unique index on that column. Uniqueness is still enforced -over the whole value: distinct values sharing a long prefix are both accepted -(the property that ruled out prefix-unique indexes), NULLs stay distinct, and a -composite tuple containing NULL conflicts with nothing. - -The shadow is created only *after* the server refuses the direct index, so the -dialect divergence is selected by the error code rather than by a dialect check: -Postgres and SQLite are byte-identical to before. Non-unique indexes are -deliberately left refused — an index over a digest accelerates no lookup the -planner can reach. diff --git a/.changeset/heavy-eels-shave.md b/.changeset/heavy-eels-shave.md deleted file mode 100644 index 24bc98dfbc..0000000000 --- a/.changeset/heavy-eels-shave.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/runtime": minor ---- - -Packaged flows can be switched off durably, and the process-local off-switch is retired - -Disabling a packaged flow now writes an install-level row to the -`sys_metadata_activation` ledger (ADR-0126 §4/§7.2) instead of setting a -process-local map. The engine consults that ledger at the `execute()` seam — -the one seam every entry path crosses (record-change, schedule, time-relative, -api, subflow) — and refuses a disabled flow there with the existing -`FLOW_DISABLED` code; the ledger case is distinguished by the message, so no -new error code joins the ADR-0112 ledger. An install-level disable also unbinds -the flow's trigger, and re-enabling rebinds it. Absence of a row means the -packaged default, active, so a deployment that never flips anything behaves -exactly as before. - -This retires the mechanism behind #10243 rather than refining it. The old -`flowEnabled` map was not a row, so no organization wall scoped it: on a walled -multi-organization deployment a tenant org owner could switch a shipped flow -off environment-wide and an unrelated tenant read it off. The durable row -replaces it, and because a durable install-wide switch writable by tenants -would be that leak with persistence, the write is now authority-gated: -`POST /automation/:name/toggle` requires the platform operator in the `group` -and `isolated` postures, while the `single` posture — where install-level and -org-level are the same scope — is unchanged for the org admin who already holds -`manage_metadata`. The refusal names the posture and points at the clone path. - -Disabling a flow that packaged flows still call as a subflow is refused, and -the refusal names the callers (ADR-0126 §7.3). Without it a vendor flow breaks -mid-run at its subflow node with an inexplicable late failure. The check is a -definition scan at disable time over both `subflow` and `map` nodes; no -reference index is built. Enabling is never guarded. - -One behaviour change worth calling out: a disable now survives -unregister-and-re-register, which is what a package upgrade, a Studio publish -and the boot pull all do. ADR-0126 §6 requires it — the ledger records the -customer's choice, and no upgrade un-makes a choice — but it is the opposite of -what the retired in-process map did, where any re-registration silently -re-armed the flow. diff --git a/.changeset/hmr-dev-door-environment-gate.md b/.changeset/hmr-dev-door-environment-gate.md deleted file mode 100644 index d0d87f3441..0000000000 --- a/.changeset/hmr-dev-door-environment-gate.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -fix(security): gate the metadata HMR door on an explicit development posture (#12140) - -**BREAKING** surface narrowing — `GET`/`POST /api/v1/dev/metadata-events` are no -longer mounted unless `NODE_ENV` is exactly `development`. Shipped as `minor` -under the repo's launch-window convention for breaking changes. - -`MetadataPlugin` mounted both HMR routes whenever a raw-app-capable HTTP server -was present, with no authentication and no environment condition of its own. The -dev-only posture the path (`/api/v1/dev/…`) advertises lived only in prose — -"production deployments simply won't have a CLI POSTing to this endpoint" — which -is a claim about who is on the network, not a gate that stops them, and the same -structural shape #9391 closed for the `datasource-admin` family: both mounts take -the host's framework-native app handle, which is outside REST's `enforceAuth` -seam by construction. - -The distributions were enumerated rather than assumed, because "a dev-only -surface lacking a gate that says so" and "an unauthenticated door on a real -deployment" want different repairs. The official container image runs `os start` -under `NODE_ENV=production`; that boot reaches `createStandaloneStack`, which -composes `MetadataPlugin` unconditionally (only `artifactWatch` was -NODE_ENV-gated) onto a kernel that registers the Hono server whenever it serves. -So a production-shaped boot did mount both routes and did answer them — `POST` -re-reading the compiled artifact from disk and broadcasting a reload frame to -every connected client, unauthenticated. - -`registerMetadataHmrRoutes` now refuses at its first statement, ahead of every -side effect it performs, and returns `null` so "nothing was mounted" is a fact -the compiler forces its caller to handle. Unset `NODE_ENV` is closed, per the -maintainer's 2026-08-06 ruling that an absent value reads as `production`; `test` -and unrecognised spellings (`staging`, `preview`, `qa`) are closed too — a gate -must not treat a spelling nobody recognises as a key. - -Who is affected, in both directions: - -- `os dev` is unchanged. It spawns `os serve --dev`, which sets - `NODE_ENV='development'` before any plugin starts, so the watch-recompile loop - still gets its `200` and Studio still gets its reload frames. -- A deployment that was reaching this endpoint on a production-posture boot now - gets its host app's `404`. That door was never advertised, never in the SDK - (`@objectstack/client` builds no such URL) and is a build-tool loopback; if you - need a supported production reload trigger, that is a product decision, not - this endpoint. -- The server-side artifact-file watcher is untouched on every boot shape that had - it: the reload still happens, only the broadcast to (now absent) SSE clients is - skipped. - - diff --git a/.changeset/hook-ctx-title-accessor.md b/.changeset/hook-ctx-title-accessor.md deleted file mode 100644 index 32b69a3d02..0000000000 --- a/.changeset/hook-ctx-title-accessor.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/runtime": minor -"@objectstack/cli": minor ---- - -**Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). - -A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. - -What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. - -```js -// this record — nameField, formula or stored column alike -await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); -// a related record, through the lookup column that holds its id -const account = await ctx.title('account_id'); -``` - -**Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. - -**Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. - -**It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. - -Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. diff --git a/.changeset/hook-input-delete-lands.md b/.changeset/hook-input-delete-lands.md deleted file mode 100644 index fa9fbb6783..0000000000 --- a/.changeset/hook-input-delete-lands.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/runtime': minor ---- - -fix(objectql,runtime): `delete ctx.input.x` in a hook actually removes the field (#12277) - -A hook that stripped a field from its input with `delete` did nothing, on BOTH -execution paths, while an assignment made two lines above it on the same object -in the same call landed normally. Nothing raised, and nothing in the platform -reported it. - -Graded `minor` rather than `patch` deliberately: it moves data that reaches -downstream consumers. Any shipped hook that already contains -`delete ctx.input.` has been a no-op until now and starts taking effect -on upgrade — which is the point, and is also exactly why it must not arrive as -a silent patch. No API is removed and no accept set narrows. - -### The two mechanisms, which were unrelated and produced one outcome - -**In-process (`installFlatInput`, `packages/objectql/src/hook-wrappers.ts`).** -The flat-record `Proxy` a declarative hook receives over the engine's -`{ data, options, id? }` wrapper trapped `get` / `set` / `has` / `ownKeys` / -`getOwnPropertyDescriptor` — but not `deleteProperty`. The delete therefore fell -through to `Reflect.deleteProperty` on the WRAPPER, one level above the record, -removing a key that was never there and returning `true`. `set` was trapped and -wrote into `data`, which is what the engine persists; hence assignment survived -and deletion evaporated. - -**Sandboxed (`applyMutationsToInput`, -`packages/runtime/src/sandbox/body-runner.ts`).** A QuickJS body's mutations -were written home with `Object.assign(target, result.mutatedInput)`. -`Object.assign` copies own enumerable properties and **has no way to represent a -removal**: a key the VM deleted is simply not in the snapshot, and the host's -key stayed. Deletions are now diffed against the entry snapshot and applied -separately. - -Both are fixed in one change on purpose. Closing either alone would make the -same authored `delete` behave differently depending on whether the hook body -runs in-process or in the sandbox — a worse contract than the symmetric silence -it replaced. - -### What an author could see, before and after - -The sandboxed path is the one with no tell at all. Measured on the pre-fix code, -one hook call, host row alongside: - -``` -delete ctx.input.internal_notes -> true -'internal_notes' in ctx.input -> false <- the VM agrees -Object.keys(ctx.input) -> ['subject'] <- ...and so does this -host ctx.input after write-back -> { subject: 'HELP', - internal_notes: 'STAFF-ONLY' } -``` - -The in-process path was less deceptive than reported, and the correction is -worth having in writing: only `delete`'s own return value lied there. `'k' in -input`, `input.k` and `Object.keys(input)` all went on honestly reporting the key -as present, so an author who checked with anything other than the return value -would have seen the no-op. - -### `Object.defineProperty(ctx.input, …)` was the same gap, and nobody reported it - -Found while enumerating the trap set, fixed in the same stroke because it is the -strictly worse shape: it defined on the wrapper, and the `get` trap's -fall-through then read the value straight back — so `input.k` CONFIRMED a write -that never reached `data`, while `Object.keys(input)` denied it and the record -never received it. It now routes into `data` like `set` and `deleteProperty` do. -One inherited JS invariant follows: a proxy may not report success for an -explicitly `configurable: false` descriptor its target does not carry, so -`Object.defineProperty(input, 'x', { value: 1, configurable: false })` now throws -a `TypeError` where it used to define, silently and uselessly, on the wrapper. -Omitting `configurable` — the common spelling, and the one spread and -`Object.assign` produce — is unaffected. - -### The direction the sandbox write-back deliberately does not overreach in - -Absence from the exit snapshot is the only evidence a deletion leaves, and on its -own it is ambiguous: a key whose host value is `undefined` (or a function, or a -symbol) never survived `JSON.stringify` INTO the VM either, so it is missing from -the dump without anyone having deleted it. The diff is filtered through the same -JSON lens the boundary uses, so such a key is left alone. Every failure mode of -that probe is conservative — an unprobeable key is simply not deletable — because -losing a delete is recoverable and destroying a field on evidence that was never -there is not. One residual miss follows and is named here rather than discovered -later: a `bigint`-valued key crosses into the VM as a string but is dropped by the -probe, so deleting one is still lost. - -Measured consumer cost of the reported half: a guest-intake app stripped the -fields an anonymous web-to-case / web-to-lead submitter must not write — -internal staff notes, the resolution, the escalation flag, the owner — with -fifteen `delete` statements, every one inert. A submission carrying -`internal_notes` and `resolution` stored them verbatim, and the app's unit tests -stayed green throughout, because they drive the handler with a plain object where -`delete` genuinely works. diff --git a/.changeset/hot-reload-inert-state-strategies-retired.md b/.changeset/hot-reload-inert-state-strategies-retired.md deleted file mode 100644 index ccb3531d9d..0000000000 --- a/.changeset/hot-reload-inert-state-strategies-retired.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049) - - - -**BREAKING** accept-set narrowing + export removal, landing after the v17.0.0 -cut (the lockstep launch-window convention ships it as `minor`; the -prescription is registered under protocol major 18 — -`RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry -`hot-reload-inert-state-strategies-retired` — where `os migrate meta` users -will look). - -This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825 -ruling deliberately kept. That ruling retired the authorable lifecycle-config -container and kept `HotReloadConfigSchema` as a host-driven library parameter -type; this change measures the kept vocabulary's own remainder and finds the -same defect in it. The keep itself stands — `HotReloadConfigSchema`, -`PluginStateSnapshotSchema` and the health vocabularies still export, and -`HotReloadManager` / `PluginHealthMonitor` are untouched. - -The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both -wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said -"memory fallback" — and announced the substitution at DEBUG level only. A host -that asked for durable or cluster-replicated state got process-local memory -and no error: state that does not survive the restart it was configured to -survive. `distributedConfig` had ZERO readers anywhere, so an author could -name a Redis endpoint, a TTL and a replication factor and nothing ever opened -a connection. - -FROM → TO: - -- `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime - behaviour, because `'disk'` already stored to memory. It is the spelling - that was false, not the behaviour. -- `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or - `'none'` to disable state preservation outright. -- `distributedConfig: { … }` → *(removed)* — delete the key. It left with the - `'distributed'` value its own doc comment called it "required" for. -- `DistributedStateConfigSchema` / `DistributedStateConfig` / - `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of - that one key. - -One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete -any `distributedConfig` — you were already getting in-memory state. There is -no in-tree replacement for durable or distributed plugin state; persist it in -the host, which owns the process lifetime these strategies pretended to -outlive. Real disk or distributed persistence returns only via the ENFORCE -route of ADR-0049 — the implementation first, the declaration with it. - -The retirement kit: - -- **enum-value narrowing** (`['memory','disk','distributed','none']` → - `['memory','none']`): invisible to all four ratchets by construction (the - def still emits), so the prescription hangs on the enum's own `error` map - dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'` - precedent. A value that was never legal still gets zod's own enum message, - so a typo is not told it "was removed". -- **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable - surface: no metadata-type binding, stack collection or manifest embed ever - carried it, and nothing in the tree parses `HotReloadConfigSchema` outside - its own unit test, so there is no authored document to rewrite and nobody - who could receive a parse-time tombstone): `kernel/DistributedStateConfig` - in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as - a def removal must — `api-surface` −3, `authorable-surface` −8, - `json-schema.manifest` −1. -- **runtime doors** in `@objectstack/core`, because route 3 leaves no - parse-time prescription: `HotReloadManager.registerPlugin` now refuses an - unhonoured `stateStrategy` and a leftover `distributedConfig` with an - ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the - prescription. Refused BEFORE the `enabled` check, so a disabled config - cannot smuggle the false declaration through. TypeScript hosts never reach - it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a - compile error at the call site. -- **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the - #11825 survivor list, so this reverses one line of that ruling on new - evidence — #11825 measured the container's six groups, never this key's own - readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts` - moves in the same commit with the reasoning recorded beside it, and asserts - the surrounding keep is intact. -- zero in-tree consumers passed `'disk'` or `'distributed'` (measured at - cdbd9204b6 with a firing positive control; every live caller passes - `'memory'` or `'none'`), so no in-repo source changes ride along. diff --git a/.changeset/hot-reload-watch-placeholder-retired.md b/.changeset/hot-reload-watch-placeholder-retired.md deleted file mode 100644 index c1cc94ff5f..0000000000 --- a/.changeset/hot-reload-watch-placeholder-retired.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescription is registered -under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry -`hot-reload-watch-placeholder-retired` — where `os migrate meta` users will -look). Graded `minor` rather than `major` for the same reason #12340 was one -day earlier, in this same module. - -ADR-0049 applied one symbol over from #12340, in the same file and on the same -per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and -`PluginStateSnapshotSchema` still export, and `HotReloadManager` / -`PluginHealthMonitor` are untouched apart from the two doors below. - -`HotReloadManager.startWatching` contained **no watcher**. Its whole body was a -guard plus `logger.info('File watching started', { patterns })`, above an -in-source note saying real watching "would require chokidar or similar". Where -#12340's inert fallback at least announced itself at DEBUG, this claimed -success at **INFO**: an operator who set `enabled: true` with `watchPatterns` -and read that line had been told the opposite of the truth. `watchHandles` was -only ever read, deleted, iterated and cleared and **never set**, so -`stopWatching`'s cleanup branch and the teardown loop over its keys were -structurally unreachable rather than merely untaken. `watchPatterns` therefore -had no reader that acted on it — its only two uses were log lines. - -FROM → TO: - -- `watchPatterns: ['src/**/*.ts']` → *(removed)* — delete the key. Declare your - globs wherever your own watcher reads them. -- `manager.startWatching(name)` → `manager.scheduleReload(name, reloadFn)`, - called from your own watcher's change handler. That is the debounced - integration point this class does implement, and it is unchanged. - -One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own -file watcher instead of `startWatching` — nothing was ever watched, so nothing -that used to happen stops happening. File watching is the host's job in this -host-driven library; `chokidar` is already a dependency of -`@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — -never of `@objectstack/core` — so a host has a working model to copy. - -The retirement kit: - -- **key tombstone**, and the build is what chose it: the plain deletion was - tried first and `gen:schema` gate (a) refused it, because - `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a - silent strip (#3733, ADR-0104) — the very defect being retired, one layer - down. #12340 could take route 3 because what left there was a whole *def*; a - key leaving a *surviving* def has no such exit. So `watchPatterns` is - `retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and - `kernel/HotReloadConfig:watchPatterns` is registered by exact key in - `RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves - `authorable-surface` only — the def still emits, so `api-surface` and - `json-schema.manifest` do not. -- **no D2 conversion**, deliberately: the chain walks a normalized stack, and - `HotReloadConfig` is not an authorable surface — no metadata-type binding, - stack collection or manifest embed ever carried it — so a conversion would be - a transform with no seam that ever runs. For the same reason the prescription - carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling - in this module does not. -- **runtime doors** in `@objectstack/core`, because nothing in the tree parses - `HotReloadConfigSchema` outside its own unit test, so the tombstone alone - reaches nobody: `startWatching` now throws an ADR-0112 envelope - (`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and - `registerPlugin` refuses a leftover `watchPatterns` the same way — before the - `enabled` check, so a disabled config cannot smuggle the false declaration - through. `startWatching` is kept as a throwing door rather than deleted so - that caller meets a prescription instead of a bare `TypeError`. -- **dead code removed with a firing positive control**: `watchHandles` and both - of its unreachable readers are gone. The zero was pinned first — - `reloadTimers.set` resolves a real writer in the same file and the same scan, - while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the - half that always did something (it cancels a pending debounced reload), and - `shutdown` is unchanged in effect: the loop it lost iterated `watchHandles` - and therefore ran zero times. -- **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a - removal: no runtime composes `HotReloadManager`, so enforcing would build for - a caller that does not exist; and a scan of every planning doc returned zero - mentions of hot-reload file watching against 145 control hits in the same - files, so there is no roadmap for `experimental` to point at. diff --git a/.changeset/human-user-predicate-fail-closed.md b/.changeset/human-user-predicate-fail-closed.md deleted file mode 100644 index 3257f1d594..0000000000 --- a/.changeset/human-user-predicate-fail-closed.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(plugin-security): fail CLOSED on a non-object row in the platform-admin promotion predicate (#12515) - -`bootstrapPlatformAdmin`'s local `isHumanUser` decided "is this `sys_user` row a -HUMAN?" with a bare truthiness check followed by two property comparisons: - -```ts -const isHumanUser = (u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; -``` - -On a truthy NON-object input (`'usr_alice'`, a number, `true`) both comparisons -read `undefined` and therefore both pass, so the input scored **human**. The -same question's consolidated owner — `isHumanUserRow` in `@objectstack/plugin-auth` -— requires `typeof row === 'object'` and answers **non-human** for those inputs. -Two owners of one question, disagreeing, and the disagreement fell the wrong way -on the security-critical side: this is the copy that performs the -**platform-admin promotion**, so it failed OPEN. Its worst shape is the system -account's own id arriving as a bare string, which the old spelling would have -promoted. - -The predicate now mirrors `isHumanUserRow` — the same `typeof` guard, and a real -boolean return instead of echoing a falsy input back: - -```ts -const isHumanUser = (u: any) => - !!u && typeof u === 'object' && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; -``` - -**Why mirroring rather than a stricter rule of its own.** Over-tightening this -predicate has a worse failure mode than the bug: an install that cannot promote -its first admin is locked out of itself. The guard was therefore measured before -it was chosen, not after. Against a real `SqlDriver` over the shipped `SysUser` -declaration, every row a real `sys_user` read yields is a plain object — zero -truthy non-objects, and zero rows whose verdict moves when the guard is added. -The mirrored guard is also already the incumbent on this exact population: -`plugin-auth`'s dev-admin seed filters the byte-identical read (`sys_user`, -`where: {}`, `limit: 50`, system context) through `isHumanUserRow` today. - -**No reachable behaviour changes.** The divergence is unreachable through any -live call site, so this ships as a hardening of malformed-input handling rather -than a behavioural fix. The 14 existing agreement cases in the cross-package -pin are byte-for-byte unmoved; the pin gains the non-object class it previously -had to exclude (it would have failed), which is what now stops the asymmetry -returning — consolidating the two copies into a shared package stays declined, -so nothing else was going to retire it. diff --git a/.changeset/i18n-flow-bucket-liveness-gate.md b/.changeset/i18n-flow-bucket-liveness-gate.md deleted file mode 100644 index 6e12c7b3ec..0000000000 --- a/.changeset/i18n-flow-bucket-liveness-gate.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/lint": patch ---- - -fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624) - -`os lint` computes i18n coverage and runs the authoring-rule registry in a -single pass over the same stack, and for the `flows` translation group the two -halves pointed opposite ways: - -| the author does | which rule fires | what it says | -|---|---|---| -| omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X | -| adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it | - -Measured on one stack, one run: omitting produced **4** `i18n/missing-flow` -findings and 0 liveness findings; authoring produced 0 demands and **2** -`liveness-planned-property` findings ("sets `flows` but this translation -property is planned"). There is no per-rule suppression in `os lint`, only -`--skip-i18n`, which silences the entire `i18n/missing-*` family — so the -author's only escape cost them every other coverage signal. Under -`--i18n-strict` the demand side is an **error**, so a project could be forced -to author keys it is then warned for. - -⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner -reads the group, so a translated wizard string is stored and never shown — the -failure mode `validationMessages` was removed in 17.0.0 for. The premature half -is the demand. - -**The fix.** `collectExpectedEntries` — the single definition of what is -translatable at all, shared by the coverage gate and the `os i18n extract` -skeleton — now leaves out any translation group the liveness ledger warns -authors for authoring. It reads that set from `@objectstack/lint`'s new -`authorWarnedProperties(type)`, which returns the very warn-map -`lintLivenessProperties` iterates, so the demand side and the warn side cannot -drift into disagreeing about the same keys again. - -Two properties fall out of reading the ledger rather than switching on `flows` -by name: the bucket **turns itself back on** the day an objectui screen-flow -runner lands and the row flips to `live` (no flag, no follow-up edit), and any -future group that acquires an `authorWarn` is covered on the day it is marked -rather than re-opening this collision one group at a time. Today `flows` is the -only such group — pinned as an equality so a second one goes red instead of -shipping. - -No other bucket changes: `objects`, `apps`, `pages`, `dashboards`, -`globalActions` and `metadataForms` are all `live` and are reported exactly as -before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` + -`authorWarn: true`. diff --git a/.changeset/i18n-generated-leaf-source-provenance.md b/.changeset/i18n-generated-leaf-source-provenance.md deleted file mode 100644 index 0315927283..0000000000 --- a/.changeset/i18n-generated-leaf-source-provenance.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/cli": minor ---- - -feat(platform-objects,cli): record which source revision a generated translation leaf was filled from (#11671) - -Closes the half of the sticky-translation-drift class that no value comparison -could reach, under maintainer ruling #12069 Option A — by extending the existing -#8765 Option B source-hash mechanism to the generated bundles rather than -building a second one. - -**The hole.** `os i18n extract --fill=default` fills gaps only: any non-empty -value in a translated locale wins forever. So the ordinary sequence — extract, -revise the source string, extract again — rewrites `en` and strands the previous -source text in every other locale. The bundle is still in sync by key, so -`check:i18n` reports OK; the leaf is still present, so `check:i18n-coverage` -counts it translated. Measured on #11659 at `bbe0b17`: three locales serving a -602-char superseded draft of a 411-char help string under 31 green checks. Once -the source has moved, that stale fill is indistinguishable **by value** from a -real translation — 2648 of 3010 leaves differ from `en`, so "untranslated AND -differing from the source" describes an empty set, not a noisy one. - -**What is new.** `os i18n extract --source-hashes` writes -`.source-hashes.generated.ts` beside each generated bundle: per leaf, -the digest of the source revision that leaf is **still a byte copy of**. -`withSourceFallback` takes that table as a fourth argument and now judges the -`objects` / `metadataForms` sections as well as the hand-authored ones, so a -leaf whose source has moved underneath it serves the current source string -instead of a superseded draft — the same degradation an untranslated key already -produces, which is the invariance the #8765 ruling turned on. - -The generated half needs one conjunct the hand-authored half does not: the leaf -must still hold the recorded bytes. Its hash table is itself generated, so a -translator cannot be asked to refresh a digest by hand the way -`.source-hashes.ts` asks; without that conjunct, re-translating a stale -leaf would leave the old record standing and report the fresh translation as -stale forever. With it, editing the value clears the flag by itself. - -**Behaviour on the day it lands: unchanged for every leaf.** Records are -written only where a leaf is currently a byte copy of the **current** source, so -every record equals the current digest and nothing is stale. Measured across the -nine bundle sets: 9030 translated leaves, 1543 byte-equal to `en` (records -written), 7487 differing (left with no record — legacy-trusted, per the ruling's -property 1, since nothing in the tree says which revision they were made from). -No committed bundle changed a byte. - -**Scope.** `--source-hashes` is off by default and `@objectstack/platform-objects` -is the one bundle set that opts in, by documenting the flag in its extract -config. The other eight sets keep exactly today's behaviour and can be enabled -file-by-file later; a set with no companion is entirely legacy-trusted. - -The false "this hole cannot occur there" note that kept the generated sections -out of the mechanism is corrected in `source-hash.ts`, with the measurement that -falsifies it. diff --git a/.changeset/identifier-ceiling-storage-owned.md b/.changeset/identifier-ceiling-storage-owned.md deleted file mode 100644 index e21ba23c4f..0000000000 --- a/.changeset/identifier-ceiling-storage-owned.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): state that identifier length ceilings are storage-owned (#12144) - -The shared identifier schemas (`SystemIdentifierSchema`, -`SnakeCaseIdentifierSchema`, `EventNameSchema`) declare `.min()` plus a -grammar and no `.max()`. That absence is now a documented contract decision -rather than an accident: the enforced ceiling on an identifier is the -`maxLength` of the column that stores it (refused at the write seam by -ObjectQL's record validator), and the storing columns disagree — 100 for -`sys_permission_set.name` / `sys_position.name` / `sys_capability.name`, -255 for `sys_metadata.name` — so no single shared `.max()` can equal every -consumer's enforced ceiling. No accepted value changes. - -A pin test in `@objectstack/plugin-security` -(`identifier-storage-ceiling-pin.test.ts`) links the spec schemas to the -storage columns that bound them, reading the widths off the registration -surface (the PR #12143 idiom), so the two cannot drift silently: a `.max()` -landing below a storing column's width, or a column width change, turns a -named test red with re-derivation instructions. diff --git a/.changeset/identity-keyed-text-bounds.md b/.changeset/identity-keyed-text-bounds.md deleted file mode 100644 index 3b2e40240e..0000000000 --- a/.changeset/identity-keyed-text-bounds.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -Declare sourced `maxLength` bounds on the thirteen unbounded keyed identity -columns, so their declared indexes can exist on MySQL - -`driver-sql` (since #11430) honours a keyed text-family field's declared -`maxLength`, emitting `varchar(maxLength)` instead of `TEXT` — but thirteen -identity columns declared no bound at all, so on MySQL every one of their -declared indexes was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB -column cannot be a key without a prefix length) and the objects landed -registered-but-broken. Measured on live MySQL 8.0.46 (`+08:00`, -`STRICT_TRANS_TABLES`): schema-sync failures drop **12/44 → 8/44** platform -objects and physically-present declared indexes rise **89/128 → 104/128**, -with the Postgres 16 control at 0 failures on both legs. `sys_session`, -`sys_api_key`, `sys_device_code` and `sys_oauth_consent` sync completely -clean — a MySQL stack can now enforce the session-token uniqueness its -sign-in path assumes. - -Every bound is derived from a named source, none guessed (maintainer ruling -on #11374, 2026-08-24 — route A; the full table with sources is in the PR): -better-auth 1.7.1's own MySQL schema mapping (`session.token` / -`verification.identifier` → 255), its device-authorization plugin's hard -runtime cap of 191 on both codes, IdP norms (`account_id` 256 = SAML Core -NameID cap, above OIDC Core's 255 `sub` cap), the landed bounds of referenced -or producing siblings (`client_id` × 4 → 255 from -`sys_oauth_application.client_id`; `provider_id` 255 from -`sys_sso_provider.provider_id`; `issuer` 2048 from `sys_sso_provider.issuer`), -and the in-repo producer (`sys_api_key.key` 64 = fixed sha-256 hex). - -This is an enforcement change on published objects — hence the minor grade: a -write wider than its column's new bound is now **refused** (measured: a -300-char `sys_session.token` insert fails `ER_DATA_TOO_LONG` on a strict -server, 0 rows; a 255-char one lands). Every bound admits everything its -upstream producer can write, so only values the producing contracts already -forbid are affected. - -Deliberately not bounded, per the ruling's escape clause: -`sys_verification.value` (better-auth's oauth-provider stores JSON -authorization-code payloads there — no defensible bound exists), and -`sys_import_job.created_by` (outside this card's identity surface). -`sys_account.issuer`'s 2048 exceeds the 768-char utf8mb4 key ceiling on -purpose — tighter would refuse SSO sign-ins that `sys_sso_provider`'s own -contract admits — so its `(issuer, account_id)` unique stays for #11627's -hash-shadow route, alongside the `maxLength: 1024` token columns. A new pin -test enumerates every keyed text-family identity column and names any future -unbounded arrival. diff --git a/.changeset/import-job-created-by-bound.md b/.changeset/import-job-created-by-bound.md deleted file mode 100644 index 6db347e9e1..0000000000 --- a/.changeset/import-job-created-by-bound.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -Declare a sourced `maxLength` on `sys_import_job.created_by`, so its declared -index can exist on MySQL — route A's last column - -`driver-sql` (since #11430) honours a keyed text-family field's declared -`maxLength`, emitting `varchar(maxLength)` instead of `TEXT`, and #11699 -declared bounds on thirteen keyed identity columns. `sys_import_job.created_by` -is keyed by `(created_by, created_at)` and declared no bound at all, so on MySQL -that index was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB column cannot -be a key without a prefix length) and the object landed registered-but-broken. -It was the only remaining such object outside the >768-character class that -#11627 tracks. - -The bound is **255**, derived by referenced-column transitivity rather than -chosen: the column holds a `sys_user.id` stamped by the rest-server import route -from `context.userId`, and `driver-sql` creates every table's primary key as -`table.string('id').primary()` — knex's `varchar(255)` — so no id this column -can receive exceeds 255. It agrees with what the column would get if declared -like its siblings (`Field.lookup('sys_user')` emits -`DEFAULT_STRING_VARCHAR_CHARS` = 255) and with the landed declarations for the -same value class (`sys_metadata_audit.actor`, `sys_metadata_commit.actor`, -`sys_view_definition.owner`, all 255). A minted platform id is 26 characters, so -the bound clears the floor with 229 characters of headroom. - -This is behaviour-narrowing on a published object: on a strict MySQL server a -`created_by` longer than 255 is now **refused** (`ER_DATA_TOO_LONG`, 0 rows) -rather than stored, where previously the column was unbounded `TEXT`. No value -the producing contract can emit is affected, because the id it copies is itself -capped at 255 by its own column. - -The route-A pin moves from `identity/identity-keyed-text-bounds.test.ts` to -`platform-keyed-text-bounds.test.ts` and now enumerates **every** platform -object the package exports, not just `identity/`. That directory scoping is -exactly how this column escaped the first pass — the pin could not see it — and -a new control asserts the enumeration reaches columns in `audit/`, `metadata/` -and `system/` so the narrowing cannot silently return. diff --git a/.changeset/import-mapping-name-declared.md b/.changeset/import-mapping-name-declared.md deleted file mode 100644 index 7f307f2e32..0000000000 --- a/.changeset/import-mapping-name-declared.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Declare `mappingName` on `ImportRequestSchema` (and therefore on the aliased -`CreateImportJobRequestSchema`). Both import routes already accepted it on the -wire — `prepareImportRequest` resolves the named `mapping` artifact and refuses -`mappingName` plus an inline `mapping` with `400 CONFLICTING_MAPPING` — but the -published contract could not express it, so the typed SDK call -`client.data.import(object, { mappingName: '…' })` was a TS2353 compile error -(#10330). The key is now declared with the same mutual exclusion as a schema -`.refine()`, so a conflicting pair is rejected at authoring time as well as by -the route. Additive only: the schema is a plain `z.object` that strips unknown -keys, so no existing caller changes behavior. diff --git a/.changeset/import-object-name-override-namespace-prefix.md b/.changeset/import-object-name-override-namespace-prefix.md deleted file mode 100644 index 221d8d8320..0000000000 --- a/.changeset/import-object-name-override-namespace-prefix.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -`importObject` now refuses an explicit `opts.name` that violates the ADR-0028 -namespace-prefix rule (#11061). The override used to be taken verbatim -(`opts.name ?? draft.name`) and persisted through `metadata.register('object', -…)` — the one runtime write path no namespace gate looks at — so -`POST /api/v1/datasources/:name/external/tables/:remote/import` with -`{"name": "customers"}` minted an unprefixed federated object that -`defineStack()` and the publish pre-flight would both have refused. - -The refusal answers `400 EXTERNAL_IMPORT_ERROR` (the family's registered -ADR-0112 code, in the #8016 thrown-refusal shape) carrying -`validateObjectNamespacePrefix`'s own actionable message — the same text the -publish gate serves for the identical violation, e.g. `Object 'customers' is -missing the package namespace prefix. Rename it to 'wh_customers' (namespace = -'wh').` A compliant override (`wh_customers`), a `sys_*` platform-reserved -name, and any override on a datasource whose package resolves no namespace are -accepted exactly as before; the derived-name path (no `name` in the body) is -unchanged. diff --git a/.changeset/ingress-resolved-id-wins-over-payload-id.md b/.changeset/ingress-resolved-id-wins-over-payload-id.md deleted file mode 100644 index 09316d043c..0000000000 --- a/.changeset/ingress-resolved-id-wins-over-payload-id.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/service-settings': patch -'@objectstack/metadata': patch ---- - -Make the settings engine facade and the metadata database loader bind the row -they resolved, not a row the payload names - -Two ingresses resolved an authoritative row id and then folded it into the -write payload with the **losing** spread order — `{ id, ...data }` — so a -caller-supplied `data.id` spread over the id the ingress had just resolved and -silently retargeted the write: - -- `wrapEngineAsSettingsEngine`'s by-id `update` branch - (`@objectstack/service-settings`), whose id comes from the caller's - `where.id`. -- `DatabaseLoader._update` (`@objectstack/metadata`), whose id arrives as a - separate parameter every caller resolves first (`existing.id`, from the read - immediately above). - -Both now spell it `{ ...data, id }` — the operation's id **after** the spread, -so it wins. That is the convention the repo's other two ingresses already -document: `rest-server.ts`'s batch update arm ("the operation's id AFTER the -spread, so it wins") and `protocol.updateData`'s #6479 fix -(`{ ...request.data, id: request.id }`). - -**No wrong write is known to have been reachable.** Both sites' current callers -build fresh field literals and never put an `id` inside `data`, so this is -hardening a fragile pattern rather than repairing a measured defect. What makes -it worth the three characters is that neither site can be caught downstream: -both pass **no `where`** to the engine, so the payload is the only id the engine -ever sees, and the engine's conflicting-id refusal (`UPDATE_ID_MISMATCH`, 400) -needs two disagreeing declarations before it can fire. The fold is the entire -trust boundary at both sites, and it is one refactor — a caller handing back a -row copy, and rows carry `id` — from the #6479 shape. - -Both are pinned with a payload whose `id` names a **different** row than the -one the ingress resolved, asserting the resolved row is still the row bound. A -pin exercising a payload without an `id` would have passed against both -spellings. The doubles answer "which row does this bind?" with the producer's -own `assertEngineUpdateDispatch`, so they cannot be kinder about it than a -running server. diff --git a/.changeset/init-scaffold-object-file-spelling.md b/.changeset/init-scaffold-object-file-spelling.md deleted file mode 100644 index e68ba91295..0000000000 --- a/.changeset/init-scaffold-object-file-spelling.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os init` scaffolds its starter object as `_item.object.ts`, the spelling the registry declares (#11598) - -`objectstack init` wrote its starter object to `src/objects/_item.ts` -while `DEFAULT_METADATA_TYPE_REGISTRY` declares the `object` type as -`**/*.object.ts` / `.yml` / `.json`. Measured with `node:path`'s `matchesGlob` -against the registry read at runtime: `src/objects/my_app_item.ts` matched -**zero** of the three patterns, `src/objects/my_app_item.object.ts` matches -exactly one. Both `srcFiles` tables (the `app` and `plugin` templates) and the -barrel specifier they emit now carry the type infix. - -**This was a naming inconsistency, not breakage — measured, not assumed.** A -scaffolded project declares its objects in code (`import * as objects from -'./src/objects'` → `objects: Object.values(objects)`), so the object reaches the -stack through the barrel's *module specifier*, and `os dev` / `os serve` then -boot from the compiled `dist/objectstack.json` rather than by globbing source. -Three real scaffolds were compiled with the real `os compile` to establish it: -the old-spelled file **did** land in the artifact (so nothing was ever silently -skipped — this is not the #10359 silent-strip shape), a `*.object.ts` file -dropped into `src/objects/` but *not* re-exported from the barrel did **not** -land in it (so the registry glob was never on this load path), and the new -spelling lands identically. - -What it *was*: one CLI teaching two spellings for one metadata type. After -#11071 an author who runs `os init` and then `os g object customer` gets -`src/objects/my_app_item.ts` beside `src/objects/customer.object.ts` in the same -directory, from the same CLI. The registry spelling is the authority — the same -convergence #11071 settled — and it is already what `create-objectstack`'s blank -starter ships (`note.object.ts`), what the examples use -(`app-crm/src/objects/account.object.ts`), and what the getting-started docs -list as the house convention two lines under the callout that described the old -name. - -**Existing scaffolded projects need to do nothing.** The old filename still -loads exactly as it did — the barrel imports it by specifier and the filename is -not consulted. Renaming `src/objects/_item.ts` to -`src/objects/_item.object.ts` (and the matching `from './_item'` → -`from './_item.object'` in `src/objects/index.ts`) is an optional -consistency cleanup, not a migration: it changes nothing about how the project -builds, boots or behaves. Only newly scaffolded projects get the new name. diff --git a/.changeset/introspection-contract-withdraw-indexes-widen-defaults.md b/.changeset/introspection-contract-withdraw-indexes-widen-defaults.md deleted file mode 100644 index 5d10c236df..0000000000 --- a/.changeset/introspection-contract-withdraw-indexes-widen-defaults.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/driver-sql": patch -"@objectstack/objectql": patch ---- - -Withdraw the never-honored `IntrospectedTable.indexes` promise and widen two -introspection declarations to the measured emitted types (#11122, maintainer -ruling 2026-08-23, option B — 「其他同意你的意见」). - -The spec's introspection contract (`schema-diff-service.ts`) declared -`indexes: IntrospectedIndex[]` as REQUIRED, yet no producer has ever emitted -it — a consumer typed against the promise read `undefined` with no compiler -complaint. It also declared `defaultValue?: string` while the in-tree SQL -driver passes `knex.columnInfo().defaultValue` through raw (measured on live -SQLite: `null` for a column with no default, dialect-quoted strings such as -`'abc'` otherwise; other producers report native values such as `true`). - -- `IntrospectedTable.indexes` is now **optional**, and absence is meaningful: - an absent key means the producer did not read indexes; an empty array is a - positive claim the table HAS none. Producers that did not look must omit - the key rather than emit `[]`. Wiring the index read into - `introspectSchema()` is explicitly NOT part of this change. -- `IntrospectedColumn.defaultValue` is now `unknown` — consumers narrow - before use instead of trusting a string promise no producer kept. -- The SQL layer's extra `maxLength` fact (driver-sql / objectql - `IntrospectedColumn`, driver-sql `PhysicalColumn`) widens from `number` to - `number | string` — SQLite reports the string `"255"` where other dialects - report a number. - -With the spec now telling the truth, the deliberate `Omit` workarounds in -`@objectstack/driver-sql` and `@objectstack/objectql` (which carved -`defaultValue` and `indexes` out of the spec types to keep the divergence -visible) are retired: both packages' introspection types now extend the spec -contract directly. - -Consumers that read `table.indexes` must guard for absence (none exist -in-tree — the requirement was never honored, so today's readers would have -crashed on `undefined` anyway); consumers of `defaultValue` must narrow from -`unknown` before string operations. diff --git a/.changeset/invitation-probe-page-ceiling.md b/.changeset/invitation-probe-page-ceiling.md deleted file mode 100644 index ec445aaafc..0000000000 --- a/.changeset/invitation-probe-page-ceiling.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): the invitation carve-out stopped admitting past 200 pending invitations (#11770) - -Under the `invite_only` audience posture that #11739 made the default, an -administrator could send an invitation and the invitee's account creation would -still be refused with `SELF_REGISTRATION_CLOSED` — silently, with no signal to -either party — as soon as the environment held more than 200 concurrently -pending invitations. A 500-employee onboarding is an ordinary way to reach that. - -`AuthManager.hasPendingInvitationFor` answered "does this address hold a pending -invitation?" by reading at most 200 rows filtered only on `status = 'pending'` -and scanning them in memory for a case-insensitive email match. Past the first -page the invitee simply was not there, so the fail-closed `catch`-alike branch -applied the posture and refused a legitimate invitee. - -The address now goes into the query — `sys_invitation.email` carries a declared -index — and the page chain is exhausted, so no row count can hide a live -invitation. A page is "pending invitations addressed to this one person", which -better-auth bounds by refusing a second pending invitation per organization, so -this is not a read of the environment's pending population on the sign-up path; -in practice it is a single indexed lookup where the old code always read 200 -rows. - -The in-memory scan existed on the stated belief that invitation addresses are -stored as the inviter typed them. Measured against the installed better-auth -1.7.1, that is wrong on both halves — `organization/invite-member` lowercases -the address before storing it, and `internalAdapter.createUser` lowercases the -registrant's before calling `validateUserInfo` — and the vendor's own -`findPendingInvitation` / `listUserInvitations` / `findMemberByEmail` all query -with `email.toLowerCase()`, so a mixed-case row was never redeemable through -`accept-invitation` anyway. - -The row-side comparison is kept rather than deleted: `=` folds case on some -collations and folds accents with it, so every returned row is re-checked -against the normalized address — a case-only difference still matches, an -accent-only difference does not. Expiry stays in JS so a row with no readable -`expires_at` keeps reading as live. The security properties are unchanged: -`status = 'pending'` only, expiry still enforced, and an unanswerable probe -still means no carve-out. diff --git a/.changeset/invite-entry-on-members-tab.md b/.changeset/invite-entry-on-members-tab.md deleted file mode 100644 index cf2bebfe71..0000000000 --- a/.changeset/invite-entry-on-members-tab.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/spec': patch ---- - -Surface the email-invite entry on the organization record page's default -Members tab, and stop it rendering as a twin of "Add Member" - -The in-shell Team surface (`sys_organization` record page, ADR-0081) opens on -tab-0 **Members**, whose related-list toolbar carried exactly one action — -`add_member`, which attaches an **already-registered** user by id. The -email-invite entry, `invite_user`, was declared only on `sys_invitation` and -`sys_user`, so it appeared only on tab-1 Invitations. An admin looking to -"invite a teammate by email" landed on Members, found no invite affordance and -concluded the product had none. The delivery half worked the whole time -(`sendInvitationEmail`, template `auth.invitation`) — only the door was in -another room. - -`sys_member` now declares its own `invite_user` on `list_toolbar`, ahead of -`add_member`: same endpoint (`/api/v1/auth/organization/invite-member`), same -email + role inputs, and the same `requiresFeature: 'organization'` capability -gate as the other two mirrors. Declaration order is render order in the -related-list toolbar bridge, so the invite button sits left of the attach one. - -**The `email` param names `objectOverride: 'sys_invitation'`, and must.** -`sys_member` has no `email` field, so a verbatim copy of the `sys_invitation` -declaration would leave the param unresolvable — the renderer answers that with -a `type: 'text'` fallback labelled by the raw field name, which still submits -and still looks fine (the ADR-0078 valid-but-inert class). `role` needs no -override: `sys_member` declares it, from the same -`BUILTIN_MEMBERSHIP_ROLE_OPTIONS` constant `sys_invitation` reads. A test now -holds this over **all three** mirrors, so the next copy of any action cannot -reintroduce the shape. - -`add_member` keeps its behaviour and its label and is differentiated only in -chrome — `variant: 'secondary'` and `icon: 'link-2'` (the "attach an existing -record" icon `sys_account`'s `link_social` already uses) — so the two buttons -no longer render as identical primary `user-plus` twins. Both halves are -honoured by the renderer: it draws `primary` filled and every other variant -outlined. - -The `@objectstack/spec` half is one line of registry bookkeeping: -`PUBLIC_AUTH_FEATURES.organization.gatedInputs` books the new gated action, as -it already books the other twelve. No schema, export or authorable key changes. diff --git a/.changeset/invoked-as-reciprocal-pointers.md b/.changeset/invoked-as-reciprocal-pointers.md deleted file mode 100644 index a22d3ca023..0000000000 --- a/.changeset/invoked-as-reciprocal-pointers.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -docs(cli): `invocation.ts`'s `isProcessEntry` doc now names its two siblings - -The three-copy `argv[1]`-vs-`import.meta.url` predicate (`isProcessEntry` here, -`isEntrypoint`/`invokedAs` in this repo's `scripts/invoked-as.mjs`, and -objectui's own `scripts/invoked-as.mjs`) carried the "change one, change the -others" sync obligation in only one of the three copies — objectui's. Neither -objectstack copy pointed at the other two, so an agent editing either file -here had no way to discover that a third copy exists elsewhere (#12013). - -Both objectstack copies were otherwise correct and are not changed in -substance; only a reciprocal pointer is added to each, comment-only. diff --git a/.changeset/ja-jp-webhook-label-consistency.md b/.changeset/ja-jp-webhook-label-consistency.md deleted file mode 100644 index 6d157116a9..0000000000 --- a/.changeset/ja-jp-webhook-label-consistency.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/plugin-webhooks': patch ---- - -Fixed the `sys_webhook` object's `ja-JP` display name (`label`) to ウェブフック, matching the already-Japanese `pluralLabel` and the bundle's own help prose (`object_name.help`, `triggers.help`), which both use the same term. Previously `label` was a stale `Webhook` fill left over from a prior source revision, so the object rendered its own name two different ways within the same locale in the Setup/admin UI. `description` (which uses "Webhook" inside a longer Japanese sentence) and the `Webhook ID` field label are unchanged — this only renames the object itself. `zh-CN` is deliberately untouched: it uses `Webhook` for both slots on purpose, as there is no established Chinese rendering of the term in this codebase. diff --git a/.changeset/knowledge-manifest-turso-origin.md b/.changeset/knowledge-manifest-turso-origin.md deleted file mode 100644 index 926f407b71..0000000000 --- a/.changeset/knowledge-manifest-turso-origin.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Tell the operator where `@objectstack/knowledge-turso` comes from instead of -naming a package this repo does not build - -An operator who selects the `turso` knowledge adapter in **Settings → AI & -Embedder** and runs the connection test was told: "Mount -`@objectstack/knowledge-turso` to exercise live calls." That package is in no -directory of this repo (0 path hits on `main`; `knowledge-memory` and -`knowledge-ragflow` return 8 each under the identical command, so the zero is -real), and the message said nothing about where it does come from — leaving the -instruction un-followable at exactly the moment it is read. - -The prior question the card turned on — *is it published anywhere?* — is now -measured rather than assumed. Against the public npm registry on 2026-08-23, -with `@objectstack/spec` and `@objectstack/cli` as positive controls and -`@objectstack/security-enterprise` as a known-private negative control: -`@objectstack/knowledge-turso` **is published**, `latest` 6.9.0 (2026-05-27), -nine versions from 6.4.0. So the option stays — dropping it would have deleted a -working adapter. - -What it is *not* is co-installable with this platform version: 6.9.0 exact-pins -`@objectstack/spec@6.9.0` while this repo ships 17.2.0, so mounting it resolves a -second spec rather than reusing this one. The runtime message now names the -package, says this platform does not ship it, points at the ObjectStack Cloud -monorepo where it is built, and tells the operator to check for a release -matching their platform version — the framework#3366 discipline that an install -hint must carry its own edition/version boundary. - -The manifest's adapter-list comment loses the undated "mirrors the plugin -packages currently published" claim that stopped being true with nothing to -catch it, and gains the measurement with its date and method so the next reader -can re-run it. No behaviour change: `ok` and `severity` are untouched on every -branch of the test action. diff --git a/.changeset/lazy-pugs-shake.md b/.changeset/lazy-pugs-shake.md deleted file mode 100644 index 79201dbdcf..0000000000 --- a/.changeset/lazy-pugs-shake.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ApiEndpoint.target` is now **optional** in the vocabulary (#10338, maintainer ruling -2026-08-23). The key was required on every endpoint but read only for `type: 'flow'` -(executor, OpenAPI enrichment and the publish gate all address an `object_operation` -via `objectParams.object` / `.operation`) — so an `object_operation` author was forced -to write a dead string nothing consumed or cross-checked. Authoring guidance: omit -`target` on `object_operation` endpoints. The publish gate still **requires** `target` -for `type: 'flow'` — a flow endpoint that names no target flow is refused at publish, -and the runtime's structural backstop answers `501 NOT_IMPLEMENTED` for one that -reached the store another way. No migration: this is a pure widening — every previously -valid declaration (all of which carry a string `target`) still parses unchanged. diff --git a/.changeset/ledger-convergence-registration-and-one-store.md b/.changeset/ledger-convergence-registration-and-one-store.md deleted file mode 100644 index d9751dd0e9..0000000000 --- a/.changeset/ledger-convergence-registration-and-one-store.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/service-automation": minor -"@objectstack/objectql": minor -"@objectstack/core": minor ---- - -feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) - -Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling -(2026-08-26, verbatim and untranslated: 「同意」). - -## The registration follows the declaration (#12359) - -`sys_metadata_activation` is declared in `@objectstack/platform-objects`, but -the only thing that REGISTERED it was the automation service's manifest — -because flows were the ledger's first and, until packaged actions landed, only -consumer. Packaged actions are a second consumer with a different owner: their -consult and write path live on the ObjectQL engine, present in every -composition that can execute an action. - -So a deployment with actions and no automation service had no ledger table, and -the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — -correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be -reported as one) and permanently. Measured on a real boot; it is now this -change's positive test, measured on the same boot: - -``` -POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} - before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED -``` - -`PlatformObjectsPlugin` registers it now, so every composition carrying -platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, -`skill`, `position`) inherits it. **MOVE, not add** — the automation service no -longer names the object. That was not a style choice: a second code package -claiming one object throws `Object "…" is already owned by package "…"` -(ADR-0029 D3/D7), measured, so adding a registrant would have been a boot -failure rather than a duplicate. - -**Upgrade is a no-op for existing data, and that is measured rather than -asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` -step 4 routes an object by its owning package's `defaultDatasource` — so the -registrar carries the table's datasource with it: - -``` -owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' -owner com.objectstack.platform-objects (none) -> undefined (global default driver) -``` - -The ledger table already exists in live databases, so on any deployment -carrying a `cloud` datasource that difference would leave the rows in one -database and read another — every disabled artifact silently re-arming. The -ledger therefore rides its own manifest from the same plugin, carrying the -automation manifest's `scope` / `namespace` / `defaultDatasource` triple -verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, -`sys_secret`) deliberately do not get it and keep riding the project database. - -## One implementation of the §4 row contract (#12350) - -ADR-0126 §4 declares one activation ledger; it had two independent -implementations of that one row contract — `ObjectStoreFlowActivationStore` -(service-automation) and `ObjectStoreActionActivationStore` (objectql). They -agreed because the second was written from the first, and nothing structurally -held them together; §8 pre-charts `tool`, `skill` and `position`, and a third -and fourth copy is where the org-row skip and the `0`-is-false read get lost -quietly, in the direction (an artifact re-arming) nothing else measures. - -Neither consumer could import the other, so the contract now lives once in -`@objectstack/core` — the package both already depend on — as -`ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside -`InMemoryMetadataActivationStore`, `MetadataActivationRow`, -`MetadataActivationStore`, `MetadataActivationStoreEngine` and -`METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own -one-argument constructor and its own docs, and fixes the discriminator. - -**No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / -`InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and -`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / -`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` -/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same -shapes. Row semantics are byte-equivalent: install-level rows only -(`organization_id` never written), org-carrying rows skipped on read and -ignored when deciding insert-vs-update, a driver `0` read as false, -read-then-write rather than a blind upsert, and no `delete` in the engine slice -because re-enabling rewrites the row. - -Both existing pin suites stay green **unchanged**, which is what makes them the -proof the consolidation lost nothing — verified by ablation: removing the -org-row skip from the one shared implementation turns both of them red on their -own org-skip assertion, so both really reach it. diff --git a/.changeset/lint-liveness-translation-bundle-walk.md b/.changeset/lint-liveness-translation-bundle-walk.md deleted file mode 100644 index f0414afb71..0000000000 --- a/.changeset/lint-liveness-translation-bundle-walk.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -**Fix:** `lintLivenessProperties` walks `stack.translations` as the locale-keyed bundle it is, so the `translation` liveness ledger finally reaches the author (#11288). - -`stack.translations` is `z.array(TranslationBundleSchema)` — each item is a `TranslationBundle`, i.e. `z.record(LocaleSchema, TranslationDataSchema)`, whose top-level keys are locale codes. The lint registered `{ type: 'translation', key: 'translations' }` in `TYPE_COLLECTIONS` and then walked those items flat, the way every other collection there is walked: `checkItem` read `bundle['flows']` for the ledger's one `authorWarn` row. A bundle has no `flows` key at any depth reachable that way — the groups live one level down, under each locale — so every warned lookup missed and the whole `translation` ledger was silent for file-authored bundles, the only way apps author translations today. - -That is the failure mode the comment above `TYPE_COLLECTIONS` names ("a newly governed type needs its collection registered or its ledger warns nobody"), reached from the other side: the collection *was* registered, and the shape underneath it was the mismatch. Registering a collection is only half the contract — the walk has to match the collection's shape — so the row is now a tombstone comment saying exactly that, and `translation` joins `object`/`field` as a bespoke walk: for each bundle, each locale entry's `TranslationData` is checked, with the finding subject naming the bundle index and the locale (`translation bundle #0 · locale 'zh-CN'`). - -Measured on a real app before the fix, as a guarded ablation: injecting a `flows:` section into a locale bundle and re-running `objectstack lint --json` produced **zero** delta — 91 issues before and after, 0 liveness findings naming `flows`. The author who reached for a `planned` translation group got silence, which is strictly worse than the ledger being absent, because the ledger's stated contract is that `authorWarn` is what tells them. - -Advisory-only as before: the finding is a warning, and `os lint` exits on errors, never on warnings. - -The regression test is pinned on the **bundle** shape, and a `TranslationItem`-shaped anti-fixture is pinned alongside it. That shape — `locale` plus the groups at the top level — is the runtime metadata door, and it *warned on the broken walk*, so a fixture written that way would have been green from the day the bug shipped and pinned nothing. Runtime-authored `translation` items are reached by this lint through no door at all: no stack collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does not run at the runtime publish gate either. The two doors share the group vocabulary, not the container; only the file-authored one is lintable, and now it is linted. diff --git a/.changeset/listview-map-liveness-live.md b/.changeset/listview-map-liveness-live.md deleted file mode 100644 index bbadea09ea..0000000000 --- a/.changeset/listview-map-liveness-live.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Liveness-ledger verdict:** `view.list.map` (`ListViewSchema`'s view-level `map` block) moves `planned` → `live` (#11442). - -objectui#5908 landed the missing half of the forward: `packages/plugin-list/src/ListView.tsx`'s `resolveListMapConfig` now merges the view-level `map` block over the legacy `options.map` bag before `case 'map'` builds the `object-map` schema, and the same merged config also feeds the visualization-switcher's capability gate — so a view that binds its coordinates only in the spec's `map` block both renders on the map surface and is no longer filtered out of `allowedVisualizations`. Nothing about the schema shape or the authoring-time validation changed; only the runtime consequence of authoring the key does. - -Pinned by objectui `packages/plugin-list/src/__tests__/ListView.mapViewLevelConfig.test.tsx` (the forward, against a spy) and `packages/plugin-map/src/ObjectMap.listViewMapConfigReach.test.tsx` (the end-to-end read through a real `ObjectMap` — markers, titles, camera). Re-measured against objectui `origin/main@08ca73f8` (squash commit `e2e8e68` for #5908 confirmed an ancestor). diff --git a/.changeset/liveness-action-dispatch-anchors.md b/.changeset/liveness-action-dispatch-anchors.md deleted file mode 100644 index 6183968056..0000000000 --- a/.changeset/liveness-action-dispatch-anchors.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness ledger: repoint the `action` `type` / `body` / `method` anchors at their real consumers - -All three cited `packages/runtime/src/http-dispatcher.ts`, and none of them is read -there. The actions domain was extracted out of that file — it now only delegates -(`handleActions` → `handleActionsRequest` at :1969-1970) — and the reads live in -`action-execution.ts`, `domains/actions.ts`, `sandbox/body-runner.ts` and, for the -client-dispatched `method`, in the renderer repo. - -This is the residue of the same extraction that rotted `action.target` and -`action.requiredPermissions`, but it survived the sweep that repaired those, and the -reason is the interesting part. The key-mention check added with that sweep asks -whether the cited file names the key at all — which caught `target` and -`requiredPermissions` because `http-dispatcher.ts` contains **0** occurrences of -either. It cannot catch these three, because they are common English and HTTP words -that the file is full of for unrelated reasons: - -- `type` — 9 occurrences, four of them the TypeScript `import type` keyword, the rest - other domains' data (`error.type`, `details.type`, a field-type→JSON-Schema mapper). -- `body` — 68 occurrences: the inbound HTTP request body threaded through every - domain delegate, plus the file's own "body extracted to ./domains/…" comments, - where `body` means a *function* body. -- `method` — 41 occurrences: the inbound HTTP verb (15 `method: string` parameter - declarations, route matching such as `method === 'GET'`) and the ordinary - object-oriented sense in prose. - -So the word-bounded check anchors on the coincidence and passes. That is the designed, -honest limit of the signal rather than a defect in it — the census that shipped it said -so — and it means this class is invisible to tooling and only a hand call-graph read -can settle it. Each repointed entry now records which spelling misled the gate, so the -next reader does not have to re-derive it. - -`method` additionally CHANGES REALM, joining its siblings `bodyShape` and `bodyExtra`: -`type: 'api'` actions are client-dispatched by design, so the server never read the -verb. Its one in-repo appearance is a diagnostic that interpolates the verb into the -refusal explaining the server does *not* dispatch it — evidence of non-consumption, -and deliberately not cited as a consumer. - -No verdict was re-graded: all three were `live` and remain `live`, with the consumer -proven rather than asserted. Citation repair only. diff --git a/.changeset/liveness-citation-key-mention.md b/.changeset/liveness-citation-key-mention.md deleted file mode 100644 index f1ac1d49b4..0000000000 --- a/.changeset/liveness-citation-key-mention.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness gate: a citation must name the property it is evidence for - -Two checks already bounded a `live` entry's citation, and both bounded it from the -outside — the cited file must exist (#5623), and a cited line must be inside it -(#11210). Between them sat a gap neither could see: a consumer that moves *within* -the file it is cited to, or a citation written with no line at all, leaves the file -present and every named line in range. The pointer is wrong and the gate is green. - -Measured over the whole ledger before anything was switched on: 403 (entry, cited -local file) pairs, **11** where the cited file never mentions the property's own key, -and **7 of those 11 were real rot** — repaired here: - -- `permission.objects.allowExport` — `annotateEffectiveApiOperations` moved to - `current-user-endpoints.ts`; the same repos-internal movement that had already - rotted `permission.systemPermissions` and `permission.tabPermissions`. -- `object.tenancy.organizationField` — the resolver was promoted into - `@objectstack/metadata-core`; the cited `audit-writers.ts` says so itself, in the - re-export comment left behind. -- `action.target` / `action.requiredPermissions` — the actions domain was extracted - out of `http-dispatcher.ts`, which retains 0 occurrences of either key. -- `action.bodyShape` / `action.bodyExtra` — client-dispatched keys whose only - consumer has always been the renderer; the in-repo citation could not have been - right at any point. Now attributed to `objectui` with the commit pinned. -- `field.requiredWhen` — cited its *sibling* `record-validator.ts`, which enforces the - static `required` contract; the CEL predicate is evaluated one file over in - `rule-validator.ts`. Both files exist, so nothing could see it. - -The remaining 4 are the `camelCase` → `snake_case` convention this platform mandates -(Prime Directive #3): a property persisted as a column is read as `body_html`, -`managed_by`, never as the authoring key. Three are handled **structurally** — the -matcher folds the key across the naming convention rather than exempting them — and -the match is word-bounded so a prefix cannot satisfy the key (`required` is not -`requiredWhen`, which is precisely how that rot stayed hidden). The one residual is a -compound *child*-key remap (`fromOverride.address` → `from_address`) that no fold of -the parent key reaches, and it is a single explicit row in the shrink-only -`scripts/liveness/key-mention.baseline.json`, which fails in **both** directions: a -row whose pair later anchors must be deleted. - -So the check ships red-capable at zero unexplained hits, which is the whole reason the -census came first — `evidence.mts`'s header records what the alternative costs, when -48 of 227 entries were flagged, every one was a false positive, and the single genuine -rot inside that list sat unread. - -The check asks `evidence` only, never `producer`: a producer cites *who supplies a -second input* (#4837), which is by definition a call site and need not name the key at -all. diff --git a/.changeset/liveness-citation-line-bound.md b/.changeset/liveness-citation-line-bound.md deleted file mode 100644 index 37b25fddd0..0000000000 --- a/.changeset/liveness-citation-line-bound.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness gate: bound an evidence citation's LINE, not just its file - -`check:liveness` resolved a `path/to/file.ts:NNN` pointer with `existsSync` on the -path alone — the parser did not even retain the `:NNN` — so a consumer that moved -out of a file which still exists kept a passing pointer, was counted under the word -"resolved", and left its ledger entry reading as freshly verified. A citation that is -dead but precise-looking is worse than a missing one: it survives review, and the next -agent re-verifying the entry follows it, finds nothing, and rebuilds the call graph -from scratch. - -Citations are now bounded by the cited file's length, for `evidence` and `producer` -alike (they already share one resolver). A range `:12-34` is bounded by its END. Every -citation in a `+`-joined multi-consumer entry is bounded, not just the first. -Cross-repo attributions (`objectui: …`, `cloud: …`) are still counted and never -resolved. The run prints how many citations it checked beside how many are in range, -so a parser that degraded to extracting nothing cannot read as a pass. - -Two shipped instances, both repaired here and both real: -`permission.tabPermissions` cited `hono-plugin.ts:1200` in a 717-line file that no -longer mentions the property (all three of its pointers were dead — one past EOF, two -within bounds), and `mapping.fieldMapping` cited a range ending three lines past the -end of `import-mapping.ts`. diff --git a/.changeset/liveness-lint-null-collection-item-guard.md b/.changeset/liveness-lint-null-collection-item-guard.md deleted file mode 100644 index 4998eb6888..0000000000 --- a/.changeset/liveness-lint-null-collection-item-guard.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`lintLivenessProperties` now honours its own docblock contract ("Advisory only -— returns findings, never throws") when a collection item is `null` or -otherwise not an object. The object walk, the field walk nested under it, and -the flat `TYPE_COLLECTIONS` loop that covers every other governed type (flow, -action, agent, tool, …) each read `item.name`/`item.object` straight off every -element with no record guard, throwing `TypeError: Cannot read properties of -null (reading 'name')` on a malformed item instead of skipping it — reachable -via the exported `stack: AnyRec` signature on an unparsed or hand-built stack. -The translation bundle walk already guarded its two levels (#11383); this -closes the same hole on the three walks that did not (#11385). diff --git a/.changeset/llms-txt-freshness-gate.md b/.changeset/llms-txt-freshness-gate.md deleted file mode 100644 index bbfccdcb46..0000000000 --- a/.changeset/llms-txt-freshness-gate.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Gate the shipped `llms.txt` against the real package, and repair the claims that had rotted. - -`packages/spec/llms.txt` ships in the npm tarball as context for AI consumers and is -hand-kept with no generator, so nothing ever re-derived what it asserts. It had drifted -badly: eleven advertised symbols existed in no entry point (`IUIService` — removed in -v11 — plus `ThemeSchema`, `IdentitySchema`, `PolicySchema`, `ContractSchema`, -`EndpointSchema`, `RAGPipelineSchema`, `MCPSchema`, `FilterSchema`, `AnalyticsSchema`, -`FormSchema`), two advertised packages did not exist (`@objectstack/nextjs`, -`@objectstack/nestjs`), the schema-inventory heading disagreed with the sum of its own -table (171 vs 170) and with the tree (207), and the package heading claimed 19 against a -real 68. An agent reading the file wrote imports that do not resolve. - -New gate `check:llms-txt` re-derives every checkable claim on every PR: advertised -symbols against the checked-in `api-surface/` shards, `@objectstack/spec/x` subpaths -against the manifest `exports`, the per-domain schema counts against -`src//**/*.zod.ts`, and the package table against the workspace. Symbol claims -are resolved at the strictness their position earns — namespace bullets and fenced -imports name an entry point and must resolve from it, while the architecture overview -resolves against the union. Prose, code-fence bodies and `N+` lower-bound figures are -out of population and the script header says why. - -There is deliberately no `gen:llms-txt`: the numbers are not the claim, the prose beside -them is, and restamping a count without re-reading its row would turn a loud staleness -into a silent lie. diff --git a/.changeset/localization-failure-memo-backend-leg-only.md b/.changeset/localization-failure-memo-backend-leg-only.md deleted file mode 100644 index ef9fb5b170..0000000000 --- a/.changeset/localization-failure-memo-backend-leg-only.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/core": patch ---- - -fix(core): only a backend fault populates `resolveLocalizationContext`'s failure memo (#11877) - -`resolveLocalizationContext` memoizes an outcome for 30s whenever the read -"failed" (#10221 — so a repeatedly-failing `sys_setting` query does not re-run, -and the driver does not re-log it, on every request). The write condition was -wider than the cache's own docblock: six legs set the flag and only **one** of -them is the backend fault the docblock describes (the direct `ql.find` throw). -The other five are the **settings service refusing** — a thrown `getMany`, each -of the three older per-key `get`s, and the whole-block "service unavailable" -handler. - -Those five legs are reachable inside the settings engine's **bind window** -(`SettingsService.getMany` refuses all-or-nothing for a `localization` -namespace whose manifest is not yet registered), so: - -- A caller that deliberately re-reads **after** the bind — the #11580 stdio - repair re-resolves at `kernel:bootstrapped` for exactly this reason — was - answered from the memo taken **inside** the window for up to 30s. The - correction silently did not happen, with nothing in the output saying so. -- A settings refusal standing alongside a perfectly **successful** direct read - memoized that successful value — the staleness the docblock forbids outright - and that `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to catch. - -The memo is now written only for the direct-read fault. **#10221's protection -is unchanged for the legs it was built for**: its environment (table not -migrated yet) still memoizes, because the direct read throws there whether or -not a settings refusal stands in front of it — pinned in both directions. And -nothing is lost on the narrowed legs: those refusals throw out of an in-memory -registry check *before* any query and *before* any log line, so memoizing them -suppressed neither. - -No signature, export or accepted-input change — the flag is internal to the -module. diff --git a/.changeset/lock-packaged-permission-set.md b/.changeset/lock-packaged-permission-set.md deleted file mode 100644 index dbc1d9d4ff..0000000000 --- a/.changeset/lock-packaged-permission-set.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -Lock package-declared permission sets at the save door; clone to customize (#11513) - -Maintainer ruling of 2026-08-24, recorded verbatim and untranslated: -「同意 第一步(创业阶段,Salesforce 式)」 — step 1 of the mainstream-platform -comparison: lock the base, clone to customize. - -A Studio/API save that targets a **package-declared** permission set is now -**refused at the server**, with a message that names the sanctioned path — clone -it and edit the clone. Previously the data door translated the write into a -metadata write and left the refusal entirely to the metadata protocol's ADR-0005 -tier gate. That gate is exactly what the documented -`OS_METADATA_WRITABLE=permission` operator hatch switches off, so on a -deployment running with the hatch there was no refusal at all: the save minted a -`sys_metadata` overlay of a packaged set, and boot reconciliation re-projected -that overlay onto the record on every boot, unconditionally, forever — the set -froze at the fork and every future package upgrade of it was ignored, silently. - -**Clone-to-customize** is the sanctioned path and is unchanged: the clone is an -ordinary org-owned set (`managed_by: 'admin'`, no `package_id`, so no upgrade -linkage), and upgrades keep flowing to the package-declared base untouched. - -**Existing forks** get a **detection reading** at boot — count *and names*, -warned loudly, saying outright that nothing was reaped. It reads `sys_metadata` -directly rather than the `customized` column, which is forced `false` on the -exact confounded shape the field report measured (a genuinely package-declared -set whose row's `managed_by` predates provenance tracking). Nothing is reaped, -merged or migrated: disposition of an existing fork is a follow-up reading for -the maintainer, and the per-set remedy remains the explicit, audited -"Discard Overlay" action a human invokes. - -Behaviour deliberately NOT narrowed: - -- an **ordinary org-owned** set is still fully editable (pinned as a control — - a lock that refuses everything would satisfy the refusal pin perfectly); -- the **activate / deactivate** actions still write their column: a bare - `{ active }` patch is row state, not a customization of the definition; -- a `managed_by: 'package'` row with **no artifact behind it** — published - through the metadata door (ADR-0070) and materialized by the ADR-0086 P2 - path — keeps editing in place. That is ADR-0094 D5-R's surviving - `allowRuntimeCreate` neighbour, and `managed_by` is measurably not the - artifact-provenance fact. Provenance is read from the engine SchemaRegistry, - the one source this plugin already calls "package-declared". - -Provenance is **fail-closed**: a read that cannot answer refuses the save rather -than accepting it, and the read is not a name-keyed page over -`sys_permission_set`, so it cannot be truncated into a false "not packaged". diff --git a/.changeset/lucky-jokes-shave.md b/.changeset/lucky-jokes-shave.md deleted file mode 100644 index c6bfb67b4f..0000000000 --- a/.changeset/lucky-jokes-shave.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/runtime": minor -"@objectstack/spec": minor ---- - -Packaged actions can be switched off, on the same activation ledger as flows - -A packaged action can now be disabled for an installation, generalizing the -packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on -the maintainer's amendment ruling 3). The flip writes an install-level row to -the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — -no new table, no new column, no schema change of any kind. Absence of a row -means the packaged default, active, so a deployment that never flips anything -behaves exactly as before, and an empty ledger changes nothing anywhere. - -The consult point is action DISPATCH, and it is present on every door that -dispatches a declared action: the REST `POST /actions/:object/:action` route and -the MCP `run_action` bridge. Both call one shared guard, and a disabled action -is refused `409 ACTION_DISABLED` before anything runs — before the handler body -(which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action -reaches the automation engine, before the param contract is enforced and before -the subject record is read. The refusal names the ledger and the remedies. The -code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and -answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, -which would tell an operator to go looking for a flow that does not exist. - -The consult reads a projection the ObjectQL engine holds and hydrates at boot, -so a disabled action stays disabled across a restart and across the handler -re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — -the ledger records the customer's choice, and nothing re-arms it silently). - -The write door is `POST /actions/_activation/:object/:action` with a -`{ enabled?: boolean }` body. Its first segment is reserved rather than deep in -the path because a machine name can never begin with `_`, so it cannot collide -with an object, an action or a record id. It carries the same two authority -tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture -rule — in the `group` and `isolated` postures the install-wide switch requires -the platform operator, while `single`, where install-level and org-level are the -same scope, is unchanged. That gate is now one implementation shared with -`POST /automation/:name/toggle`; the flow refusal text is unchanged. - -Two refusals are worth knowing about. The ledger addresses an action by its -machine name, so a name declared on more than one object is refused with -`409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off -silently. And a flip that cannot be made durable — no ledger table reachable — -is answered as a failure instead of a 200, because a switch reported as durable -that reverts on the next restart is the failure this whole family exists to -remove. - -Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so -disable is the only primitive here and authoring a new sibling action stays -exactly as it is today. diff --git a/.changeset/manager-of-org-screen.md b/.changeset/manager-of-org-screen.md deleted file mode 100644 index 5de7ee32bc..0000000000 --- a/.changeset/manager-of-org-screen.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -fix(sharing): honour the declared `organizationId` in `managerOf` (#10231) - -`ITeamGraphService.managerOf(userId, organizationId?)` declares an organization -parameter. `TeamGraphService.managerOf` spelled it `_organizationId` and -discarded it, and the `BusinessUnitGraphService` standalone fallback read -`sys_user` the same unscreened way — a declared-but-unenforced parameter on a -security seam, while `expandRoleUsers` on the same class applied -`organization_id` to its own read. - -Both now apply the screen #10153 landed for the identical column -(`sys_user.manager_id`) on the approvals side: a manager who is **provably** -outside the caller's organization — membership rows exist for him, none of -them in that organization — is dropped. The read is `sys_member`, because -`sys_user` is the global better-auth identity table and carries no -`organization_id` at all; filtering the `sys_user` read on a column that does -not exist would match nothing and silently return `null` for every lookup. - -The screen is fail-open on an ABSENT tenancy fact (no membership rows, or the -membership read failed) and issues no query at all when no organization is in -play, so callers that pass nothing — which is how the parameter is used today — -are byte-identical to before. The manager cache key is now organization- -qualified; a user-keyed cache would have served one screened `null` to every -unscoped reader behind it. diff --git a/.changeset/manifest-contributes-dead-members-retired.md b/.changeset/manifest-contributes-dead-members-retired.md deleted file mode 100644 index cc284cc7a5..0000000000 --- a/.changeset/manifest-contributes-dead-members-retired.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the nine dead members of the plugin-manifest `contributes` block — `events` / `menus` / `themes` / `translations` / `actions` / `drivers` / `fieldTypes` / `functions` / `commands` (#10724, ADR-0049 enforce-or-remove) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -The census behind it (#10627, controlled and re-verified at claim time across -objectstack, objectui and cloud) measured that the ENTIRE monorepo contains -exactly one non-test read of `manifest.contributes`, and it reads `kinds` -(`packages/objectql/src/engine.ts` → `registry.registerKind`). The other nine -members parsed, entered the manifest, and changed nothing — while published -material kept teaching them: `commands` documented Commander.js runtime -resolution the CLI dropped for oclif auto-discovery, `fieldTypes` advertised a -registration seam that has never existed, and `events` was decorative even for -its only in-repo author, which already subscribes imperatively. - -**What is refused:** authoring any of the nine keys. Each is a `retiredKey()` -tombstone (the `manifest.loading` precedent — neither `ManifestSchema` nor the -`contributes` object is `.strict()`, so a plain deletion would have silently -stripped the keys), so authoring one is a `tsc` error and a parse error -carrying the per-key prescription. - -**FROM → TO, per member** (each tombstone carries its own one-line fix): - -- `contributes.events` → subscribe in plugin code (`ctx.hook('kernel:ready', …)` - from `init`/`start`); delete the key. -- `contributes.menus` → app `navigation` / `manifest.navigationContributions` - (ADR-0029 D7); delete the key. -- `contributes.themes` → the stack-level `themes` metadata collection (an - unrelated `ThemeSchema` surface); delete the key. -- `contributes.translations` → the `translation` metadata type: - `defineTranslationBundle` in `defineStack({ translations })`; delete the key. -- `contributes.actions` → the stack `actions` collection or - `engine.registerAction`; delete the key. -- `contributes.drivers` → register a kernel service named `driver.*`; delete - the key. -- `contributes.fieldTypes` → nothing (no registration seam exists; the - vocabulary is the spec `FieldType` enum); delete the key. -- `contributes.functions` → `defineStack({ functions })`; delete the key. -- `contributes.commands` → oclif native plugin auto-discovery (an `oclif` - section in the plugin's own `package.json`; see `cli-extension.zod.ts`); - delete the key. - -**What stays:** `contributes.kinds` (the block's one live member) and -`contributes.routes` (an open enforce-or-remove fork, #10726 — deliberately -untouched here). Runtime behaviour is unchanged: nothing ever read the nine -members, so removing them removes no behaviour; a stored manifest still -carrying one degrades to a single `[metadata_spec_invalid]` log line at -registration rather than a boot failure. - -D3 semantic entry `plugin-manifest-contributes-dead-members-retired`; no D2 -conversion, because a package manifest is not a stack collection member -(`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry) and a conversion -would be a transform with no seam that ever runs. diff --git a/.changeset/manifest-contributes-routes-retired.md b/.changeset/manifest-contributes-routes-retired.md deleted file mode 100644 index 582a31f905..0000000000 --- a/.changeset/manifest-contributes-routes-retired.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `contributes.routes` — the plugin-manifest block's last dead member (#10726, ADR-0049 enforce-or-remove; maintainer-ruled Option B 2026-08-22) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`contributes.routes` was the one member #10724's nine-member retirement -deliberately excluded: removing it needed a ruling, not a tombstone, because -the key was the only *declared* channel for a real capability (serving a -code-handler endpoint) and four published surfaces — a customer-published -skill among them — taught it as working machinery. The measurement (#10627, -controlled, three repos, cloud leg closed clean by #10812) is that nothing -ever read it: the HttpDispatcher never registered a prefix from the -declaration, so an entry parsed cleanly and served nothing. The maintainer -ruled Option B (remove; redirect the author-facing materials to the -imperative mount). The doc corrections landed first (PR #11327); this change -is the removal half, plus the two remaining teaching sites (#11328): the -worked manifest example in `plugin-rest-api.zod.ts` and the `router` -delivered-form comments in `metadata-plugin.zod.ts`. - -**What is refused:** authoring `contributes.routes`. It is a `retiredKey()` -tombstone (neither `ManifestSchema` nor the `contributes` object is -`.strict()`, so a plain deletion would have silently stripped the key), so -authoring it is a `tsc` error and a parse error carrying the prescription. - -**FROM → TO:** - -- `contributes.routes: [{ prefix, service, methods? }]` → mount the route - imperatively: resolve the `http.server` service from the plugin context and - register the handler on `kernel:ready`; delete the key. A declarative - endpoint over a pipeline the platform already runs (query/return records, - trigger a flow) is `defineStack({ apis })`. - -**What stays:** `contributes.kinds`, now the block's sole live member -(engine → `registry.registerKind`). Runtime behaviour is unchanged: nothing -ever read the key, so removing it removes no behaviour; a stored manifest -still carrying one degrades to a single `[metadata_spec_invalid]` log line at -registration rather than a boot failure. - -D3 semantic entry `plugin-manifest-contributes-routes-retired`; no D2 -conversion, because a package manifest is not a stack collection member -(`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry) and a conversion -would be a transform with no seam that ever runs. diff --git a/.changeset/manifest-kind-globs-retired.md b/.changeset/manifest-kind-globs-retired.md deleted file mode 100644 index 6a5c7e31c1..0000000000 --- a/.changeset/manifest-kind-globs-retired.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `contributes.kinds[].globs` — the declared file-type watch patterns nothing ever read (#11169, ADR-0049 enforce-or-remove; maintainer-ruled 2026-08-24) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -**Removed key:** `manifest.contributes.kinds[].globs`. - -The schema promised that declaring `globs` "enables the system to parse and -validate new file types" (its own example: a BI plugin handling -`*.report.ts`). The promise was never kept: real glob-driven artifact -discovery reads `filePatterns` off the metadata type registry — which -`contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records -outright — so an authored `globs` was accepted, stored, served back through -`GET /metadata/kind`, and never consulted. Measured (PR #11168, re-verified -with positive control at claim): zero value reads anywhere; the only non-test -occurrences of the path were the schema declaration and two type positions. - -**FROM → TO:** `kinds: [{ id, globs: […], description? }]` → -`kinds: [{ id, description? }]` — delete the `globs` key; the kind's `id` and -`description` are unchanged and still register. The key is a `retiredKey()` -tombstone, so authoring it is a `tsc` error and a parse error carrying this -prescription. - -**What stays:** the `contributes.kinds` bucket itself and its `id` field -(live: engine → `registry.registerKind`, served via `GET /metadata/kind`). -File-type discovery remains single-channel on the metadata type registry's -`filePatterns`; if plugin-extensible discovery is ever wanted, it gets -designed against that registry, not revived here. The `registerKind` / -`getAllKinds` type positions drop `globs` (type-only; the parameter widens). - -D3 semantic entry `plugin-manifest-kind-globs-retired`; no D2 conversion (a -manifest is not a stack collection member — no seam would ever run it). diff --git a/.changeset/mapping-lookup-params-removed.md b/.changeset/mapping-lookup-params-removed.md deleted file mode 100644 index 678ef80708..0000000000 --- a/.changeset/mapping-lookup-params-removed.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the import mapping `lookup` transform's steering params (#10329, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`ImportFieldMappingSchema.params` declared four keys whose only stated purpose -was to steer the `lookup` transform — `object` ("Lookup Object"), `fromField` -("Match on"), `toField` ("Value to take"), `autoCreate` ("Create if missing") — -and the import path read none of them: `applyMappingToRows` handles `lookup` in -the same branch as `none` (the cell is copied through unchanged), and reference -resolution runs afterwards in `import-coerce.ts`, driven by the target field's -own metadata. Implementing them would be a second reference-resolution dialect -on the import path, which the code declines to build and the #10329 triage -ruling declined again. `autoCreate` was the one with teeth: it read as -"create the referenced record when nothing matches", and nothing was ever -created — with or without the key, an unresolved cell fails its row with -`import_reference_not_found`. - -FROM → TO, per key (all four are pure deletions — none ever had an effect to -lose, so removing them changes no import behaviour): - -- `fieldMapping[].params.object` → *(removed)*. The referenced object is the - target field's own declared `reference`. -- `fieldMapping[].params.fromField` → *(removed)*. The pipeline matches the - cell's display value against the referenced object itself. -- `fieldMapping[].params.toField` → *(removed)*. Resolution always writes the - referenced record's id. -- `fieldMapping[].params.autoCreate` → *(removed)*. Create or import the - referenced records first, then import the rows that point at them. - -One-line fix: delete the four keys (and any of their old alias spellings) from -`fieldMapping[].params`; `os migrate meta --from 17` lists the mechanical edits -for existing sources. - -The retirement kit: - -- `params` is a `strictObject`, so the keys are strict-deleted with a - `guidance` prescription per spelling — the four canonical keys and the - eleven ex-alias spellings (`lookupObject`/`targetObject`, - `match`/`matchOn`/`matchField`/`keyField`, `returnField`/`valueField`, - `create`/`createIfMissing`/`upsert`) all land on the full prescription - rather than a "did you mean" pointing at a key that is also gone (the - 17.0.0 #4509 treatment, one level down) -- ADR-0087 registration: the D2 conversion `mapping-lookup-params-removed` - (protocol 18), wired into the step-18 chain — `os migrate meta --from 17` - strips the four keys from stored `mappings[].fieldMapping[].params`. No - retired-key table entries: these keys sit one sub-walk level below the - authorable-surface drill (`data/ImportFieldMapping:params` is the walked - row, and it stays live), so there is no `defKey:name` row to register or - age out -- pin tests (`mapping.test.ts`): refusal pins per key asserting the - prescription (the `autoCreate` pin asserts the row-fails truth), alias - routing pins, and a surviving-surface pin (`value`/`valueMap`/`separator` - untouched) -- liveness ledger: `liveness/mapping.json`'s `fieldMapping` sub-walk boundary - note now records the retirement instead of parking the finding -- docs: the `import-mappings.mdx` warning about the inert params is deleted - along with the keys; the generated mapping reference no longer lists them diff --git a/.changeset/masterdetail-formtype-vocabulary.md b/.changeset/masterdetail-formtype-vocabulary.md deleted file mode 100644 index 632bbf5e57..0000000000 --- a/.changeset/masterdetail-formtype-vocabulary.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `ObjectMasterDetailFormPropsSchema.formType` narrows from bare `string` to the measured `simple | tabbed` (#11873 — the spec half of objectui#5939). - -**Newly rejected:** `wizard`, `split`, `drawer` and `modal` — each names an `object-form` renderer branch that breaks `object-master-detail-form`'s atomic parent+details contract (wizard mounts only the current step and turns the Save bar into Next; split persists via `dataSource.create` around the batch; drawer/modal move the parent half into a portal dialog the Save bar cannot submit). Each refuses with a per-value prescription; any other string (e.g. `wizzard`) now gets the plain enum refusal instead of parsing clean and rendering a silently sectionless parent form. - -**Write instead:** `simple` or `tabbed` — the two variants the renderer honours end-to-end for the parent half. For a wizard/split/drawer/modal presentation without inline details, author an `object-form`, whose `formType` keeps all six values. - -Breaking ships as minor per the launch-window convention (`scripts/check-changeset-no-major.mjs`). - - - diff --git a/.changeset/maxlength-adr0087-ledger-entry.md b/.changeset/maxlength-adr0087-ledger-entry.md deleted file mode 100644 index 22479142fd..0000000000 --- a/.changeset/maxlength-adr0087-ledger-entry.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -The #11566 `maxLength` narrowing (shipped in 17.x: `z.number().int().min(1)`, refused outside `BOUNDED_STRING_FIELD_TYPES`) is now registered in the ADR-0087 migration ledger (#11950) — the enforcement PR deliberately deferred the entry because the registry file was serialized behind an in-flight change. Following the #8321 `scale`/`precision` template, the major-18 semantic entry carries both halves: the mechanical one (delete the key where it was misplaced — inert by construction outside the write-time validator's bounded-string branch) and the judgment one (a malformed value on a bounded-string type WAS consumed by the validator's raw comparison — `maxLength: 0` accepted only empty strings, a negative value refused every write — so only the author knows the bound they meant; the entry tells them to re-declare it). `objectstack migrate meta`, `spec-changes.json` and the upgrade guide surface the entry at the major boundary; no accept/reject behaviour changes in this release. - - diff --git a/.changeset/maxlength-bounded-string-only.md b/.changeset/maxlength-bounded-string-only.md deleted file mode 100644 index 37a28a18c8..0000000000 --- a/.changeset/maxlength-bounded-string-only.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`FieldSchema.maxLength` tightens on both axes (#11566, maintainer ruling 2026-08-24). Shape: the key is now `z.number().int().min(1)`, so `maxLength: 0`, negative and non-integer declarations are refused at parse — none of them is a character length, and `maxLength: 0` measurably sent the SQL schema-drift planner asking for `varchar(0)` DDL at severity error/destructive before #11431 taught that consumer to defend itself. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the write-time validator's ten — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code` — the only one of the three previously-disagreeing lists with a measured reader, exported as `BOUNDED_STRING_FIELD_TYPES`. - -What newly gets rejected: `maxLength: 0` / negative / non-integer on any type, and `maxLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms (`field.form.ts`, previously three types; `object.form.ts`, previously nine) converge on the same ten. Already-legal declarations (a positive-integer `maxLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. - -The ADR-0087 ledger entry for this narrowing is deferred to #11950 (the migration registry file was serialized behind an in-flight change when this landed), following the #8321 `scale`/`precision` template: a mechanical delete of malformed or misplaced values plus a semantic re-declare prescription at the next major boundary. diff --git a/.changeset/mcp-stdio-localization-after-settings-bind.md b/.changeset/mcp-stdio-localization-after-settings-bind.md deleted file mode 100644 index 4a67551268..0000000000 --- a/.changeset/mcp-stdio-localization-after-settings-bind.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -MCP stdio transport now serves the workspace's CONFIGURED timezone/locale -instead of the manifest defaults - -The stdio transport resolved its localization inside `MCPServerPlugin.start()`. -`SettingsServicePlugin` registers its service in `init()` but binds its data -engine from a `kernel:ready` hook registered in its own `start()`, and every -plugin's `start()` body runs strictly before the first `kernel:ready` handler — -so that read was inside the settings bind window under **every** composition -order. Being ordered after the settings plugin did not help, and the -`optionalDependencies` edge that repairs the neighbouring ordering defects would -not have moved it either. - -In that window the read does not fail: the empty in-memory fallback plus the -manifest defaults answer with `source: 'default'`, so `resolveLocalizationContext` -returned `UTC` / `en-US` and reported success, never reaching its direct -`sys_setting` fallback. The value is then held for the life of the transport by -design, so a long-lived stdio MCP server served every call with `UTC` / `en-US` -on a workspace whose persisted `localization` settings said otherwise, and never -self-corrected. - -The resolution now happens from a `kernel:bootstrapped` hook — the earliest phase -strictly after the bind, and the one `SettingsService.reportPreBindRead` names as -the remedy — memoized so it stays one resolution for the life of the transport -rather than a per-call settings read. A host that never fires the boot hooks -resolves it lazily at first use instead, so nothing can deadlock on a hook that -never arrives. - -**Behaviour change on a declared setting**: a deployment that has configured -`localization.timezone` / `localization.locale` / `localization.currency` will -see those values take effect on the stdio MCP surface, where it previously -always received the platform defaults. Formula evaluation (`ctx.timezone`) and -message localization on that surface change accordingly. diff --git a/.changeset/mcp-stdio-prebind-memoization-window.md b/.changeset/mcp-stdio-prebind-memoization-window.md deleted file mode 100644 index c16c9d4dd4..0000000000 --- a/.changeset/mcp-stdio-prebind-memoization-window.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/mcp": patch ---- - -The stdio MCP transport no longer freezes a pre-bind localization when a data call races the boot. #11580 moved the localization read onto a `kernel:bootstrapped` hook, but its lazy entry point memoized whatever the FIRST read produced — and the transport goes live inside `MCPServerPlugin.start()`, before the remaining plugins' `start()` bodies and before every `kernel:ready` handler, which is where `SettingsServicePlugin` binds its data engine. A client fast enough to send a data call in that stretch resolved localization pre-bind, received `UTC` / `en-US` from the manifest defaults, and kept them for the life of the process — the narrow race turning into the permanent wrong value #11580 was about. - -The memo is now armed by the bind rather than by the first read: a resolution taken while the settings bind window is still open is answered but not kept, so a call arriving after the bind (even before `kernel:bootstrapped`) already sees the configured value, and the first resolution taken once the window has closed is the one that lives for the life of the transport. #7279's steady state is unchanged — one resolution, never a per-call settings read — because the `kernel:bootstrapped` hook takes that resolution the moment the window closes. A host that never fires the boot hooks (a bare kernel, a test harness) still answers rather than deadlocking, which is why the read is not made to wait for the bind. diff --git a/.changeset/messaging-inbox-authenticated-caller-scope.md b/.changeset/messaging-inbox-authenticated-caller-scope.md deleted file mode 100644 index 35f40ca4f3..0000000000 --- a/.changeset/messaging-inbox-authenticated-caller-scope.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/service-messaging": minor ---- - -**Feature:** `MessagingService` gains a plugin-facing inbox write door scoped to the **authenticated caller** — `markReadAsCaller(caller, ids)` and `markAllReadAsCaller(caller)` (#10753). - -A plugin that pushes an "…awaiting your approval" message through `emit()` had no legitimate way to close it out again once the work was done, so the Console bell's unread badge stayed lit through a full page reload until the user hit "mark all read". The reporting project carries 30+ business hooks in that shape. - -What it was reaching for instead is the shape this closes. `markRead(userId, ids)` is the REST door's contract method (`INotificationService.markRead?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin ONE shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string. **Any plugin could mark any user's inbox messages read**, and the receipt lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check saw it either. This release is therefore both an API widening and the first tightening of in-process power on that path. - -The new pair takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId`, so "mark someone else's inbox read" has no spelling on this surface — it is unrepresentable rather than discouraged. That fits the case it was asked for exactly: the approver who clears a request *is* the recipient whose badge is stuck. - -`userId` is read, and nothing that merely resembles one: - -- `attributedUserId` is **attribution only** — its own contract states that nothing in the authorization path reads it, and a context carrying only it authorizes as anonymous (ADR-0118 D2). A `userId ?? attributedUserId` fallback would read as working and clear the wrong person's badge. -- `actor` is a service-principal label (`svc:`), not a `sys_user` id. -- `isSystem: true` with no user is refused rather than elevated: the system has no inbox to be the recipient of. - -Each refusal throws `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401` and the registered `code: 'UNAUTHENTICATED'` — and the refusal is evaluated **before** the empty-`ids` and no-data-engine short-circuits, which return `{ success: true, readCount: 0 }`. Reaching one of those with no authenticated caller would report success for a write that was never authorized, which is the silent-success shape this door exists to replace. - -Honest about what it is: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can write `sys_notification_receipt` directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. - -Nothing existing changes behaviour: `markRead` / `markAllRead` / `listInbox` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. diff --git a/.changeset/messaging-inbox-read-authenticated-caller.md b/.changeset/messaging-inbox-read-authenticated-caller.md deleted file mode 100644 index 752bec6948..0000000000 --- a/.changeset/messaging-inbox-read-authenticated-caller.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-messaging": minor ---- - -**Feature:** `MessagingService` gains the plugin-facing inbox **read** door scoped to the authenticated caller — `listInboxAsCaller(caller, opts)` (#11452), completing the axis the write door (`markReadAsCaller` / `markAllReadAsCaller`, #10753) established. - -`listInbox(userId, opts)` is the REST door's contract method (`INotificationService.listInbox?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin one shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string, and the read lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check sees it either. Any plugin could read any user's inbox titles, bodies and read-state — the exact shape the write door closed, on the arguably more sensitive half: inbox bodies carry rendered business content. - -`listInboxAsCaller` takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId` through the same `resolveInboxRecipient` the write door uses — one refusal vocabulary, not two. `attributedUserId` (attribution only, ADR-0118 D2), `actor` (a service-principal label) and `isSystem` are refused rather than promoted, with `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401`, registered `code: 'UNAUTHENTICATED'`. The refusal is evaluated **before** `listInbox`'s no-data-engine / no-user short-circuit, which answers a well-formed empty `{ notifications: [], unreadCount: 0 }` inbox — the read-side analog of the silent success the write door replaced. The options window (`read` / `type` / `limit`) is forwarded unchanged. - -Honest about what it is, same as the write door: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can read `sys_inbox_message` rows directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. - -Nothing existing changes behaviour: `listInbox` / `markRead` / `markAllRead` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. `resolveInboxRecipient` gains an optional third parameter naming the target-user door its refusal prescribes; it defaults to the write door's existing text, so existing call sites keep their refusal bytes unchanged. diff --git a/.changeset/metadata-item-name-grammar-enforced.md b/.changeset/metadata-item-name-grammar-enforced.md deleted file mode 100644 index c9dd52ccb4..0000000000 --- a/.changeset/metadata-item-name-grammar-enforced.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor ---- - -feat(spec,metadata-protocol): declare the metadata item-name grammar and refuse it loudly at the publish door (#12194, #12176 stage 1) - -**BREAKING** accept-set narrowing at the metadata write door, shipped as -`minor` under the repo's launch-window convention for breaking changes. - -Metadata item names — the `name` half of the `type`/`name` pair that keys -`sys_metadata` and the `/api/v1/meta` URL space — were entirely unconstrained: -the empty string, `//`, `'Views/All Leads'` and slash-compound spellings -(`views/all_leads`) were all accepted and stored, and a slash in the name -bypassed the unrecognised-metadata-type refusal entirely (`type=fieldz -name='a/b'` was accepted and stored while `type=fieldz name='a'` was 400). -Maintainer ruling 2026-08-25 (#12176): item names must not contain `/`. - -The grammar is now **declared in spec** (`MetadataItemNameSchema` / -`METADATA_ITEM_NAME_PATTERN`, `@objectstack/spec/shared`): lowercase -snake_case segments, optionally dot-qualified — the family -`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` — sourced from the existing -`ViewItemNameSchema` dotted declaration (same segment source, one grammar; the -view-item identity keeps requiring its dot). And it is **enforced at the -publish door** (`saveMetaItem` and `publishMetaItem` in -`@objectstack/metadata-protocol`): an off-grammar name is refused -`400 INVALID_REQUEST` with the grammar and the dotted prescription in the -message, and nothing is persisted. The slash bypass of -`refuseUnmintableMetaType` closes as a consequence. - -**What an author writes instead.** A flat snake_case name (`crm_lead`) and a -dotted qualified name (`crm_lead.pipeline`) both work exactly as before. A -name that spelled a sub-resource with a slash (`views/all_leads`) is -re-authored with a dot qualifier (`crm_lead.pipeline` — the qualified identity -whose prefix recovers the owner) or flattened with an underscore -(`views_all_leads`); containment is expressed by structure, never by a -separator inside the identity string. A translation item conventionally named -after its locale is named in snake_case (`zh_cn`) with the BCP-47 spelling in -its required `locale` field (`"zh-CN"`), which has been the item's real -identity key all along. - -Reads and `deleteMetaItem` deliberately stay open, so any pre-grammar residue -row remains listable and clearable. The in-repo stored corpus was measured at -**zero** slash-bearing item names (#12176 census, re-asserted at land time); -out-of-repo stored slash rows, if any exist, are reported by their deployment's -migrate run rather than rewritten silently. - - diff --git a/.changeset/metadata-protocol-specifier-pin-11350.md b/.changeset/metadata-protocol-specifier-pin-11350.md deleted file mode 100644 index d37a609494..0000000000 --- a/.changeset/metadata-protocol-specifier-pin-11350.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -Pin the declaration emitter's module specifier for `FormFieldInput` to `@objectstack/spec/ui` (#11350). When #11350 made the three ui/automation input types nameable from `@objectstack/spec`'s root entry, tsc's declaration emitter for this package switched its synthesized reference for `FormFieldInput` from the `/ui` slice to the root entry — both portable, but the root specifier pulls spec's entire root module graph into every downstream TypeScript program that reads this package's declarations (measured: +190k types, +805k instantiations, roughly +560MB on one real program). A local type-only import binding keeps the emitted reference on the narrow `/ui` entry. Type-only and erased at runtime: every emitted JS file is byte-identical; the package's public export surface is unchanged. diff --git a/.changeset/migrate-multi-value-columns-command.md b/.changeset/migrate-multi-value-columns-command.md deleted file mode 100644 index 27abd9d454..0000000000 --- a/.changeset/migrate-multi-value-columns-command.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/driver-sql": minor ---- - -New operator-run command `os migrate multi-value-columns`: migrates a stale `varchar`/`text` column to `json` where the field declares `multiple: true` — the `manual_column_type_change` drift `os migrate apply` reports and deliberately never reconciles for you (#11535, ruled C on #11700). Flags: `--apply` (default off), `--yes`/`-y`, `--force`, `--table ` (repeatable), `--database-url`, `--json`. **Dry-run contract: without `--apply` the command executes nothing at all** — it prints the exact statements and the database they would run against, opens no seam and issues no probe, and a run is verified to have left the column type and every row unchanged. `--apply` runs `@objectstack/driver-sql`'s own `manualJsonConversionSql` — newly re-exported from that package's index for this consumer, its only other change — i.e. the statement the drift finding itself prints (Postgres: one `ALTER … USING (CASE …)` with `json_build_array`; MySQL: the two row-shaping `UPDATE`s then `ALTER … MODIFY … json`), refuses to execute anything the finding does not contain verbatim, re-runs detection afterwards and exits non-zero if the finding has not cleared. SQLite is excluded — the stale column round-trips a real array there, so the finding is never raised. Rows corrupted before the column is migrated are out of scope, and the command is never invoked automatically: nothing on the boot path reaches it. diff --git a/.changeset/minlength-bounded-string-only.md b/.changeset/minlength-bounded-string-only.md deleted file mode 100644 index 6856591ead..0000000000 --- a/.changeset/minlength-bounded-string-only.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`FieldSchema.minLength` tightens on both axes (#11949, maintainer ruling 2026-08-25) — `maxLength`'s twin defect pair (#11566), closed with the same template. Shape: the key is now `z.number().int().min(1)`, so `minLength: 0`, negative and non-integer declarations are refused at parse. The lower bound is 1 by ruling: "no minimum" is expressed by omitting the key, not by declaring a vacuous truth — `minLength: 0` can never fail, and a permanently-true declaration is exactly the noise an AI metadata author mass-produces, so it is refused loudly at authoring time. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the `BOUNDED_STRING_FIELD_TYPES` set — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (twelve members since #11875) — the same set `maxLength` converged on. - -What newly gets rejected: `minLength: 0` / negative / non-integer on any type, and `minLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms converge on the same set (`field.form.ts` previously showed the key for three types; `object.form.ts` for nine). Already-legal declarations (a positive-integer `minLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. - - diff --git a/.changeset/mysql-boolean-row-read-presentation.md b/.changeset/mysql-boolean-row-read-presentation.md deleted file mode 100644 index e41af7172c..0000000000 --- a/.changeset/mysql-boolean-row-read-presentation.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): a declared `Field.boolean` answers JSON booleans on MySQL's row-read doors (#11782) - -`formatOutput`'s boolean read coercion — and its per-column mirror -`readPresentationKind`, which `distinct()` and the aggregate group-key / -`min`/`max` tracking consume — was gated `isSqlite`-only. On MySQL the storage -is `tinyint(1)` and mysql2 hands back a JS number, so a declared boolean -answered `1`/`0` through `find()`, `distinct()` and aggregate group keys while -SQLite and Postgres answered `true`/`false` — and, after #11635 presented -aggregate `min`/`max` on every dialect, `max(flag) === true` and -`row.flag === 1` disagreed on the same column over the same MySQL connection. - -Measured on live MySQL 8.0.46 before the fix: `find().flag` → `1` (`typeof -number`), `distinct('flag')` → `[0, 1]`, aggregate group keys → `1`/`0`. The -boolean presentation now runs on the two dialects whose stored boolean is a -number (SQLite `INTEGER` 0/1, MySQL `tinyint(1)`); Postgres stores a real -`boolean` node-pg already parses, so it deliberately stays outside the gate and -its answers are byte-identical. A `NULL` boolean stays `null` on every door -(absence is not `false`), and declared `number`/`string` columns are untouched. diff --git a/.changeset/mysql-row-byte-budget-diagnostic.md b/.changeset/mysql-row-byte-budget-diagnostic.md deleted file mode 100644 index a50bf82ebb..0000000000 --- a/.changeset/mysql-row-byte-budget-diagnostic.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -MySQL's row-size refusal now names the declarations that caused it (#11565). MySQL charges every bounded column's DECLARED byte width against a per-row budget, independently of the per-column `varchar` ceiling — measured on 8.0.46 through this driver, 15 fields at `maxLength: 1024` create and 16 are refused — and its own error names no column and no declaration, about a table its author described entirely in metadata. Schema sync now translates `ER_TOO_BIG_ROWSIZE` at both the `CREATE TABLE` and `ALTER TABLE ADD COLUMN` sites into the same failure re-worded: every varchar column the object produces, widest first, with its emitted width and its byte cost at the schema's real bytes-per-character (read from the server, not assumed), plus the fields that reach the budget while declaring nothing — `lookup`, `user`, `auto_number` and the option types all take `varchar(255)`. InnoDB's separate per-page limit answers with the same code and is reported with the number the server quoted rather than 65535. Deliberately a translator and not a pre-flight: it speaks only after the server has refused, so it cannot refuse an object MySQL would have accepted. Nothing is refused that was accepted before, and no other dialect is touched. diff --git a/.changeset/nine-parents-keep-details.md b/.changeset/nine-parents-keep-details.md deleted file mode 100644 index ea4eaadb69..0000000000 --- a/.changeset/nine-parents-keep-details.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor ---- - -`FieldSchema` now rejects an authored `deleteBehavior: 'set_null'` on a `master_detail` field at parse time (#9689). The engine has always resolved every value except `restrict` on that type to `cascade`, so the declaration asked for the child rows to be kept and got them deleted — silently, at the moment the parent went away. The rejection names the outcome and both legal re-declarations (`restrict` refuses the parent delete while children exist — no data loss; `cascade`, or omitting the key, accepts the cascade deliberately; a `lookup` is the type to use when children must survive the parent). - -Mechanism (the #7918 Option A shape, plus the 2026-08-24 idempotent-materialization ruling): the property-level `.default('set_null')` moved off `deleteBehavior` into a post-check `.overwrite()`, so the schema can tell an authored `set_null` from a defaulted one — and the `.overwrite()` never materializes a default the schema itself would refuse as authored. A bare `master_detail` now parses to output that OMITS `deleteBehavior` (previously the baked `set_null` was indistinguishable from an authored one by design, so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse — every app build with a bare `master_detail` failed). Built app artifacts stop carrying a value the schema itself refuses; the engine treats absent exactly as it treated the baked value (both cascade — measured, behavior unchanged). Every other field type keeps byte-identical output — non-reference types still carry the default at its shape position, and `set_null` on `lookup` stays legal. The inferred `Field` output type now declares `deleteBehavior` as optional (the same accepted cost as the currency `precision` relocation); at runtime a parsed field carries it on every type except `master_detail`, where absence is the honest spelling. - -There is deliberately no automatic conversion (`field-master-detail-set-null-refused` in the migration registry): only the author knows whether they meant `restrict` (keep-my-children, as a refusal) or `cascade`. Stored rows carrying the refused combination keep loading and serving — registry validation is a diagnostic, not a gate — and are refused on their next authoring-path save. - -`@objectstack/objectql`: the engine behavior is unchanged (an authored `set_null` on `master_detail` still cascades — the #9625 pin holds), but the coercion site now logs loudly (`error`, falling back to `warn`) when the combination reaches it via a raw registration or a pre-tightening stored row — the two populations parse-time rejection cannot catch. diff --git a/.changeset/non-unique-keyed-text-remainders.md b/.changeset/non-unique-keyed-text-remainders.md deleted file mode 100644 index fefc02e2fd..0000000000 --- a/.changeset/non-unique-keyed-text-remainders.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -Make the last two non-unique keyed text indexes expressible on MySQL — remove one, -narrow one - -`driver-sql` emits a keyed text-family column as `varchar(maxLength)` only when the -declared bound is one MySQL can key (768 characters on utf8mb4, the 3072-byte key-part -ceiling); otherwise the column stays `TEXT`, MySQL refuses it as an index key -(`ER_BLOB_KEY_WITHOUT_LENGTH`), and the object's whole `syncSchema` fails — it lands -registered with its declared index absent. #11374 declared sourced bounds for thirteen -such columns and #11627 carried the over-long UNIQUE ones on a SHA-256 hash-shadow -column, taking live MySQL 8.0.46 from 12/44 → 8/44 → 2/44 failing objects. - -The two that remained are **non-unique**, and a hash shadow structurally cannot serve -them: a UNIQUE constraint is an equality-only predicate that survives hashing exactly, -but a non-unique index exists for an access path, and an index over a digest -accelerates no `WHERE col = ?` the planner can reach without rewriting the read side. -They are ruled separately (maintainer, 2026-08-25) because they are different problems: - -- **`sys_verification.value` — the declared index is removed.** The column is - genuinely unboundable (better-auth's oauth-provider writes OIDC authorization-code - payloads there as a JSON blob), and the index was measured dead: better-auth 1.7.1 - keys every verification lookup on `identifier`, `id` or `expiresAt` - (`internal-adapter.mjs`), upstream declares the field unindexed and unbounded, and no - in-repo query filters `sys_verification` by `value`. An index that silently does not - exist on one dialect is the worst of both worlds; removing it makes the metadata match - reality. -- **`sys_oauth_client_resource.resource_id` — the declared bound narrows 1024 → 768.** - This one is a live access path (the FK side of `sys_oauth_resource.identifier`, read - as a predicate by upstream's client-registration collision path), so it keeps its - index and becomes keyable instead. 768 is the widest utf8mb4 value a MySQL key part - holds, and the smallest narrowing that works. - -This is an enforcement change on published objects — hence the minor grade. On MySQL and -SQL Server a `resource_id` longer than 768 characters is now refused rather than stored, -and on PostgreSQL and SQLite the `sys_verification` `[value]` index is dropped on the -next schema sync (on MySQL it never existed). Neither narrows what the producing -contract can emit: the value is an RFC 8707 resource-indicator URI, and upstream -better-auth 1.7.1 stores that same identifier as `varchar(255)` on MySQL -(`get-migration.mjs`) and this referring column as `varchar(36)`, so a resource whose -identifier exceeded 768 characters could never have been registered upstream at all. - -The pin that enumerated the package for unbounded keyed text columns now also rejects a -non-unique index over any text column MySQL cannot key, so a third member of the class -fails at test time rather than on a live server. Its `UNBOUNDABLE` allowlist — which -existed to excuse `sys_verification.value` — is empty as a result, and a synthetic -control keeps the excusing branch exercised rather than letting it rot. diff --git a/.changeset/nosql-index-unique-scope-docs.md b/.changeset/nosql-index-unique-scope-docs.md deleted file mode 100644 index f19fcdcb70..0000000000 --- a/.changeset/nosql-index-unique-scope-docs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Document why `NoSQLIndexSchema.unique` stays a bare boolean instead of the ADR-0120 unique-scope vocabulary carried by `FieldSchema.unique` and `IndexSchema.unique`: the schema is a raw NoSQL driver-configuration descriptor below the tenancy seam — nothing materializes indexes from it, and the one NoSQL driver that creates indexes (driver-mongodb) consumes the object-level `indexes[]` surface (which already carries the vocabulary) and is explicitly single-tenant (#3724) — so a scope word here would be declarable-but-inert vocabulary (ADR-0078). The `describe()` and docblock now state the deliberate omission and the condition under which `UniqueScopeSchema` should be adopted, so the asymmetry with the other two `unique` surfaces is not mistaken for drift (#11215). diff --git a/.changeset/notify-node-template-locale-is-not-per-recipient.md b/.changeset/notify-node-template-locale-is-not-per-recipient.md deleted file mode 100644 index fbc4435b45..0000000000 --- a/.changeset/notify-node-template-locale-is-not-per-recipient.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/service-automation': patch -'@objectstack/service-messaging': patch ---- - -The `notify` node's Studio form and the messaging registration log now state the locale the delivery path actually resolves — one per notification, not one per recipient - -`NotifyConfigSchema` was corrected in `packages/spec` to say that the `template` -path resolves `(name, locale)` with **one** locale for the whole notification. -The same retired promise survived outside the spec file, in the places an app -author is most likely to read it: - -- `service-automation/src/builtin/notify-node.ts` — the `template` field's - `configSchema` description, i.e. the text rendered in the **Studio form** the - author fills in. It said the row is "resolved by (name, recipient locale) at - delivery time and rendered per recipient". -- `content/docs/automation/email-templates.mdx` — the only site that stated the - conclusion outright rather than merely licensing it: "so one node mails each - person in their own language". -- `service-messaging/src/messaging-service-plugin.ts` — the channel-registration - log line, which advertised "resolve sys_email_template per recipient locale". -- Two internal comments in `notify-node.ts` and one in its test, describing the - payload the outbox snapshots as carrying a per-recipient-locale resolution. - -None of that is what the delivery path does. `payload.locale` is interpolated -**once, before fan-out**, so it is a single value for the whole notification, and -its fallback is the deployment default (`II18nService.getDefaultLocale()`). The -platform has no per-user locale to read — `sys_user` carries no locale column, -and request-scoped locale does not exist at async delivery time — so recipients -whose personal languages differ all receive the same template row. A per-user -locale is deferred until measured pull (maintainer ruling, 2026-08-13) and layers -in as an override at that same seam when it lands; the corrected wording dates -the deferral so it reads as a decision with provenance rather than an oversight. - -The gap was worth correcting because the wording licensed exactly one action — -convert `notify` nodes on the belief that non-English recipients get non-English -mail — and that action is a **net regression**: `TEMPLATE_*` failures classify -`permanent` and dead-letter, and the inbox channel starts requiring an email -service with `renderTemplate()` where inline text needed none. - -Text only: no schema accepts or refuses anything it did not before, no delivery -behaviour moves, and no wire value changes. A new pin in `notify-node.test.ts` -asserts the form description names `payload.locale` and the deployment default -and refuses a bare "recipient locale", so a later edit cannot quietly restore the -promise. diff --git a/.changeset/notify-template-locale-is-deployment-default.md b/.changeset/notify-template-locale-is-deployment-default.md deleted file mode 100644 index 8b04d7a0c3..0000000000 --- a/.changeset/notify-template-locale-is-deployment-default.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale - -The `notify` node's localizable path (`template` → a `sys_email_template` bundle) -was documented in `packages/spec/src/automation/io-node-config.zod.ts` as -resolving `(name, recipient locale)` **per recipient** at delivery time, and the -`template` `.describe()` added that it "renders subject/body per recipient". -Read plainly — and it is the text a consuming app's author reads — that says the -recipient's own language selects the template row. - -It does not, and deliberately does not. The delivery path -(`service-messaging/src/email-channel.ts`) has said so honestly at its own -`getDefaultTemplateLocale` all along: the platform has no per-user locale -(`sys_user` carries no locale column), and request-scoped locale -(`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async -delivery time, so "recipient locale" resolves to the **deployment default**, -`II18nService.getDefaultLocale()` — the same ruled source the auth emails use. -The one lever is `payload.locale`, and that is interpolated **once, before -fan-out**, so it is a single value for the whole notification at all three -`channel.send` call sites (`fanOut`, the outbox single-delivery path, and -`processDigestGroup`). - -The gap mattered because the wording licensed exactly one conclusion — "convert -the nodes and non-English users get non-English notifications" — which is false, -and acting on it is a **net regression**: `TEMPLATE_*` failures classify -`permanent` and dead-letter, and the inbox channel starts requiring an email -service with `renderTemplate()` where inline text needed none. So the drift was -not a cosmetic imprecision; it was an instruction to make a change that loses -deliveries. - -Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side — -a per-user locale is deferred until measured pull — so the prose is the side that -moves. All five "recipient locale" sites in the file now name the resolved value: -the schema doc block, the `template` field's JSDoc and `.describe()`, and both -`superRefine` refusal messages. Each says the locale is `payload.locale` if the -producer set one, else the deployment default, and that it is **one value per -notification, not one per recipient**, with the 2026-08-13 deferral dated in -place so the limitation reads as a decision with provenance rather than a -permanent property of the design — a per-user locale layers in as an override at -that same seam when it lands. - -Text only. No schema accepts or refuses anything it did not before, no delivery -behaviour moves, and no wire value changes — `packages/spec` publishes -`src/**/*.zod.ts` and the generated reference page, so the corrected wording -ships to consumers reading either. The pins in -`io-node-config.test.ts` that asserted the old `/recipient locale/` string now -assert the qualification itself, and refuse a bare "recipient locale", so a -future edit cannot quietly restore the promise. diff --git a/.changeset/object-editmode-declared.md b/.changeset/object-editmode-declared.md deleted file mode 100644 index 2ea81b81ac..0000000000 --- a/.changeset/object-editmode-declared.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `editMode?: 'modal' | 'page'` on the object document (#11408) - -Accept-set **widening** — no existing document changes meaning and nothing is -removed. Maintainer ruling 2026-08-24 (declare, the #10144 declare-or-rule-out -family): objectui's shipped runtime reads `objectDef.editMode` (record-edit -routing: modal form vs a dedicated `/record/:id/edit` route) and its CHANGELOG -announces the key to authors, while the spec's strict parse rejected it with -`unrecognized_keys` — so an author following objectui's documentation was -refused by every spec-validating path and the key only worked through data -sources that skip validation. - -The object document now declares it beside the other display hints -(`nameField`, `highlightFields`, `stageField`): an optional cross-renderer -edit-interaction intent — `'modal'` opens the edit form as a dialog over the -current view, `'page'` navigates to a dedicated full-page edit route, absent -lets the renderer pick its own default (objectui defaults to modal). Values -outside the enum are rejected as a located value error at `editMode`. - -Consumer-side follow-up (not in this change): objectui retires its -`ObjectSchemaClientExtensions.editMode` client-extension member and lets the -spec derivation carry the key — its pinned rejection tests flip by design. -That retirement is **release-gated** on the `@objectstack/spec` release -containing this change (per the recorded ruling), not merely on this merge. diff --git a/.changeset/object-grid-data-view-data-converged.md b/.changeset/object-grid-data-view-data-converged.md deleted file mode 100644 index 694454e796..0000000000 --- a/.changeset/object-grid-data-view-data-converged.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `ComponentPropsMap['object-grid'].data` converges onto `ViewDataSchema` (#12039, objectui#6207 Option A) - -**BREAKING** accept-set change on one props-map entry, shipped as `minor` under -the repo's launch-window convention for breaking changes. - -Two spec authorities disagreed on the KIND of `object-grid`'s `data`: -`ViewDataSchema` — the authority objectui#5090 ruled the registry declaration -against, and what `ObjectGridSchema.data` resolves to — is an object -discriminated on `provider` (`object` / `api` / `value` / `schema`), while -`ComponentPropsMap['object-grid'].data` said `z.array(z.unknown())`. Measured -on `@objectstack/spec@17.2.0`: `{ provider: 'value', items: [] }` — the -pinned-legal form — was refused by the props-map entry while the bare array -parsed. Maintainer ruling (2026-08-25, objectui#6207, Option A): the props-map -entry converges onto `ViewDataSchema`; the bare-array form is the deprecated -`staticData` shortcut the objectui#4648 carve-out already refuses to publish. - -Migration — FROM → TO, one wrapping object: - -```ts -// before (refused now) -data: [{ id: 1, title: 'Inline row' }] -// after -data: { provider: 'value', items: [{ id: 1, title: 'Inline row' }] } -``` - -The ruled migration check ran with the change: the sweep of generated -artifacts, templates and first-party corpora (examples/, skills/, -create-objectstack, spec fixtures) found zero bare-array `data` authors, so no -rewrite ships. `staticData` (the legacy bare-array shortcut the renderer still -reads) keeps its shape but is not the prescription. - -`ComponentPropsMap['element:number'].filter` (the sibling key of #12039 / -objectui#6206) is NOT changed here: the ruling's binding measurement-first -precondition measured the pinned adapter/runtime refusing the raw -`ViewFilterRule[]` form on the element's primary (analytics) read path, which -forks that key back to triage. objectui#6206 remains open. - - diff --git a/.changeset/object-grid-default-sort-retired.md b/.changeset/object-grid-default-sort-retired.md deleted file mode 100644 index 42a3c93619..0000000000 --- a/.changeset/object-grid-default-sort-retired.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `object-grid`'s legacy `defaultSort` fallback (#11805, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`, per the maintainer's #11805 -ruling — 「不需要major」; the migration prescription is registered under -protocol major 18, where `os migrate meta` users will look). - -`ObjectGridPropsSchema.defaultSort` was the legacy second spelling of `sort`: -a single `{ field, order }` pair the renderer read only when `sort` was absent -— measured at the `.objectui-sha` pin (`190fbd01d`), -`plugin-grid/src/ObjectGrid.tsx:1244-1246` (the `$orderby` fetch fallback) and -`:2847`, where the header-arrow path wraps it `[schema.defaultSort]`, the -exact array shape `sort` carries. One intent, two spellings; objectui's mirror -schema is parity-test-only and parses nothing at runtime, so only this -strictObject can refuse the legacy spelling (objectui#5861 retires the -renderer's reads as the consumer half, on its own schedule). - -FROM → TO: - -- `defaultSort: { field, order }` (no `sort` beside it) → - `sort: [{ field, order }]` — the same pair, wrapped in the array shape every - read path honours. -- `defaultSort` beside an authored `sort` → *(removed)*. The renderer's own - precedence made the fallback unread there, so the deletion is lossless. - -One-line fix: rename the key to `sort` and wrap the value in an array; -`os migrate meta --from 17` lists the mechanical edits for existing sources. - -The retirement kit: - -- `retiredKey()` tombstone in `ObjectGridPropsSchema` — authoring the key is a - tsc error (`never`) and a parse error carrying the wrap-and-rename - prescription (the surface baseline line carries `[RETIRED]`) -- ADR-0087 registration: `ui/ObjectGridProps:defaultSort` in - `RETIRED_KEYS_BY_MAJOR[18]`, and the D2 conversion - `object-grid-default-sort-removed` (protocol 18) wired into the step-18 - chain — wrap-and-rename when `sort` is absent, a pure strip when `sort` is - present -- pin tests (`component.test.ts`): a refusal pin asserting the prescription, a - no-materialize pin, and a surviving-surface pin on `sort` -- zero authored occurrences in either repo's corpora (the card's measurement, - re-run at dispatch), so no in-repo source changes ride along diff --git a/.changeset/objectql-custom-sql-measure-refusal.md b/.changeset/objectql-custom-sql-measure-refusal.md deleted file mode 100644 index 650d0c3343..0000000000 --- a/.changeset/objectql-custom-sql-measure-refusal.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -The ObjectQL analytics strategy now refuses a custom-SQL measure (`AggregationMetricType` `number` / `string` / `boolean`) with a loud `400 INVALID_FIELD` naming the measure and its metric type, instead of forwarding the raw SQL expression into `engine.aggregate` — where `driver-sql` rejected it blaming a `function` key the author never wrote, and the in-memory evaluator silently answered `null` for every bucket under the measure's own name. - -What stops being served, and for whom: on deployments whose driver has no native SQL capability (the ObjectQL aggregate path — e.g. Mongo or in-memory), a query or dataset widget selecting a custom-SQL measure now answers a 400 that says to use an aggregate measure (count/sum/avg/min/max/count_distinct) or run the cube on a native-SQL driver. Those queries previously "succeeded" with a per-bucket `null` (or a mis-attributed driver error), never with a correct number. Native-SQL driver behaviour is unchanged: custom-SQL measures still run there, emitted verbatim. diff --git a/.changeset/objectql-per-measure-aggregate-filter.md b/.changeset/objectql-per-measure-aggregate-filter.md deleted file mode 100644 index 03aaa3a6fc..0000000000 --- a/.changeset/objectql-per-measure-aggregate-filter.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -**Fix:** `/api/v1/analytics/query` on the ObjectQL door (MongoDB, the memory driver, or any deployment whose driver reports `objectqlAggregate` but not `nativeSql`) now honours a measure's own scoped `filter` — `won_count` and `won_amount`-style conditional measures answer the same numbers the dashboard door and the native-SQL door already did (#10413 phase 2). - -`ObjectQLStrategy.execute` lowers each measure's `filter` into the ONE aggregation it belongs to, via the per-aggregation `filter` field #10576 added to `engine.aggregate`'s contract (SQL `FILTER (WHERE …)` semantics) — not into the whole-call filter, which would have narrowed every measure (a fix shaped that way would make a conditional measure right while making every unconditional sibling measure in the same query wrong). An aggregation with no measure filter is unchanged and keeps the native-pushdown-eligible shape. - -`ObjectQLStrategy.generateSql` (the `/analytics/sql` echo) renders the same conditional aggregate — `COUNT(CASE WHEN … THEN … END)`-style — so the preview stays an honest description of what `execute()` now actually runs, matching the native-SQL strategy's existing echo for the same class of measure. - -Phase 1 (PR #10758) already ANDed a dataset's definition-level `filter` into the whole-call filter on this door; this closes the remaining half of the two-door disagreement #10413 reported. `NativeSQLStrategy` (#10298 / PR #10411) is unaffected by this change. diff --git a/.changeset/objectql-privileged-reads-join-ambient-transaction.md b/.changeset/objectql-privileged-reads-join-ambient-transaction.md deleted file mode 100644 index c4f9c445cb..0000000000 --- a/.changeset/objectql-privileged-reads-join-ambient-transaction.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -**Fix:** the engine's three privileged driver-level reads now JOIN an open ambient transaction instead of asking the connection pool for a second connection — which deadlocked `pool max=1` datasources and made `/admin/remove-user` refuse an entitled, signed-in caller with `401 UNAUTHENTICATED` (#10792). - -`resolveSecret`, `resolveSecretField` and `resolveInternalField` read at DRIVER level on purpose: that is the only layer where a masked or `internal: true`-omitted value still exists, and bypassing hooks, field-level security and sharing is the declared trust each of them places in its in-process caller. What they also bypassed — not by design — was the connection the surrounding transaction is holding. `buildDriverOptions` threads the ambient handle (ADR-0034) onto every ordinary read for exactly this reason; these three passed the driver **no options at all**, so their read went to a *fresh* pooled connection. - -On a roomy pool that is invisible: the pool simply hands out another connection. On a single-connection pool it is a deadlock. SQLite's knex pool is `max: 1` — `driver-sqlite-wasm` and `driver-sql`/better-sqlite3 both — and `pool max=1` is not a tuning choice there, it encodes SQLite's single-writer model. - -Measured on the erasure path, which is where the two met. `AuthManager.handleRequest` runs the `SESSION_ERASURE_PATHS` routes inside `engine.transaction(...)` so a refused erasure cannot leave the session and account deletes committed. Inside that transaction the vendor's session re-read reaches `resolveInternalField` through plugin-auth's internal-field readback; the read waited for a connection that could not be freed until the transaction waiting on the read finished, knex's acquire timeout fired (`Timeout acquiring a connection. The pool is probably full`), and the route degraded the block into an authentication refusal. On the default `objectstack dev` datasource, before this change: a caller better-auth's own admin gate **admits** was answered `401` after **120,196 ms** with the target row still present, and a signed-in plain member got the same `401` after **120,025 ms** instead of the `403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS` an authorization refusal owes them. After: `200` with the row deleted, `403`, and an anonymous caller's `401` unchanged — all promptly. Postgres and MySQL (`max >= 10`) always conformed and are unaffected; the reach nonetheless mattered because SQLite is the default datasource for `objectstack dev`, the showcase/dogfood boot, and any self-host that has not configured Postgres or MySQL. - -Two properties are deliberately **not** widened. The join is reads-only — the privileged write paths are untouched. And the #5351 same-origin gate still decides whether the handle is this object's driver's to use, so a privileged read that resolves to a *different* datasource keeps its own connection rather than executing someone else's statement on the wrong one. diff --git a/.changeset/olive-crabs-clone.md b/.changeset/olive-crabs-clone.md deleted file mode 100644 index c48fd29259..0000000000 --- a/.changeset/olive-crabs-clone.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -Add `POST /api/v1/automation/:name/clone` — whole-definition flow clone (ADR-0126 §7.1) - -An admin who cannot edit a packaged flow in place can now copy it to an ordinary -org-authored sibling and edit that instead. `POST /automation/:name/clone` takes -`{ name, label }` — both mandatory — and registers a copy of the source flow's -parsed definition under the new machine name. - -**The copy is whole-definition, never an enumerated facet list.** Every key the -source definition carries comes across; exactly `name`, `label` and `status` are -mutated. This is the shape ADR-0126 §7.1 rules for, and the reason is measured: -a clone assembled from an enumerated facet list silently dropped three of six -facets (#11703) — the record was created, the success toast fired, and the -difference was discoverable only by diffing the two rows. A flow has far more -facets than a permission set, so the acceptance test asserts deep equality of -the cloned definition against its source minus those three fields; a dropped -facet fails the test rather than shipping. - -**The new machine name is mandatory and a same-name clone is refused** with a -409 `RESOURCE_CONFLICT` naming both the reason and the remedy. Not because -storage rejects it — storage legitimately holds both rows — but because the -automation engine keys flows by bare name, so a second definition under one name -silently shadows the other and which of the two dispatches depends on -registration order. - -**No ancestry is recorded.** Nothing tracks what a clone was copied from — no -provenance field on the definition, none on the response (ADR-0126 amendment -ruling 2, §9). The source's own ADR-0010 protection envelope (`_packageId`, -`_provenance`, `_lock`, …) is dropped rather than carried across, so the clone is -an org-owned flow the admin can actually edit rather than a second copy of the -package's locked artifact. - -**References are not re-pointed** — no reference index exists, so the clone calls -exactly what the original called. The response says so, along with the fact that -`status: 'draft'` is a lifecycle label and not an off-switch: the engine only -disables on `obsolete`/`invalid`, so a cloned record-change or schedule flow is -bound to its trigger and runs alongside the flow it was copied from. - -The route joins the `manage_metadata` authoring-write set (#10145/#10243) — it -registers flow metadata at environment scope, exactly as `POST /automation` does. diff --git a/.changeset/olive-donkeys-repeat.md b/.changeset/olive-donkeys-repeat.md deleted file mode 100644 index c9949bae28..0000000000 --- a/.changeset/olive-donkeys-repeat.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): classify a refusal by what the producer declared, not by how its message starts - -The three record-share routes and `POST /api/v1/analytics/dataset/query` each -built their error answer by hand and shared no branch with the door every -`/data` face reports through, so one refusal got a different wire answer -depending on which route caught it. Both now ask that door first, through a -single new seam (`classifiedRefusalAnswer`), for a refusal the **producer -classified** — a declared 4xx `status`/`statusCode` **plus** a `code`, or a -sandboxed hook body's business `throw`. Everything else is untouched. - -**Per route, old answer → new answer.** Check your error handling if you branch -on any of these. - -`GET /api/v1/data/:object/:id/shares`, `POST` the same path, and -`DELETE /api/v1/data/:object/:id/shares/:shareId`: - -| the thrown refusal | was | is now | -| :--- | :--- | :--- | -| `{ code: 'RECORD_LOCKED', status: 409 }` (any code outside the five prefixes) | `500` `SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` | `409` `RECORD_LOCKED` | -| `{ code: 'FORBIDDEN', status: 403 }` — `plugin-sharing`'s own write gate | `500` `SHARE_*_FAILED` | `403` `FORBIDDEN` | -| the same declared as `statusCode` rather than `status` | `500` `SHARE_*_FAILED` | the declared status + code | -| a sandboxed hook refusal, no status declared | `500` `SHARE_*_FAILED`, message = the QuickJS wrapper `hook '' threw: Error: ` | `400` `VALIDATION_ERROR`, message = the hook's own sentence | -| a sandboxed hook body that CRASHED | `500` `SHARE_*_FAILED`, message = the wrapper around `TypeError: …` | `500` `SHARE_*_FAILED`, message = `Internal server error` | -| `VALIDATION_FAILED:` / `PERMISSION_DENIED:` / `NOT_FOUND:` / `CONFLICT:` / `SHARING_NOT_ENABLED:` prefixed messages | 400 / 403 / 404 / 409 / 422 with the prefix stripped | **unchanged** | -| anything else | `500` `SHARE_*_FAILED` with its own message | **unchanged** | - -`POST /api/v1/analytics/dataset/query`: - -| the thrown refusal | was | is now | -| :--- | :--- | :--- | -| a sandboxed hook refusal, no status and no code declared | `500` `{ code: 'ANALYTICS_QUERY_FAILED', error: }` | `400` `{ message: }` — the same status `POST /api/v1/data/:object` answers for the identical throw, and no code, because the producer declared none | -| a declared 4xx + code spelled `statusCode` rather than `status` | `500` `ANALYTICS_QUERY_FAILED` | the declared status + code | -| a declared 4xx + code spelled `status` | the declared status + code | **unchanged** | -| a declared 5xx, a crashed hook body, a driver fault, anything unclassified | `500` `ANALYTICS_QUERY_FAILED` | **unchanged** | - -The nested `{ success: false, error: { code, message } }` envelope the sharing -family answers is unchanged — only the status and code inside it move. The -`VALIDATION_ERROR` on the sandbox row is the catalog's declared floor for a -required `code` the producer did not name (`standardErrorCodeForHttpStatus`); -the flat `/data` body omits `code` there instead, because its `code` is -optional and ADR-0112 invents nothing. diff --git a/.changeset/one-id-shaped-platform-admin-judge.md b/.changeset/one-id-shaped-platform-admin-judge.md deleted file mode 100644 index 4b1acb0bab..0000000000 --- a/.changeset/one-id-shaped-platform-admin-judge.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/plugin-auth": patch ---- - -**Security:** the "is this user id a platform admin?" question is now asked in exactly one place, and the two copies that answered it differently are gone (#10348, #10949). - -ADR-0068 D2 defines platform standing as one thing — an unscoped `admin_full_access` grant, held now. `core/security/resolve-authz-context.ts` is the declared authority for authorization derivation and its header states that every entry point must resolve through it and never re-read the grant tables itself. `plugin-auth`'s `auth-manager.ts` did exactly that twice: once inside the `customSession` callback, and once in the predicate that authorizes `/sso/register` and, through the impersonation oracle, `/admin/impersonate-user`. Both copies are deleted. Both callers — and the session payload — now ask `hasPlatformAdminStanding(engine, userId)`, a projection of `resolveUserAuthzGrants` exported from `@objectstack/core`, so a platform-admin verdict is derived in one place for the whole platform. - -**What that changes, and it is a tightening on all three counts.** The deleted copies applied neither the ADR-0091 validity window nor the ADR-0049 `active` check, and resolved `admin_full_access` by matching a name over a page of the permission-set catalogue. The authority applies both checks before any derivation and resolves the set by id. So: - -- an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload; -- a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too; -- an environment holding **more permission sets than a single catalogue page** can no longer lose the `admin_full_access` row and demote every platform admin at once. - -**One behaviour widens, and it was ruled deliberately** (maintainer, 2026-08-24). The `customSession` copy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin — the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected: the two reads reached identical rows there already. - -**The org boundary is unchanged and now pinned at both gates.** An org owner, an org admin, a `TENANT_ADMIN`-posture principal and an org-scoped `admin_full_access` grant are all refused — the `PLATFORM_ADMIN` rung derives from the unscoped capability grant alone. The predicate takes an engine and a user id and nothing else: it deliberately does not accept the resolver's caller-supplied seeds, so no part of a request can supply part of its own verdict. - -Population queries are a different kind and are untouched: `ensure-default-organization.ts` asks *which* user is the platform admin, which a per-user predicate cannot express. diff --git a/.changeset/openapi-info-version-is-the-api-version.md b/.changeset/openapi-info-version-is-the-api-version.md deleted file mode 100644 index 57868de115..0000000000 --- a/.changeset/openapi-info-version-is-the-api-version.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -`GET {basePath}/openapi.json` no longer falls back to the spec package's -compile-time version when `api.version` is configured empty - -The served `info.version` has always carried the API version identifier -(`api.version`, default `'v1'`), under a comment claiming it carried "the -runtime version so consumers don't pin to the spec package's compile-time -version". Both halves were false: the runtime version never reached the field, -and the `|| enriched.info.version` fallback published exactly the compile-time -version the comment said the line existed to avoid. - -The fallback was reachable rather than dead, though not because the contract -permits it: `RestApiConfigSchema` declares -`version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which refuses `''`. Nothing -parses this config against that schema — both hops into the server are casts — -so `normalizeConfig`'s `??` is the only guard, it does not catch `''`, and the -document advertised `@objectstack/spec`'s package version. It now serves the configured value as -written, so a misconfigured deployment stays visibly misconfigured instead of -silently switching the field to a different kind of fact. Every non-empty -`api.version` — including the default — serves exactly what it served before. - -`info.version` is deliberately not the runtime version: OpenAPI 3.1 defines it -as "the version of the OpenAPI document (which is distinct from the OpenAPI -Specification version or the API implementation version)". Callers who want the -serving artifact read `{basePath}/discovery` or `/health`. diff --git a/.changeset/org-membership-limit-unbounded.md b/.changeset/org-membership-limit-unbounded.md deleted file mode 100644 index 2362aae4aa..0000000000 --- a/.changeset/org-membership-limit-unbounded.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/types': patch ---- - -An organization no longer stops accepting members at 100 — membership is not a -limited axis, and the ceiling nobody chose is now stated explicitly - -A customer adding users was refused with `Organization membership limit -reached`. Nothing in this codebase set that ceiling: better-auth's organization -plugin substitutes a vendor default of **100** for an absent `membershipLimit` -(`count >= (membershipLimit || 100)` in `routes/crud-members`), and -`auth-manager` passed `organizationLimit` — how many organizations one user may -CREATE — while never passing `membershipLimit`, which is a different question. - -The two read almost identically in a config block and mean nothing alike, which -is why the gap survived: the option that WAS set looked like the option that -was not. In the field the refusal is worse than merely wrong — it arrives while -an operator is looking at licences and seat counts, and reads as an entitlement -problem on an axis that carries no entitlement at all. Seats are metered on AI -usage; plain membership has never been billed. - -- `membershipLimit` is now passed explicitly, defaulting to unbounded. -- `OS_ORG_MEMBERSHIP_LIMIT` is the opt-in for a deployment that DOES want a - ceiling (a pilot, a trial tenant). Unusable values (empty, non-numeric, - zero, negative) read as unset rather than as a cap — a typo must not be the - thing that locks an organization, which is exactly the failure mode being - fixed. -- The decision lives in `resolveMembershipLimitOption()` rather than inside the - plugin-construction expression, so it is testable: the unset case, the - explicit ceiling, the unusable-value direction, and — deliberately — that the - chosen value clears the vendor's 100 by a wide margin. If a future - better-auth changes that default, the test says so instead of leaving an - unexplained constant behind. - -The unbounded value is `Number.MAX_SAFE_INTEGER`, not `Infinity`: the option is -compared numerically but also travels through option plumbing that may assume a -finite value, and nine quadrillion members is unlimited by any measure that -reaches a real deployment. diff --git a/.changeset/organization-field-pin-annotation-prose-refresh.md b/.changeset/organization-field-pin-annotation-prose-refresh.md deleted file mode 100644 index d405717e95..0000000000 --- a/.changeset/organization-field-pin-annotation-prose-refresh.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Refresh the `tenancy.organizationField` scope-pin annotation and its -`.describe()` in `packages/spec/src/data/object.zod.ts` — prose accuracy -only, no schema or behaviour change. - -The #8778 scope-pin annotation (landed by #10999) said consumer 1 (audit -stamping) was "as of this annotation, still the only one wired up" and that -consumers 2 and 3 (the approval-row writer and the automation-run recorder) -were "sanctioned but not yet implemented". #10101's PR #11311 (merged -2026-08-23) landed both: `resolveRecordOrganizationField` was promoted to a -shared resolver in `@objectstack/metadata-core` (plugin-audit re-exports it -from its original path) and all three sanctioned platform-row writers now -call it. The annotation and the `.describe()` string are updated to name all -three live consumers; the pin's load-bearing property — "The ruling sanctions -exactly THREE consumers of this key, and no others", a fourth consumer needs -its own ruling — is unchanged and still stated verbatim. - -No accept/reject behaviour change, no schema shape change, no new keys. diff --git a/.changeset/out-of-repo-package-names.md b/.changeset/out-of-repo-package-names.md deleted file mode 100644 index 3d65b0dd20..0000000000 --- a/.changeset/out-of-repo-package-names.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/console': patch -'@objectstack/cli': patch ---- - -Stop naming a package nobody can install: `@objectstack/framework` is not a real -package, and the multi-org remedy now says it is not publicly obtainable - -Four `@objectstack/` names appear in this repo's published docs and runtime text -without this repo building any of them. Measured against the public npm registry -(unauthenticated `GET https://registry.npmjs.org/@objectstack%2F`, with -`@objectstack/spec` and `@objectstack/cli` as positive controls so a 404 is a -fact about the name and not about access), they are **not one population but -three**: - -- `@objectstack/framework` — **404, and fabricated.** Unlike the others, nothing - in this tree describes it as enterprise, cloud, or private; it is presented as - the *default public* install. There is no umbrella package and there never was. -- `@objectstack/security-enterprise` (404) and `@objectstack/organizations` - (404) — **real, and deliberately not public.** This tree calls them - "closed-source" and "cloud-private" in a dozen places, and - `PLATFORM_CAPABILITY_PROVIDERS` declares `security-enterprise` with - `edition: 'enterprise'`. Their 404 is the caveat npm's API carries for any - private package, not evidence of fabrication. -- `@objectstack/service-tenant` — **published, at 4.1.0**, exactly as - `platform-object-names.ts` describes it. Untouched. - -What changes: - -- **`@objectstack/console`'s README** no longer opens with - `pnpm add @objectstack/framework`. The mechanism it described is real, just - misnamed: `@objectstack/cli` declares `@objectstack/console` as a dependency - and both ship at one version from the Changesets `fixed` group, so any app - that installs the CLI — every `npx create-objectstack` scaffold does — already - gets a version-matched Console. The instruction is corrected rather than - deleted, so the reader is left with something they can run. -- **`serve`'s multi-org fail-fast** kept telling an operator to add - `@objectstack/organizations` to their app without saying the runtime ships - only with an enterprise/cloud subscription. That is the un-followable "add it - to your dependencies" that framework#3366 exists to make legible. The remedy - now states it, so an operator without a licence can see that the two bullets - below it are their actual path. The declared-but-unresolvable branch is - unchanged — that operator does have the package. - -No behaviour changes: boot outcomes, exit codes and the posture wall are -untouched, and `@objectstack/security-enterprise`'s install hint is deliberately -left alone — it already names its edition boundary, and the test pinning it is -strengthened to assert that it keeps doing so. diff --git a/.changeset/outbox-ack-claim-credential.md b/.changeset/outbox-ack-claim-credential.md deleted file mode 100644 index 1bb04022ee..0000000000 --- a/.changeset/outbox-ack-claim-credential.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -**BREAKING (interface member signature):** `INotificationOutbox.ack()` now takes back the claimed record instead of a bare row id, and its compare-and-set binds the claim credential the record carries (#11859). `claim()` / `claimDigest()` declare their true return type, `ClaimedDeliveryRecord[]` — the same rows as before, with the (`claimedBy`, `claimedAt`) pair the store stamps guaranteed present — so reads of claim results do not change; the one breaking edit is at ack call sites, which hand the whole record back where they previously handed `record.id` (the caller already holds it: `ack` completes a claim, and the record is what `claim()` returned). - -Why: `status = 'in_flight'` could prove a claim exists but not whose. In the reachable sequence — node A claims a row, the send outruns `claimTtlMs`, another node's `claim()` reaps and re-claims the row, A finishes late — A's ack still matched and wrote its outcome over B's live attempt. With the credential in the predicate a late ack matches nothing, is refused with the existing `NotificationAckError` (`DELIVERY_NOT_ELIGIBLE`, ADR-0112), and writes nothing; the caller never needs to know its own `nodeId`, because ownership is proven by round-tripping what `claim()` returned. Both implementations (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) enforce it identically. - -Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`). - - diff --git a/.changeset/outbox-ack-status-precondition.md b/.changeset/outbox-ack-status-precondition.md deleted file mode 100644 index 43ee0fc6f8..0000000000 --- a/.changeset/outbox-ack-status-precondition.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`INotificationOutbox.ack()` enforces its declared precondition — the row must be claimed — in both implementations, and `attempts` moves only for a real dispatch attempt - -`ack()` is the dispatcher's completion callback for a row it CLAIMED, and -neither implementation checked that. `MemoryNotificationOutbox.ack` looked the -row up by id and mutated it; `SqlNotificationOutbox.ack` read only `attempts` -by id. So `ack(id, { success: false, suppressed: true })` on an unclaimed -`pending` row succeeded, flipped the row to terminal `suppressed`, and -incremented `attempts` — which made `ack` read like the cancellation primitive -this interface deliberately does not have. - -That was a trap in two directions. It **raced the dispatcher**: between a -caller's `list()` and its `ack()`, `claim()` could take the row — `claim` is -atomic by contract and `ack` was never part of that atom — so a suppression -could land on a delivery already on the wire, or a dispatcher's real outcome -could be overwritten by a caller that thought it was cancelling. And it -**corrupted `attempts`**: the counter feeds the retry schedule -(`classifyDeliveryAttempt(result, errorClass, row.attempts, …)`), so a row -"cancelled" this way arrived at its next real attempt with the backoff already -advanced by an attempt that never went out. - -Both implementations now refuse an ack on a row that is not `in_flight`, -throwing `NotificationAckError` with this package's already-registered -ADR-0112 code `DELIVERY_NOT_ELIGIBLE` — the same refusal -`SqlHttpOutbox.redeliver` raises when its own compare-and-set misses. A refused -ack writes **nothing**: status, `attempts` and `error` are left exactly as they -were, so the row stays claimable and its backoff position stays honest. An id -matching no row remains a silent no-op — an absent row has no state to corrupt -and no claim to lose. - -`SqlNotificationOutbox` does it as an **atomic conditional update** rather than -a read-then-write, because a read cannot hold a row still and a read-then-write -is the same defect wearing a different hat. The precondition is re-stated in -the write (`where: { id, status: 'in_flight' }`), which — per #11009 — must -ride the predicate path: on the by-id path the driver binds only the primary -key and the extra predicate is silently discarded. `attempts` is incremented -inside that condition and nowhere else, so the counter can only move for a row -that was genuinely claimed. A conditional write that matches nothing is -reported rather than passed off as success. - -`NotificationDispatcher` absorbs exactly one refusal — `DELIVERY_NOT_ELIGIBLE` -— logs it and continues with the rest of the batch, because a send slower than -`claimTtlMs` legitimately loses its claim to the visibility-timeout reap, and -letting that unwind the partition loop would strand every still-valid row in -the batch `in_flight` until its own timeout expired. Any other error still -propagates. - -The sibling HTTP outbox is deliberately untouched: `assertHttpRedeliverable` -depends on `IHttpOutbox.ack` incrementing `attempts` unconditionally, so that -`attempts === 0` on a terminal row still means "parked, never sent". diff --git a/.changeset/package-door-declared-code.md b/.changeset/package-door-declared-code.md deleted file mode 100644 index 92b9bedd93..0000000000 --- a/.changeset/package-door-declared-code.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the direct-mount package door carries a demoted producer code on `declaredCode` (#12405) - -`GET /api/v1/packages`, `GET /api/v1/packages/:id`, `POST /api/v1/packages/publish` -and `DELETE /api/v1/packages/:id` now put a producer's own error-code spelling on -the wire's `error.declaredCode` when the closed ADR-0112 vocabulary did not admit -it. Previously that spelling was resolved and then dropped: `sendThrownError` -(`packages/rest/src/package-routes.ts`) asked `resolveThrownHttpError` for the -answer — which returns `declaredCode` exactly when the demote happened — and then -forwarded only `details` to the shared envelope writer. - -Nothing invalid shipped, which is what made the loss silent and one-directional: -the closed `code` still carried the member the HTTP status derives, so every body -parsed, while an author's spelling vanished and a consumer told by ADR-0112 to read -`declaredCode` found nothing there. - -This ADOPTS the rule two sibling doors already apply rather than inventing one — the -demote is read through `demotedDeclaredCode`, the single definition of "presence -means demotion", exactly as the dispatcher's `errorFromThrown` -(`packages/runtime/src/http-dispatcher.ts`, #9106) and the flat `/data` door's -`thrownCodeFields` (`packages/rest/src/error-response.ts`, #9232) do. That matters -here specifically: the runtime dispatcher domain is the TWIN transport for -`/api/v1/packages`, and it has emitted this channel all along, while this -direct-mount registrar — which registers first and is therefore the one production -serves for the three routes both declare — dropped it. One path, two doors, -disagreeing on a declared channel. - -Additive and shape-preserving. A REGISTERED producer code still carries no -`declaredCode` (repeating it would put two spellings of one fact on every refusal), -a producer that declared no code still carries none, `details` is untouched, and -`code`/`status`/`message` are byte-identical to before on every existing path. The -5xx message withhold is unchanged and does NOT suppress the demote: that withhold is -scoped to the prose by its own contract (`status`, `code` and `details` untouched), -`declaredCode` is a code channel, and the twin applies no status condition to it -either. - -⛔ `userMessage` is deliberately NOT threaded here — the shared `sendError`'s `extra` -has admitted it since #12404, and that channel is threaded separately, in #12502. diff --git a/.changeset/package-door-user-message.md b/.changeset/package-door-user-message.md deleted file mode 100644 index ef0f65c936..0000000000 --- a/.changeset/package-door-user-message.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the direct-mount package door carries a producer-marked `userMessage` (#12502) - -`GET /api/v1/packages`, `GET /api/v1/packages/:id`, `POST /api/v1/packages/publish` -and `DELETE /api/v1/packages/:id` now put a producer's user-facing refusal text on -the wire's `error.userMessage` when the throw carried one. Previously that text was -resolved and then dropped: `sendThrownError` (`packages/rest/src/package-routes.ts`) -asked `resolveThrownHttpError` for the answer — which returns `userMessage` exactly -when the producer marked a non-empty string at throw time (#9934) — and then -forwarded only `details` and `declaredCode` to the shared envelope writer. - -Nothing invalid shipped, which is what made the loss silent: `code`, `status` and -`message` were all correct, so every body parsed, while an author's deliberate, -end-user-addressed sentence vanished and a consumer told by ADR-0112 to render -`userMessage` verbatim found nothing there and fell back to its generic -substitution — the #3821 behaviour this channel exists to override. - -This completes the pair the sibling change left open. That one threaded -`declaredCode` and said `userMessage` was "tracked separately"; this is it, and the -`extra` it spreads into has admitted the field since #12404. - -⛔ The idiom is the INVERSE of the sibling's, deliberately. `declaredCode` must be -read through `demotedDeclaredCode` because its raw field carries a second meaning — -it is also set when the producer's spelling IS the registered member, so forwarding -it raw would put two spellings of one fact on every registered refusal. -`userMessage` has no second meaning: `declaredUserMessage` already decided what -counts as marked (a non-empty string, or nothing), so the caller passes -`thrown.userMessage` straight through, byte for byte what the dispatcher twin -serving this same path does (`errorFromThrown`, -`packages/runtime/src/http-dispatcher.ts`). Consumers must not read presence as -anything but "the producer opted in". - -Additive and shape-preserving. An unmarked refusal still carries no `userMessage`, -and the three shapes `declaredUserMessage` rejects — `''`, whitespace-only, a -non-string — still carry none, so nothing invents a marked message for a producer -that never wrote one. `details`, `declaredCode`, `code`, `status` and `message` are -byte-identical to before on every existing path. - -The 5xx message withhold is unchanged and does NOT suppress the mark: that withhold -rewrites a local `message` const and `looksLikeInternalErrorLeak` is only ever handed -`thrown.message`, so the marked channel is never an input to it. The two are -answering different questions — leaked diagnostic prose is withheld, while the -producer's own sentence to the end user discloses only what it chose to — and the -ruling that created the channel made it status-agnostic on purpose. - -⚠️ Stated because it is the honest cost, and so the next channel added here does not -have to rediscover which bar applies: the IN-TREE producer set at this door is empty, -for this channel and for `declaredCode` alike. This door is judged live because it is -**composed rather than closed** — `resolvePackageService()` and the `protocol` slice -are open composition points whose throws all four handlers forward verbatim, and -ADR-0112's federation amendment exists precisely because the producer set is not -enumerable in-tree. The live population is the injected/federated limb, which is the -population the new pins in `packages/rest/src/package-door-user-message.test.ts` -drive. diff --git a/.changeset/page-component-responsive-removed.md b/.changeset/page-component-responsive-removed.md deleted file mode 100644 index e25f43fa76..0000000000 --- a/.changeset/page-component-responsive-removed.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `page.components[].responsive` and the `ResponsiveConfig` layout vocabulary; repair every shipped text that prescribed it (#11027, ADR-0049 D2) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`page.components[].responsive` was the LAST carrier of the per-breakpoint -LAYOUT block (`ResponsiveConfig`: grid columns / visibility / display order on -the Tailwind `xs…2xl` axis) — and the destination the -`dashboard.widgets[].responsive` tombstone (#4876) prescribed verbatim as the -live alternative ("which objectui `useResponsiveConfig` really does read"). -Measured across objectstack + objectui with the tsc-probe methodology -(positive and negative controls; objectui `3b147a367`, objectstack -`8d21f7a76`): that claim was false. objectui's two complete, published -implementations of the contract — `useResponsiveConfig` (`@object-ui/mobile`) -and `ResponsiveProtocol` (`@object-ui/core`) — had ZERO callers, nothing in -either repo read `.responsive` off a page component, objectui's own -`BaseSchema` node interface never declared the key, and zero authored -instances exist. An author following the shipped prescription moved an inert -key to an inert key and was told it now works. Identical disposition to -`view.responsive` (#3896) and `dashboard.widgets[].responsive` (#4876) on -identical evidence. - -**What is refused:** an authored `responsive` on a page component. The key is -a `retiredKey()` tombstone, so authoring it is a `tsc` error and a parse error -carrying the prescription. - -**What leaves with it:** `ResponsiveConfigSchema` / `ResponsiveConfig`, -`BreakpointColumnMapSchema` / `BreakpointColumnMap`, -`BreakpointOrderMapSchema` / `BreakpointOrderMap`, and the `BreakpointName` -enum — no other authorable carrier existed, and an exported value schema with -no consumer reads as a capability (#3950; the `PerformanceConfigSchema` -precedent). Importing any of them is TS2305 from this release. - -**What stays:** `responsiveStyles` (ADR-0065, `ResponsiveStylesSchema` / -`StyleMapSchema`) — the per-breakpoint channel objectui really compiles to -id-scoped CSS — is untouched and is what every repaired text now points at. - -The redirect repair (the reason this ships as one change): four author-facing -surfaces shipped the false redirect and are corrected together — the #4876 -tombstone prescription (`dashboard.zod.ts`), the generated widget reference -page, the protocol upgrade guide prose, and the protocol-17 migration -rationale that `os migrate meta --from 16` prints. The `dashboard.json` -liveness note and the #4876 conversion summary carried the same claim and are -corrected too; `responsive.zod.ts`'s `hidden → hiddenOn` alias curation, which -justified itself by `useResponsiveConfig`'s return shape, leaves with the -schema that hosted it. - -The retirement kit: - -- `retiredKey()` tombstone at the schema (`packages/spec/src/ui/page.zod.ts`), - prescription pointing at `responsiveStyles` with the CSS translations for - `columns` / `hiddenOn` / `order` -- ADR-0087 registration: retired-key entry `ui/PageComponent:responsive`, four - retired-def entries (`ui/ResponsiveConfig`, `ui/BreakpointColumnMap`, - `ui/BreakpointOrderMap`, `ui/BreakpointName`), and the D2 conversion - `page-component-responsive-removed` (protocol 18), wired into the step-18 - chain — `os migrate meta --from 17` strips the key from authored pages at - every component position, region, slot, or nested container (pure lossless - delete; it never had an effect to lose) -- pin tests (`page.test.ts` — refusal pin asserts the prescription; a positive - pin parses a component without the key and asserts `responsiveStyles` - survives; `dashboard.test.ts`'s #4876 pins now assert the corrected - prescription instead of the false redirect) -- `ResponsiveStylesSchema` guidance for `columns` / `hiddenOn` / `order` now - names the retirement and the CSS that IS applied, instead of prescribing the - dead sibling key -- generated baselines/docs follow the schema (authorable surface, JSON-schema - manifest, api-surface, export-origins, spec-changes, upgrade guide, - reference docs, skill references) -- objectui's two dead consumer implementations are the other half of this - measurement and are queued under objectui#4773 (this package's texts no - longer point authors at them) - -## FROM → TO - -```ts -// before — parsed green; no renderer ever applied any of it -{ - type: 'element:text', - id: 'kpi_label', - responsive: { - columns: { xs: 12, lg: 4 }, - order: { xs: 2, lg: 1 }, - hiddenOn: ['xs'], - }, -} - -// after — express per-breakpoint behaviour as scoped CSS (ADR-0065), which -// objectui compiles and applies (desktop-first buckets) -{ - type: 'element:text', - id: 'kpi_label', - responsiveStyles: { - large: { gridColumn: 'span 4', order: '1' }, - small: { gridColumn: 'span 12', order: '2' }, - xsmall: { display: 'none' }, - }, -} -``` diff --git a/.changeset/page-declaration-shape-gate.md b/.changeset/page-declaration-shape-gate.md deleted file mode 100644 index c6d24200f2..0000000000 --- a/.changeset/page-declaration-shape-gate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -`CONNECT_AGENT_PAGE` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery can see it. It reaches the kernel through `CONNECT_AGENT_UI_BUNDLE.pages`, but was authored as a bare `export const CONNECT_AGENT_PAGE = { … }` with per-field `as const` — the same shape `MarketplaceInstalledPage` shipped in before #11574, and invisible to the export-shape scan the canonical-envelope gates (#11255, #11480) discover their population with. A new repo-wide gate, `check:page-declaration-shape`, now closes the class: every identifier in a bundle's `pages:` array must be declared `export const X: Page =` or through `definePage()` (#11576). diff --git a/.changeset/permission-metadata-door-lock.md b/.changeset/permission-metadata-door-lock.md deleted file mode 100644 index cc9e983ebd..0000000000 --- a/.changeset/permission-metadata-door-lock.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -The packaged-permission-set lock now guards the metadata door as well as the data door. The pre-persistence authoring-gate seam gains a `permission` registration (`registerPackagedPermissionSetLockGate`, new export) that consults the same `classifyPackagedPermissionSet` classifier and throws the same `PackagedPermissionSetLockedError` the `sys_permission_set` write door already uses — one spelling of "package-declared", two doors, one refusal. - -What stops working, and for whom: an operator using the `OS_METADATA_WRITABLE=permission` escape hatch to save a permission set that an installed package declares now gets `403 NOT_OVERRIDABLE` (the refusal names the sanctioned clone path) instead of silently minting a `sys_metadata` overlay whose grants win at read over the package's declaration. This applies hatch open or closed, draft and publish saves alike, and also means stored-overlay maintenance passes (e.g. stored-item migration) report a per-row refusal for such grandfathered forks rather than rewriting them. - -What keeps working unchanged: hatch writes to any name no installed package declares — the hatch's documented per-org/env override capability — land exactly as before, and package-door authoring of workspace-owned sets (definitions living in `sys_metadata`; ADR-0070, ADR-0094 D5-R) stays editable. Package publishes travel the `package-author` channel, which this seam exempts by contract. diff --git a/.changeset/permission-restore-purge-retired.md b/.changeset/permission-restore-purge-retired.md deleted file mode 100644 index 179f483e84..0000000000 --- a/.changeset/permission-restore-purge-retired.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-security": patch ---- - -feat(spec): retire the `allowRestore` / `allowPurge` object-permission bits — declared gates on operations that do not exist (#12497, ADR-0049) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). -Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's -recommendation B; **the keys return with the M2 lifecycle initiative** (feature -+ RBAC in one batch) — anchor card #1883 stays open. - -`allowRestore` and `allowPurge` claimed to gate `restore` (undelete) and -`purge` (hard-delete / GDPR erase) ObjectQL operations that have never -existed: no destructive lifecycle verb is in the engine's dispatch vocabulary -(pinned by objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106). -Authoring the bits granted nothing — and in the `allowPurge: false` direction -the failure was ADR-0049's worst false-compliance shape: an admin believed a -lock on permanent deletion existed when the operation itself did not. The -sibling `allowTransfer` is **enforced** (#3004, the insert/update `owner_id` -door) and is untouched. - -**What is refused:** authoring either key, with any value — both are -`retiredKey()` tombstones (`ObjectPermissionSchema` is reachable from the -`permission` metadata root, so the tombstone route keeps the removal audible: -a tsc `never` on the input type plus a parse-time prescription). The former -`restore` / `purge` bare-verb aliases now answer with the same prescription -instead of a rename onto a tombstone. The tombstone rides the `.extend()` -clone into `EffectiveObjectPermissionSchema`, so the response-side def carries -the same `[RETIRED]` rows. - -**What stays accepted:** every other object-permission bit parses -byte-identically (`allow*` CRUD, `allowExport`, `allowTransfer`, -`viewAllRecords`, `modifyAllRecords`, `readScope` / `writeScope`). - -**Runtime (plugin-security):** the evaluator's pre-mapping rows -(`OPERATION_TO_PERMISSION` restore→allowRestore / purge→allowPurge) retired in -the same batch — with the bits unwritable, a mapping onto them was a claim -about a surface that rejects authoring. Behaviour is deny-before and -deny-after: a dispatched `restore` / `purge` is refused fail-closed by the -`DESTRUCTIVE_OPERATIONS` backstop, now unconditionally (not even -`modifyAllRecords` reaches an unmapped destructive op — the bypass re-covers -them only when the M2 batch re-adds the rows). `transfer` keeps its row and -its bypass. `describeHighPrivilegeBits` stopped reading `allowPurge` (a legacy -stored value grants nothing, so flagging it guarded nothing real); the -delete/purge/transfer class message is unchanged. - -The retirement kit: - -- `retiredKey()` tombstones + former-alias `guidance` prescriptions at the - schema (`packages/spec/src/security/permission.zod.ts`) -- ADR-0087 registration: retired-key entries - `security/ObjectPermission:allowRestore` / `:allowPurge` (and the - `security/EffectiveObjectPermission` pair for the cloned rows) and the D2 - conversion `permission-allow-restore-purge-removed` (protocol 18), wired - into the step-18 chain — `os migrate meta --from 17` strips the keys from - every object grant in `permissions[].objects` (pure lossless delete; they - never had an effect to lose) -- liveness ledger: both entries flipped to `dead` with the retiredKey evidence - (entries stay — the tombstone keeps the keys in the walked shape, the - `rls.priority` precedent) -- pin tests (`permission.test.ts` — refusal pins asserting the prescription; - `security-plugin.test.ts` — fail-closed pins incl. the legacy-stored-grant - and modifyAllRecords directions; `audience-anchors.test.ts` — the predicate - no longer reads the retired bit) -- generated baselines/docs follow the schema (`authorable-surface/`, - `authorable-defaults/`, spec-changes, upgrade guide, reference docs) - -## FROM → TO - -```ts -// before — parsed green; nothing ever read the bits, no operation existed -definePermissionSet({ - name: 'support_agent', - objects: { - crm_ticket: { - allowRead: true, allowEdit: true, - allowRestore: true, // claimed: can undelete — nothing enforced it - allowPurge: false, // claimed: GDPR erase locked — no lock existed - }, - }, -}); - -// after — delete the keys; restore/purge dispatches are denied fail-closed -// until the M2 lifecycle batch ships the operations WITH their RBAC bits -definePermissionSet({ - name: 'support_agent', - objects: { - crm_ticket: { allowRead: true, allowEdit: true }, - }, -}); -``` - - diff --git a/.changeset/permission-set-residue-fallback.md b/.changeset/permission-set-residue-fallback.md deleted file mode 100644 index c493669844..0000000000 --- a/.changeset/permission-set-residue-fallback.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -An organization-less `sys_permission_set` row grants again — #11121 revoked standing access silently - -#11121 made the request-time permission-set loader tenant-scoped so two -organizations holding a row for the same name stop answering each other's -requests. It shipped the second half as a COMMENT — "an organization-less -leftover only where it does not [have its own]" — and the code read `.own` -alone, which by `resolveOwnOrganizationRow`'s own documented contract is never -a residue once an organization is supplied. - -That helper is written for SEEDERS, where refusing to read a residue as -"already seeded" is the entire point. Enforcement wants the opposite reading: an -organization-less row is still a row the principal was granted, and dropping it -revokes standing access with no signal at the moment of loss — the failure this -catalog's own header, and `resolve-authz-context`'s `sys_position` read, both -name as the thing not to do. - -The asymmetry was observable on a single row: its `system_permissions` and -`tab_permissions` kept applying, because that read is unscoped and by id, while -its `object_permissions` and `admin_scope` stopped. One row, two enforcement -planes, opposite verdicts. Every walled deployment carrying pre-#11121 rows — -or any row authored without a tenant, which includes admin-UI-authored sets — -lost those grants on upgrade, reported only as a boot WARN about "leftovers" -that states the catalog is complete. - -Found by cloud's `apps/ee-group-showcase` dogfood suites, which had been failing -four ADR-0111 / ADR-0105 assertions on cloud main while turbo replayed them from -cache. - -Preference order is unchanged, so the cross-tenant bleed #11121 closed stays -closed: this organization's own row still WINS wherever it exists, and a -leftover is consulted only in its absence. #11121's suite covers seeding and the -`sys_position` sweep; the three cases added here cover the loader path it did -not — residue resolves, own beats residue, and the single-posture carve-out is -untouched. Reverting the one-line fix reddens exactly the first of them. diff --git a/.changeset/perrow-sandbox-signal.md b/.changeset/perrow-sandbox-signal.md deleted file mode 100644 index 9404a42453..0000000000 --- a/.changeset/perrow-sandbox-signal.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": patch ---- - -Hook body sandbox context now carries the per-row dispatch signal and the D2 options projection (#11552). A shipped (L2 sandboxed) hook body observes `ctx.dispatch` — a frozen `{ mode: 'record' | 'per-row', index }` copy of the engine's #6966 dispatch marker (`scope` deliberately does not cross: a JSON copy cannot keep its shared-identity contract) — and `ctx.input.options` — a frozen, non-enumerable `{ multi?, where? }` projection of the caller's bag, the two members ADR-0058 Addendum II D2 declares visible to the `before*` phase. This closes the declared≠observable gap that made D3's routes 1 (batch-scoped throw) and 2 (`ctx.api` per row) inexpressible from a body-only hook: a guard written `ctx.dispatch?.mode === 'per-row'` previously evaluated `false` on every production dispatch. `Object.keys(ctx.input)` still enumerates payload fields only, `ctx.input.id` stays absent (read `ctx.previous.id`), and the post-run input write-back cannot carry the grafted keys back to the engine. The spec change is documentation-only: `HookContextSchema`'s `input`/`dispatch` TSDoc now states the body-face visibility. diff --git a/.changeset/pg-date-calendar-day-not-local-midnight.md b/.changeset/pg-date-calendar-day-not-local-midnight.md deleted file mode 100644 index 48817588ab..0000000000 --- a/.changeset/pg-date-calendar-day-not-local-midnight.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -Stop reading every PostgreSQL `Field.date` one day early on a process east of UTC - -On PostgreSQL a `Field.date` came back **one calendar day early** whenever the -Node process ran east of UTC — an app container on `TZ=Asia/Shanghai` served -`"apply_date": "2026-08-23"` for a row `psql` reads as `2026-08-24`. The stored -value was always right; the read corrupted it, so the wrong day was already in -the REST payload before anything rendered it. Worse than a display bug: an -`afterUpdate` hook copying a date into a child record persisted the shifted -value, writing the wrong day back into the database. - -`node-postgres` materialises OID 1082 (`date`) as a JS `Date` at **local** -midnight, and `SqlDriver#toDateOnly` reads a `Date` with **UTC** components. -East of UTC, local midnight is the previous day in UTC. Measured on PostgreSQL -16, one stored row `2026-08-24`, only the process `TZ` changed: - -| process `TZ` | `pg` materialised | driver returned | -|---|---|---| -| `UTC` | `2026-08-24T00:00:00.000Z` | `2026-08-24` | -| `America/New_York` | `2026-08-24T04:00:00.000Z` | `2026-08-24` | -| `Asia/Shanghai` | `2026-08-23T16:00:00.000Z` | **`2026-08-23`** | - -Fixed at the parser rather than the reader: the driver now registers a -connection-scoped type parser so `date` (OID 1082) and `date[]` (1182) arrive -as their `YYYY-MM-DD` wire text and never become a `Date` at all — the same -shape SQLite has always had, and the same shape MySQL already had via the -existing UTC connection pin. `timestamptz` is untouched: an instant is what a -`Date` is for, and `Field.datetime` depends on it. The parser is registered on -the connections this driver opens, never through the process-wide -`pg.types.setTypeParser`, so a host application's own `pg` clients keep stock -behaviour. - -Reading local components in `toDateOnly` instead was measured and rejected: -that helper is shared by the read, write and filter paths, and a caller's -`new Date('2026-08-24')` is UTC midnight — local components would report it as -`2026-08-23` west of UTC, i.e. the identical one-day error moved onto the write -and filter paths. `toDateOnly` now documents the UTC clock as its contract. - -**If you worked around this, you can undo the workaround.** Running the app -process with `TZ=UTC` is no longer a prerequisite for correct dates, and any -app-side "+1 day" compensation on a PostgreSQL date read must be removed — with -this release the driver returns the stored day, so a compensating shift now -overshoots. Rows that were *written* through the old skew (a hook that copied a -date it had just read) still hold the wrong day and need a data fix; nothing -here rewrites stored data. - -One behaviour change beyond the corrected day: on PostgreSQL a raw read -(`driver.execute(...)`, or knex used directly on this driver's connection) now -yields a `string` for a `date` column where it previously yielded a `Date`. -Values leaving `find()` / `findOne()` / `aggregate()` / `distinct()` were -already normalised to `YYYY-MM-DD` strings and keep that type — only the day -they name changes. - -Pinned by a process-zone matrix (`UTC`, `Asia/Shanghai`, `America/New_York`, -`Asia/Kolkata`) that asserts it contains an east-of-UTC cell before it believes -itself: the existing live-Postgres CI job runs at `TZ=America/New_York`, which -is west of UTC, where the pre-fix read names the right day — which is why this -was green in CI for as long as it was broken in production. diff --git a/.changeset/pg-introspect-fk-join-correlations.md b/.changeset/pg-introspect-fk-join-correlations.md deleted file mode 100644 index 3ea0deaaaa..0000000000 --- a/.changeset/pg-introspect-fk-join-correlations.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -**Fix:** `introspectForeignKeys`' Postgres arm no longer drops a cross-schema foreign key, nor returns a composite one as a cartesian product (#11324). - -The arm joined three `information_schema` views, and the correlations were wrong in two independent ways. Both were measured on live PostgreSQL 16.13, against the query as it stood after #11201, so neither was caused by nor repaired by that change. - -**A foreign key whose target lived in another schema vanished.** The join carried `ccu.table_schema = tc.table_schema`, which demands parent and child sit in the same schema. For a FOREIGN KEY constraint, `constraint_column_usage` describes the *referenced* side — that is exactly why the projection aliases it `referenced_table` — so its `table_schema` is the **parent's**, not the constraint's. A cross-schema reference therefore contributed **zero rows**, and the table reported having no foreign keys at all. That is the #7332 failure mode through a different door and it has no `onFailure` to consult, because nothing failed: `[]` does not read downstream as "I could not see it", it reads as *this table has no foreign keys*, and federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift comparison all act on it. Cross-schema references are the normal shape for the federated remotes ADR-0015 points this driver at. - -**A composite foreign key came back as the cartesian product of its columns.** The `kcu` ↔ `ccu` join carried no ordinal correlation at all, so an N-column key yielded N x N rows pairing every child column with every parent column. Measured, a 2-column key `(x, y) references p (a, b)` returned **four** records — `x -> a`, `x -> b`, `y -> a`, `y -> b` — where the answer is `x -> a`, `y -> b`. Because `IntrospectedForeignKey` is a flat per-column record, the two phantom pairs are indistinguishable from the real ones to every consumer: a wrong-shaped answer that type-checks. - -**The whole query moves to `pg_constraint` rather than the join predicate being patched.** `constraint_column_usage` exposes no ordinal column at all — measured, its seven columns are the catalog/schema/name triples for the table and the constraint plus `column_name` — so the composite half has nothing to correlate on inside `information_schema`. The conservative half-fix was tried and measured: correlating `ccu` on `tc.constraint_schema`, the spelling `introspectUniqueConstraints` already carries, repairs the cross-schema case and leaves the composite case at four rows. `pg_constraint` carries both facts on one row — `conkey` and `confkey` are parallel `smallint[]`s in key order — so unnesting them *together* pairs child column with parent column by construction, and `unnest(...) WITH ORDINALITY` keeps the key position the old join threw away. That is the shape `introspectPrimaryKeys` already uses for `indkey` (#11101 / #11162), and dropping to the catalog matches what that arm and `introspectIndexes` already do. - -**No interface change.** `IntrospectedForeignKey` keeps its flat per-column shape and gains no ordinal field. A composite key is expressed as **ordered sibling rows** — contiguous, in declared key order, each pairing its own child column with its own parent column — which `ORDER BY con.conname, con.oid, k.ord` now pins and the type's docblock now states. Measured on a key declared out of column sequence, `foreign key (second_col, first_col)`, the result is key order rather than column order. An ordinal field was considered and rejected: it would let a wrong `ORDER BY` keep shipping wrong rows that merely *describe* their wrongness, where the pairing is a fact the query itself has to get right. - -Schema scoping is unchanged in meaning: `ns.nspname = ANY (current_schemas(false))` is #11201's `tc.table_schema = ANY (…)` expressed over the catalog, so a same-named table in a schema `search_path` never reaches still contributes nothing. An unknown table name still yields an empty list rather than a throw, so the #7332 `onFailure` contract is untouched. diff --git a/.changeset/planned-liveness-verdict-not-dead.md b/.changeset/planned-liveness-verdict-not-dead.md deleted file mode 100644 index 67bd2ec761..0000000000 --- a/.changeset/planned-liveness-verdict-not-dead.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`lintLivenessProperties` no longer tells authors a `planned` property is `dead` - -`describe()` in `lint-liveness-properties.ts` only knew two verdicts -(`experimental`, everything else → `dead`), while the liveness ledger ships a -third: `status: 'planned'` (declared, and a consumer is being built against -it — contract-first, the opposite of `dead`). Every `planned` row fell through -into the `dead` branch, so the finding's own **message** told the author to -remove metadata the platform had asked them to write, while the same finding's -**hint** (when the row carried one) said the opposite one sentence later. Three -shipped rows hit this: `field.relatedListFilter`, `object.externalSharingModel`, -`translation.flows`. - -`describe()` now has a third branch: `status === 'planned'` gets its own rule -id (`liveness-planned-property`, mirroring `liveness-dead-property` / -`liveness-experimental-property`'s advisory-only posture — nothing downstream -keys off these ids today) and its own message/default hint ("keep it — a -consumer is being built against this property", never "Remove it"). - -The ledger's `status` field is a documented vocabulary, not a Zod-enforced -enum — nothing rejects a ledger entry with an unrecognised status. `describe()` -previously graded any such entry `dead` silently; it now throws, naming the -offending status, so a ledger-authoring mistake (a typo, or a new status added -without teaching this file about it) fails loudly at test time instead of -mislabelling a finding. diff --git a/.changeset/platform-admin-capability-export.md b/.changeset/platform-admin-capability-export.md deleted file mode 100644 index 64d9b0c90c..0000000000 --- a/.changeset/platform-admin-capability-export.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/plugin-security': patch ---- - -Export the kernel platform-admin capability declaration from `@objectstack/spec` (`ADMIN_FULL_ACCESS_CAPABILITIES`) and import it in plugin-security's `admin_full_access` permission-set declaration, so exactly one copy of the capability list exists (#11663 Choice 6A, leg L1). Behaviour-neutral: the declared capability set is byte-for-byte unchanged, pinned by test. diff --git a/.changeset/plugin-auto-restart-never-reinitialised.md b/.changeset/plugin-auto-restart-never-reinitialised.md deleted file mode 100644 index 9b9dbe6125..0000000000 --- a/.changeset/plugin-auto-restart-never-reinitialised.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the prescriptions are registered -under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 -semantic entry `plugin-auto-restart-never-reinitialised` — where -`os migrate meta` users will look). Graded `minor` rather than `major` for the -same reason #12340 and #12428 were, the day before, in this same module. - -## What was measured - -`PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped -there. The comment above the call read *"Call destroy and init to restart"*, -and `init` appeared in `health-monitor.ts` **only inside that comment**. So a -plugin whose health checks crossed `failureThreshold` with `autoRestart: true` -got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`, -and periodic health checks that carried on running against the destroyed -instance. The default check when no `checkMethod` resolves is -`{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes -indefinitely — so the **terminal** report on a torn-down, never-re-initialised -plugin was `healthy`. - -Reproduced at `ee3595cefd` before anything was changed, with -`successThreshold: 3`: - -``` -round 1 (failing): status=failed destroyed=0 alive=true -after backoff: status=recovering destroyed=1 alive=false -recovery round 1: status=recovering destroyed=1 alive=false -recovery round 2: status=recovering destroyed=1 alive=false -recovery round 3: status=healthy destroyed=1 alive=false -``` - -#11955 made that report *more* convincing rather than less: reaching `healthy` -now costs `successThreshold` consecutive passing rounds, so a destroyed plugin -has to earn a declared number of passes before it is misreported. -`restartAttempts` was incremented as though a restart had occurred, and -`maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin -that was never brought back up. - -## Why REMOVE and not the other two ADR-0049 states - -**ENFORCE** would have to build the restart, and the class cannot host one. -`Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call -sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`, -`kernel.ts:607`), both over the full plugin list, with a context that is -`private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain -one, so a host-provided re-init hook would have had nothing to call. (Positive -control for that scan: the same pass resolves five real non-test -`plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a -per-plugin re-init API for a caller that does not exist — no runtime constructs -`PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision -names as the wrong default at this milestone, where the shippable liability is -the false promise and not the missing feature. - -**EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR -corpus returned **zero** mentions of plugin auto-restart, against 118 control -hits for "health" and 13 for "hot reload" in the same corpus. - -`maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than -as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and -*"Backoff strategy for restart delays"* have nothing left to be the vocabulary -**of** — the test that took `distributedConfig` out with the `stateStrategy` -value it was documented as requiring (#12340). - -## What changes for a host - -All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not -`.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a -milder form of the defect being retired. A TypeScript host gets a `tsc` error -(the keys are typed `never`); a parse raises the prescription; and -`PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of -them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown -before any state is stored so a refused config leaves no half-registered plugin -behind. - -`PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that -crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and -left running; acting on that is the host's job in this host-driven library -(#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` -and restart at the level that owns the plugin's lifetime. - -Everything else in the monitor is unchanged: registration, periodic checks, the -`timeout` race and its refd-timer guard (#4875), both failure routes sharing the -counters (#11852), and `successThreshold` binding from every status that records -a failure (#11955). `recovering` is now written only by the success branch — -the one writer that ever meant it. diff --git a/.changeset/plugin-keyed-text-bounds.md b/.changeset/plugin-keyed-text-bounds.md deleted file mode 100644 index 7dbab79d34..0000000000 --- a/.changeset/plugin-keyed-text-bounds.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/plugin-audit": patch -"@objectstack/plugin-security": patch ---- - -fix(plugin-audit,plugin-security): declare sourced bounds on the four keyed text columns that break MySQL schema-sync (#12059) - -Four text columns that a declared index keys on carried no `maxLength`, so -`driver-sql` emitted them `TEXT`. MySQL refuses a TEXT/BLOB column in a key -without a key length (`ER_BLOB_KEY_WITHOUT_LENGTH`): `CREATE TABLE` succeeds, -`ALTER TABLE … ADD INDEX` fails, and the object lands registered-but-broken -with its declared index silently absent. - -| Object | Column | Bound | Producer the bound is derived from | -|---|---|---|---| -| `sys_activity` | `record_id` | 255 | the physical `id` column — `driver-sql` creates every primary key as `table.string('id').primary()`, knex's `varchar(255)` | -| `sys_audit_log` | `record_id` | 255 | same | -| `sys_audience_binding_suggestion` | `package_id` | 255 | `sys_permission_set.package_id` (255), which the same boot pass writes the same value into | -| `sys_audience_binding_suggestion` | `permission_set_name` | 100 | `sys_permission_set.name` (100), the column this value resolves against at confirm time | - -Each bound is derived from a **named producer** and stated in the declaration -so it is vetoable in review (#11374 route A; PR #12058 is the worked -precedent). None of them narrows anything storable: - -- a record id cannot exceed the `varchar(255)` column the id itself lives in, - and the `referenceVia` seed path refuses an unresolvable pointer rather than - storing a natural key verbatim; -- a permission set name longer than 100 is already refused at the write seam - today — measured on a real engine, `ValidationError: API Name must be ≤ 100 - characters (got 101)` — so no set with such a name can exist, and a - suggestion naming one could never be confirmed. - -Measured at the driver level, shipped declaration vs. the same declaration with -the bounds stripped: `record_id`, `package_id` and `permission_set_name` move -`TEXT` → `varchar(255)` / `varchar(100)`, while `id` reads `varchar(255)` in -both — the transitivity premise, read off a real table rather than assumed. - -Existing deployments are not rewritten: a physical `TEXT` column is deliberately -not diffed against `maxLength` (#11431), so no `ALTER` is planned and no value -at rest is truncated. The repair takes effect where the decision is makeable at -all — at `CREATE TABLE` — because no dialect turns a TEXT column into a keyable -one afterwards. - -Each plugin also gains a keyed-text-bounds pin driven through its **own -registration path** (`init()` → the manifest `register({ objects })` call), -rather than a hand-written object list: the platform-objects pin enumerates only -that package's exports, which is exactly why these four columns escaped route -A's sweep after ADR-0029 K2 moved the objects out. diff --git a/.changeset/plugin-wired-runtime-roster.md b/.changeset/plugin-wired-runtime-roster.md deleted file mode 100644 index 5b95d4f077..0000000000 --- a/.changeset/plugin-wired-runtime-roster.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Add `PLATFORM_PLUGIN_WIRED_RUNTIMES` (and its row type `PlatformPluginWiredRuntime`) to the kernel platform-capability module: a companion provenance roster, keyed by npm package name, for the out-of-repo runtimes that reach the kernel through app `plugins[]` wiring rather than through a `requires` capability token — today `@objectstack/organizations` (loaded by `serve` off the resolved tenancy posture) and `@objectstack/security-enterprise` (which also backs the `hierarchy-security` token). The token-keyed `PLATFORM_CAPABILITY_PROVIDERS` map structurally cannot describe a package that backs no token; this roster makes "is this out-of-repo package real, and where does it ship from?" machine-readable for that population. Provenance only — it adds no capability token, changes no `requires` resolution, and encodes no posture-to-token semantics; drift tests pin the two rosters to agree wherever they name the same package. diff --git a/.changeset/pointer-pair-adoption-five-objects.md b/.changeset/pointer-pair-adoption-five-objects.md deleted file mode 100644 index c967c62cd8..0000000000 --- a/.changeset/pointer-pair-adoption-five-objects.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-audit": minor -"@objectstack/plugin-approvals": minor -"@objectstack/plugin-sharing": minor ---- - -Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. - -The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. - -The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. diff --git a/.changeset/pointer-pair-seed-resolution.md b/.changeset/pointer-pair-seed-resolution.md deleted file mode 100644 index c0ea6591e6..0000000000 --- a/.changeset/pointer-pair-seed-resolution.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor -"@objectstack/plugin-audit": minor ---- - -Seeds can now address an ActivityPointer (#11339, ADR-0052 §5): a `text` field may declare `referenceVia: ''`, marking it as the id half of a polymorphic pointer pair whose target object the sibling column names per row (`sys_activity.record_id` via `object_name`, `source_id` via `source_object` — both now declared). The seed loader resolves such pointers as natural keys against the object each row names — the same externalId probes, in-memory map and pass-2 deferral static lookup references use — so a packaged app's seed can ship timeline rows that actually attach to their records, and the shipped console filter `{ object_name, record_id }` matches them. - -The accept/reject contract changes with it, deliberately: an unresolvable pointer on a DECLARED pair is now a loud, counted failure (`success: false`, `error`-level log, record dropped when no pass 2 can heal it) instead of the old silent verbatim store — a row that rendered on no timeline and matched no filter. Undeclared text columns are untouched: only `referenceVia` opts a pair in. Authoring contradictions are refused at parse time (`referenceVia` is text-only and mutually exclusive with `reference`) and at `ObjectSchema.create` (the sibling must be a declared field). Internal-id-shaped values still pass through verbatim, so seeds wiring real ids keep working. diff --git a/.changeset/publish-sys-activity-builtin-vocab.md b/.changeset/publish-sys-activity-builtin-vocab.md deleted file mode 100644 index f28465dbe9..0000000000 --- a/.changeset/publish-sys-activity-builtin-vocab.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-audit': patch ---- - -Publish the built-in `sys_activity.type` vocabulary from `@objectstack/spec` (#11807). - -`SYS_ACTIVITY_BUILTIN_TYPES` (and the derived `SysActivityBuiltinType` union) is now exported from `@objectstack/spec/data` (`feed.zod.ts`, alongside `FeedItemType`), and the `sys_activity` object declaration in `@objectstack/plugin-audit` derives its `type` options from it — one source instead of a hand-copied list per consumer. - -The constant is the platform's **built-in set, not the column's value domain**: `sys_activity.type` stays an open, author-extensible vocabulary (#11507 ruling — an app may contribute values via `activityMilestones[].type`, ADR-0052 §5b.2, or its own inserts, and undeclared values are stored verbatim). It is deliberately a plain `as const` tuple rather than a `z.enum`, so it cannot be used as a validator; consumers must render unknown values, never drop them. UI packages that hand-copied the list (objectui's feed-kind census, which drifted the day #11522 added `scheduled`) can now read this export instead. diff --git a/.changeset/radio-multiple-refused-at-schema.md b/.changeset/radio-multiple-refused-at-schema.md deleted file mode 100644 index 7e3bb24227..0000000000 --- a/.changeset/radio-multiple-refused-at-schema.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse an authored `radio` + `multiple: true` at the schema layer (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C) - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's -launch-window convention for breaking changes. - -An author could declare `{ type: 'radio', multiple: true }` and the producer -honoured it everywhere the widget could not: the data layer stored an array, -validated it as multi, split it on import and inferred multi arity for action -params, while the one renderer `radio` has draws a single-value radio group -with zero diagnostics. Declared multi, rendered single — the contradiction sat -inside `packages/spec` itself, where `SINGLE_OPTION_TYPES` calls `radio` -single-choice on one line and `MULTI_CAPABLE_TYPES` carries it on another -because it "shares the select branch". - -Per the maintainer ruling recorded 2026-08-22 on objectui#4015 (Option C, -「接受所有」), `FieldSchema` now refuses the authored combination at parse -time — the seam every publish crosses, both for a standalone `field` document -and for fields embedded in an `ObjectSchema` — with a diagnostic that names -the field, names the illegal pair, and prescribes the correctly-named -multi-choice types: `checkboxes` (all options visible, radio-like layout), -`multiselect` (dropdown) and `tags` (free-form values). - -**What stays accepted, byte-identically:** `radio` without `multiple` -(including its materialized `multiple: false`), `radio` with an authored -`multiple: false`, `select`/`lookup`/`user`/`file`/`image` with -`multiple: true`, and the inherently-multi types with or without the redundant -flag. Because `multiple` materializes `.default(false)`, the refusal can only -ever fire on an authored `true` — a defaulted value never trips it, and -`parse(parse(x))` stays stable (pinned). - -**What is deliberately untouched, per the same ruling:** `MULTI_CAPABLE_TYPES` -and `isMultiValueField` in `field-value.zod.ts` keep `radio`, so data at rest -that was written under the old contract keeps its read path and no -stored-shape migration is paid — that is the whole reason Option C was -preferred over narrowing the sets themselves. A test pins `radio`'s membership -so a future cleanup trips loudly. `packages/objectql`'s record-validator -select/radio branch likewise stays, as a data-safety fallback for stock. - -Measured before landing (both repos, examples/docs/fixtures/tests included): -21 `type: 'radio'` declarations, none carrying `multiple: true` — the refused -combination has zero occurrences, so no existing metadata is invalidated. The -ruling attaches an explicit flip condition: if deployed tenant metadata -carrying the combination with stored data is ever found, entrance rejection -alone would strand it and the set-narrowing option becomes required. - - diff --git a/.changeset/react-tier-vocab-converge.md b/.changeset/react-tier-vocab-converge.md deleted file mode 100644 index 7ffe742257..0000000000 --- a/.changeset/react-tier-vocab-converge.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor ---- - -React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). ``'s canonical bindings are now the spec ListView schema's own props: `data={{ provider: 'object', object: '…' }}` for the object binding (objectui#2890 A6) and `type` for the visualization kind. `objectName` and `viewType` remain published and accepted as deprecated aliases for the whole deprecation window — nothing is removed in this release — with the deprecation visible at authoring time: `[DEPRECATED → …]` markers in the generated react-blocks contract, and a new `react-prop-deprecated` lint warning (never an error) on every use of a deprecated spelling. The lint accepts either spelling as satisfying ``'s required binding and resolves field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are. `` / `` `objectName` are unchanged: the form's spec counterpart is explicitly not 1:1 (objectui#2890 Scope B), and the chart has no metadata-tier object binding to converge on (charts bind through a dashboard `dataset` there — see chart.zod.ts guidance). Removal of the deprecated aliases is a later card after the deprecation window. diff --git a/.changeset/record-activity-types-open-vocabulary.md b/.changeset/record-activity-types-open-vocabulary.md deleted file mode 100644 index af2e272115..0000000000 --- a/.changeset/record-activity-types-open-vocabulary.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): open `RecordActivityProps.types` to author-contributed activity kinds (#11658) - -Accept-set **widening** on a published authorable prop, executing the -2026-08-24 maintainer ruling on #11507: `sys_activity.type` is an OPEN, -author-extensible vocabulary — the declared options are the platform built-in -set, ADR-0052 §5b.2 stays a sanctioned author write path, and (verbatim) -"every closed map over this vocabulary is now the bug". - -`RecordActivityProps.types` (the `record:activity` filter, also embedded as -`RecordChatterProps.feed.types`) was that closed map on the authoring surface: -`z.array(FeedItemType)`, a closed enum of the 13 built-in UI kinds, so an -author who contributes an activity type (e.g. `scheduled`, which hotcrm writes -today through the sanctioned ADR-0052 §5b.2 channel) could not name it in the -filter. The element is now `z.union([FeedItemType, z.string().min(1)])`: the -enum branch keeps the built-in kinds visible as guidance (editor autocomplete, -and an `anyOf` member of the generated JSON Schema) while the open-string -branch accepts any author-contributed kind — the union accepts exactly what a -bare non-empty string accepts, so nothing is validated against the built-in -set. A typo'd built-in consequently no longer gets a named rejection; the -ruling accepted that cost rather than re-close the vocabulary. - -Every previously-legal value still parses byte-identically; non-string and -empty entries are still rejected. The `feed.zod.ts` module docblock (and the -generated `feed.mdx` reference page) no longer claims these enums have "no -backend dependency": `FeedItemType` is the target of the map UI consumers -apply to the open `sys_activity.type` column — a backend coupling, not a -backend import. - - diff --git a/.changeset/record-alert-visiblewhen-migration.md b/.changeset/record-alert-visiblewhen-migration.md deleted file mode 100644 index 92799566ac..0000000000 --- a/.changeset/record-alert-visiblewhen-migration.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -Move both authored `record:alert` gates off `properties.visible` onto the -component-node `visibleWhen`, `has()`-guarded and served as a CEL envelope -(#9167) — the `sys_user` detail page's "Email not verified" banner, and the -showcase Task Detail page's "Awaiting review" banner. - -`record:alert` is the one record component that declares a props-level -`visible` predicate, but `PageComponentSchema.properties` is an opaque record: -the bag is served verbatim, so a bare string in `visible` never reaches -`ExpressionInputSchema` and is evaluated by the console's **legacy JS** -evaluator, which has no `has()`. The node-level `visibleWhen` declared at -`page.zod.ts:189` *is* an `ExpressionInputSchema`, so a page that goes through -the spec's transform serves `{ dialect: 'cel', source }` and runs on CEL — the -same engine, and the same `has()` semantics, every other predicate face was -migrated to. - -Three properties of that move were measured in a real console at the pinned -objectui SHA rather than reasoned about, and all three are load-bearing: - -- The `visible` key is **deleted**, not left beside the new gate. A node - `visibleWhen` and `properties.visible` compose as **AND**, so keeping both - would leave the legacy predicate load-bearing and make the migration - cosmetic. -- The `has()` guards are **mandatory**. On the CEL face an absent key is a - *fault*, and that face is fail-soft: measured, an unguarded gate with its key - stripped from the read left the banner VISIBLE, where the guarded gate hid - it. -- On `sys_user` the predicate is authored through `P` so it reaches the wire as - a CEL **envelope**. `SysUserDetailPage` is a raw `Page` object literal, so — - unlike a page built with `definePage()` — nothing normalizes it, and the - renderer keeps bare strings on the legacy path by design. Measured: the bare - form left "Email not verified" showing on *every* profile, including other - people's; the envelope restores every polarity. - -Behaviour for real users is unchanged in every polarity measured — a `todo` -task hides the banner and an `in_review` task shows it; a verified user hides -"Email not verified", an unverified user viewing their own profile shows it, -and another user's profile shows nothing. What changes is that a genuine fault -is now **loud** (CEL names the missing key) instead of silently answering -`false`, and that both predicates sit on the declared slot the platform teaches -everywhere else. diff --git a/.changeset/record-details-headercolor-enum-12126.md b/.changeset/record-details-headercolor-enum-12126.md deleted file mode 100644 index a3be628a8d..0000000000 --- a/.changeset/record-details-headercolor-enum-12126.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Declare `headerColor` on the strict `record:details` section schema as a closed enum (#12126, maintainer ruling A 2026-08-26). The key was deliberately refused by #11661 because the renderer's only read was a template-literal Tailwind class that generated no CSS; objectui#6294 (merged 2026-08-25) replaced that read with a lookup of complete class literals, so the refusal outlived its recorded reason. The vocabulary is exactly the six tokens that lookup ships — `muted` | `muted/50` | `accent` | `primary/10` | `secondary/10` | `destructive/10` — tints only (`CardHeader` sets no foreground, so solids would need a paired `text-*-foreground`). Declared = enforced: `objectstack validate` refuses anything outside the enum at authoring time — including the renderer's `bg-*` pass-through spellings, which render only when the host app's Tailwind build happens to generate the class — instead of shipping a header that silently does not paint. Optional with NO schema default; the `title` withhold from #11661 stands unchanged. diff --git a/.changeset/record-details-section-presentation-keys-11661.md b/.changeset/record-details-section-presentation-keys-11661.md deleted file mode 100644 index 3138bd250d..0000000000 --- a/.changeset/record-details-section-presentation-keys-11661.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Declare three more `record:details` section keys the renderer has honoured all along (#11661, inheriting the #11289 ruling): `defaultCollapsed` (start a `collapsible: true` section collapsed; renderer default expanded), `icon` (heading icon, lucide name; non-identifier values render as literal text) and `description` (plain-string sub-heading under the section heading). All three were refused by the strict section schema, so `objectstack validate` warned an authored key "did nothing" while the renderer read it. All three are optional with NO schema defaults — the fallbacks stay the renderer's. The same measurement's `title` and `headerColor` deliberately remain refused: `title` is a second spelling of the heading slot `label` declares (held for a declare-vs-converge ruling), and `headerColor` only reaches the DOM as a template-literal Tailwind class that generates no CSS (dead-in-practice; reported as an objectui finding). diff --git a/.changeset/record-details-section-presentation-keys.md b/.changeset/record-details-section-presentation-keys.md deleted file mode 100644 index e3acae611c..0000000000 --- a/.changeset/record-details-section-presentation-keys.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Declare `hideEmpty` / `collapsible` / `showBorder` on `record:details` -sections — the three keys objectui's renderer has honoured all along - -`RecordDetailsRenderer` spreads every authored section through to -`DetailSection`, which reads all three — but the strict section schema -declared only `name` / `label` / `columns` / `fields`, so -`objectstack validate` warned that an authored key "did nothing". For -`hideEmpty` the warning hid the one key that decides whether a section -exists at all: the renderer forces `hideEmpty ?? true`, and a section whose -fields are all empty then renders nothing — no heading, no skeleton — with -no declarable spelling to ask the skeleton back (a freshly created record -losing two of its three authored sections is how this surfaced). - -Accept-set widening only; the renderer is unchanged (maintainer ruling -2026-08-23, direction 1). All three keys are optional with **no schema -default** — the fallbacks are the renderer's, and the describe() texts -state them as measured at the `.objectui-sha` pin: `hideEmpty` on; -`collapsible` off; `showBorder` derived (on for a titled section, off for -an untitled one). `hideEmpty: false` now keeps a section's label skeleton -on an all-empty record, and schema and runtime finally say the same thing. diff --git a/.changeset/redshift-connect-timeout-bound.md b/.changeset/redshift-connect-timeout-bound.md deleted file mode 100644 index 168adbeefd..0000000000 --- a/.changeset/redshift-connect-timeout-bound.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -A `redshift` datasource now gets the 10s dialect connect-timeout bound instead of silently degrading to the 15s pool backstop (#11784). `SqlDriver` answers three separate questions about a knex `client` name from three separate tables, and `redshift` was a member of the wire-protocol one (`POSTGRES_WIRE_CLIENTS`, which #11389 put it in so it gets the calendar-day parser pin) while absent from `DIALECT_CONNECT_TIMEOUT`. It reaches the server through the `pg` driver — knex's `Client_Redshift` literally `extends Client_PG` — so it has `connectionTimeoutMillis` and would have obeyed it; it just never received it, and `withConnectBound` skipped the injection. Nothing errored and nothing was logged: the bound was simply 50% looser than the method's own docblock declares ("the effective bound" at 10s, with `pool.createTimeoutMillis` a "strictly looser backstop, reached only by a dialect that has no connect-timeout knob (SQLite) or ignores the one we set"). A `redshift` host is neither of those. The practical consequence is the framework#3769 failure shape — an endpoint that accepts the TCP connection and never completes the handshake makes every query WAIT rather than fail, and the wait was bounded 5s later than declared, with knex's inaccurate "the pool is probably full" wording instead of pg's `timeout expired`. A host that sets its own `connectionTimeoutMillis` or `pool.createTimeoutMillis` is still left alone. `redshift` gains **no** SQL-emission identity from this: the connect-timeout knob is a property of the npm driver doing the connecting, not of which DDL dialect gets compiled, so this is independent of the open support-scope decision (#11756). diff --git a/.changeset/registry-collision-order-symmetric.md b/.changeset/registry-collision-order-symmetric.md deleted file mode 100644 index ed9a084abd..0000000000 --- a/.changeset/registry-collision-order-symmetric.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the `[Registry] Collision` warning fires in the cold-boot order too (#12027) - -The artifact-vs-DB collision warning was order-asymmetric, and silent in the -order a kernel boot actually produces. It was guarded on `packageId &&`, so it -spoke only when the PACKAGE registered second — but the artifact reaches the -registry in kernel Phase 1 (`AppPlugin.init` -> `manifest.register`) and the -`sys_metadata` overlay is rehydrated in Phase 2 (`ObjectQLPlugin.start` -> -`loadMetaFromDb`), under the bare name with no package id. The kernel runs -init-all then start-all, so at boot the overlay is ALWAYS the second arrival — -the exact order the guard excluded. The direction that did warn is the -late-registration one: a marketplace install, a post-`start()` -`manifest.register`, an HMR reload. - -The consequence is worse than a missing line, because the mechanism looked -sound to anyone who had seen it work: ADR-0005 says this warning is what makes -the silent shadowing "discoverable in startup logs", and in the only order -startup produces it was not discoverable at all. Measured on a real -`@objectstack/example-crm` boot before the fix: one stored `view` overlay of a -packaged view produced 0 collision lines and 4 silent shadowings (the container -plus its three expanded ViewItems). - -The cold-boot direction now warns with its own message rather than a widened -version of the existing one. Both orders end in the same state — the runtime -row wins either way — but the event differs, and the event is what an operator -acts on: a package that is dead on arrival behind a row that predates it, -versus a stored row taking over a definition this process just loaded from -code. Which definition wins is unchanged in both orders, and pinned as such. - -Graded `patch`: this adds a diagnostic to a path that printed nothing. No API -changes, no accept/reject behaviour changes, and resolution order is untouched. -The one operator-visible effect worth stating is the log itself — a deployment -that customizes packaged metadata will see one new `[Registry] Collision` line -per shadowed name per process, where it previously saw none. Volume was -measured rather than assumed: 0 lines on a stock boot (a stock `sys_metadata` -holds no overlay of a packaged name), and the line marks the transition into -the bare slot rather than the state, so the read-side hydration and the -write-through do not re-emit it on later reads and writes. diff --git a/.changeset/report-export-column-inference-all-rows.md b/.changeset/report-export-column-inference-all-rows.md deleted file mode 100644 index 5864cd70c4..0000000000 --- a/.changeset/report-export-column-inference-all-rows.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/plugin-reports": patch ---- - -Report CSV and HTML exports now infer their columns from **every** row of the result set, not from the first 50. Column inference sampled `rows.slice(0, 50)` while the projection it produced was applied to all rows, so when a report declared no explicit `query.fields`, any key whose first occurrence fell at row 51 or later was absent from the header *and* dropped from every row that carried it. The export gave no signal: the CSV was well-formed, every row had the same arity, and nothing marked a column as inferred rather than declared, so a recipient of a scheduled report attachment could not tell. Sparse columns are the normal shape of report output — an optional field, a formula only some records satisfy, a lookup that resolves for a subset — and the sampled prefix is the query's first page in its own `orderBy`, so for a report sorted by status or created date the sample correlated with exactly the column it dropped. Both affected renderers are fixed (`csv`, which is also the `default:` format branch, and `html_table`); `json` was never affected. Already-inferred columns keep their position and late-appearing ones are appended, so an export that was correct before is byte-identical now. diff --git a/.changeset/rest-api-config-parsed-not-cast.md b/.changeset/rest-api-config-parsed-not-cast.md deleted file mode 100644 index cd31dd81e4..0000000000 --- a/.changeset/rest-api-config-parsed-not-cast.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -**BREAKING (accept-set tightening)**: `RestServer` now parses `config.api` -against `RestApiConfigSchema` at construction instead of casting to it, so a -deployment whose `api` config the spec rejects fails loudly at boot rather than -booting into a structurally broken URL space (#11637). - -The regex was always declared. `packages/spec/src/api/rest-server.zod.ts` -constrains `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v1')`, and -`version` is spliced into `getApiBasePath()` — the base of **every** route this -server mounts. Nothing ran it: both hops into `@objectstack/rest` are casts -(`config.api as any` in `rest-api-plugin.ts`, then `as Partial` -in `normalizeConfig`), the plugin declares no `configSchema`, and the kernel's -`PluginConfigValidator` could not have covered it either — `PluginLoader` -invokes its own `validatePluginConfig(metadata)` with **no config argument** and -returns early, and `createRestApiPlugin` closes over its config so the kernel -never receives it. `??` was the only guard left, and `??` substitutes -`null`/`undefined` only. Measured on the pre-fix code: `api.version: ''` -constructed happily and mounted the whole API — `/data`, `/meta`, `/discovery`, -`openapi.json` — under `/api//`. - -**Newly refused, all at `new RestServer(...)` / `createRestApiPlugin().start()`:** - -- `api.version: ''` — the reported case. Refused with - `Invalid string: must match pattern /^[a-zA-Z0-9_\-\.]+$/`. -- `api.version` carrying any character outside `[a-zA-Z0-9_-.]` — `'v1/beta'` - (which spliced an extra path segment into every route), `'v1 beta'`, `'v1%2F'` - and so on. -- A declared key written with the wrong type: `api.enableCrud: 'yes'`, - `api.basePath: 42`, a malformed `api.documentation` / `api.responseFormat`. - -**Deliberately NOT refused** — the narrowing is exactly what the schema -declares, and no more: - -- `api.projectResolution`. The declared enum is - `z.enum(['required', 'optional', 'auto'])`, but the value this platform - actually ships is **`'none'`**: `@objectstack/runtime`'s - `StandaloneStackResult.api` declares the literal type - `{ enableProjectScoping: false; projectResolution: 'none' }`, and `os serve` - forwards it into this config unchanged (`apiConfig.projectResolution ?? 'auto'` - does not fire — `'none'` is not nullish). Three packages disagree about this - key's vocabulary and have done so silently for exactly as long as nothing ran - the schema. Parsing it here would not settle that disagreement, it would turn - every `os serve` boot into a crash, so the key is `.omit()`ed and the - divergence is filed as #11999. Which spelling wins is a contract question - about project-scoping semantics that this seam cannot answer. -- `api.requireAuth`. The retired key (#3963) is `.omit()`ed from the - validation: it keeps the warn-and-ignore posture `rest-api-plugin.ts` gives - it, and `tsc` still refuses it at any typed authoring site. Converting that - warn into a boot failure is #3963's decision to make, not this seam's. -- Keys no schema in `packages/spec` declares, `api.enableSearch` first among - them. The parse is run for its verdict only and its output is **discarded** — - `RestApiConfigSchema` is not `.strict()`, so a non-strict `z.object()` strips - what it does not declare, and consuming the parsed value would have silently - turned search back on for a deployment that turned it off. -- `api.basePath: ''` — a bare `z.string()` with no declared constraint stays - accepted. -- `crud`, `metadata`, `batch` and `routes`. Those sub-objects are still cast, - not parsed, and carry unenforced constraints of their own - (`batch.maxBatchSize: z.number().int().min(1).max(1000)`, the - `routes.nameTransform` enum). Same defect class, filed separately — this - change deliberately puts one narrowing in front of contract review, not five. - -**Migration.** Delete or correct the offending key; the refusal names the path, -the declared rule that rejected it, and why an empty version is not survivable. -A deployment that meant "no version segment" wants `api.apiPath: '/api'`, which -sets the base outright and is unconstrained. - -**In-repo blast radius, measured repo-wide.** The census is mechanical, not a -reading: 173 files scanned, 316 `api: { … }` blocks brace-matched, every scalar -literal written at each of the 14 declared keys parsed against the schema this -seam runs. **Two values are refused, and both are the deliberate `''` cases in -this change's own pin file.** Every other in-repo literal is accepted, including -all four `projectResolution` spellings in use and the 96 fixtures carrying the -retired `api.requireAuth`. Nested `documentation` / `responseFormat` literals -exist only in `packages/spec`'s own schema tests, which never construct a -server. Of the 237 construction sites repo-wide, exactly one feeds computed -values (`os serve`), traced to the typed literal in `@objectstack/runtime` -above. One in-repo pin had to be retired: `rest-openapi-route.test.ts`'s falsy -`api.version` case, which carried its own written instruction to retire if -normalization ever started rejecting it, replaced here by a pin on the refusal. - -⚠️ The first version of this census was scoped to `packages/rest` and missed the -five `packages/cli` e2e boots that go through `os serve`; CI caught it. The -radius that matters is every package that CONSTRUCTS a REST server, not the -package the change lives in. - - diff --git a/.changeset/rest-discovery-version-producer.md b/.changeset/rest-discovery-version-producer.md deleted file mode 100644 index faaf150451..0000000000 --- a/.changeset/rest-discovery-version-producer.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -`GET /api/v1/discovery` reports the serving artifact's version instead of the -URL path segment the caller just typed - -`registerDiscoveryEndpoints` called the producer and overwrote the answer one -line later: - -```ts -const discovery = await protocol.getDiscovery(); - -// Override discovery information with actual server configuration -discovery.version = this.config.api.version; -``` - -`config.api.version` is the **API version identifier**, not an artifact -identity. `normalizeConfig()` defaults it to `'v1'`, `packages/spec`'s -`plugin-rest-api.zod.ts` describes it as "API version identifier", and the same -value builds the mount — `getApiBasePath()` returns -``api.apiPath ?? `${api.basePath}/${api.version}` `` → `/api/v1`. So on every -REST-served host, `GET /api/v1/discovery` answered `version: "v1"`: the segment -the caller had already typed to reach the endpoint, on every build of every -release, forever. - -`DiscoverySchema` declares `version` under **System Identity**, grouped with -`name` and `environment` — the "what server is this" question. The #10993 -ruling settled that reading and #11235/#11242 reaffirmed it. The override is -now gone and the producer's derived value reaches the wire. - -**What changes on the wire.** `version` on this endpoint was `"v1"` and is now -the value `getDiscovery()` derives: `OS_RUNTIME_VERSION` when a deployment or -build pipeline stamps one, else the resolved `@objectstack/metadata-protocol` -package version, else `"unknown"`. That is the same stamp `/health` and the -runtime dispatcher's own `/discovery` already read, so the two discovery -producers now give one answer rather than two dialects of one field. Before -#11297 this override masked two producers that genuinely disagreed (`'1.0.0'` -vs `'1.0'`); after it, it was overwriting a value that already agreed. - -**The API-version fact is not lost.** Every entry in the same document's -`routes` is prefixed with the mounted base path, which is built from -`api.version` — recoverable from the same response, in the field that means it. -No schema change, no new field: the accept set and the public surface are -unchanged, and `api.version` still does its real job of building the mount. - -Pinned in `packages/rest/src/discovery-schema-conformance.test.ts`, which drives -the **real** producer through the **real** handler. The assertions pin -provenance, never a literal version string — a stamp injected by the test must -appear on the wire, and the served value must equal what the producer answers -when called directly, including on a server configured with a different -`api.version` (where `routes.data` is asserted to still carry that segment). A -pin spelling a literal would rot at the next release. diff --git a/.changeset/rest-meta-audit-reset-casts-retired.md b/.changeset/rest-meta-audit-reset-casts-retired.md deleted file mode 100644 index 77831ac848..0000000000 --- a/.changeset/rest-meta-audit-reset-casts-retired.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -refactor(rest): the audit and reset door call sites are compiled against the declared contract (#11678, #11679) - -The `GET /meta/:type/:name/audit` and `DELETE /meta/:type/:name` doors in -`packages/rest/src/rest-server.ts` reached their protocol methods through -`(p as any)` — once for each feature-detection guard, once for each call — so -the compiler checked nothing about the request literals they built. The two -casts were load-bearing in opposite ways, both measured: the audit door's on -**member existence** (`auditMetaItem` was undeclared in `packages/spec` -entirely — removing the cast answered `TS2339`), the reset door's on **request -shape** (`deleteMetaItem` was declared, but its request schema carried 2 of the -8 members the door sends — removing the cast answered `TS2353` on six keys). - -With `MetadataProtocol.auditMetaItem` declared and -`DeleteMetaItemRequestSchema` caught up (the spec half of this landing), the -guards are now `typeof p.auditMetaItem !== 'function'` / `if (!p.deleteMetaItem)` -and each request is a named const typed against the spec contract — the reset -door through `TransportScopedMetaRequest` (it still -spreads the transport-level `environmentId`, which stays layered on by the -#9741 envelope rather than becoming a protocol key), the audit door as a plain -`AuditMetaItemRequest` (it stopped sending `environmentId` when #8747 scoped -the read, so there is no transport member left to layer on). - -**No behaviour change of any kind, and nothing about the wire moves.** The -outgoing payloads are byte-identical (same keys, same conditional spreads); the -edits hoist each literal into a const and drop type-level casts. The 501 -feature-detection guards survive on purpose: both members are declared -**optional** (a kernel may implement neither door), and each guard is also what -narrows its member to callable at the call site. An undeclared key in either -literal is now a compile error instead of a payload member no contract has ever -seen. diff --git a/.changeset/rest-package-id-registry-read-refusal.md b/.changeset/rest-package-id-registry-read-refusal.md deleted file mode 100644 index f8188a1bcc..0000000000 --- a/.changeset/rest-package-id-registry-read-refusal.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -Stop answering a terminal `404 RESOURCE_NOT_FOUND` for a REGISTRY read that -could not happen on `GET /api/v1/packages/:id` - -The detail door tries the durable `sys_packages` read first and falls back to -the in-memory registry via `protocol.getMetaItems({ type: 'package' })`. That -fallback sat in a bare `catch {} // Protocol unavailable`, so when the read -threw, control fell straight through to the line below and the door answered -**`404 RESOURCE_NOT_FOUND` — `Package "" was not found.`** - -This is the worse half of the family, not a smaller one. The list door's -version of the same swallow (#11130) answered a `200` whose `total` -under-counted; this one answers a terminal negative fact. `404` / -`RESOURCE_NOT_FOUND` is not "the answer may be incomplete", it is *"this package -does not exist"*, and callers act on it: an installer decides the package is not -installed and offers to install it, a console hides the entry, a script branches -to the create path. The producer's own words for the same condition are the -opposite — *"whether this item exists is unknown"*. - -It was also #5532's defect resurfacing one layer up. -`ObjectStackProtocolImplementation.getMetaItems` was taught **not** to report an -unreadable `sys_metadata` as "that item does not exist"; this consumer-side -catch then re-applied precisely that relabelling to the protocol's answer. So -the producer already declares the refusal — every non-benign overlay read -failure leaves as `SERVICE_UNAVAILABLE` / 503 with an ADR-0112 status+code on -the error — and the repair is the same one #11063 made for this door's durable -half and #11130 made for the list door's registry half: delete the catch and let -`sendThrownError` carry the producer's own status and code. - -Standing family ruling — #10965 · #10677 · #10789 · #11063 · #11130: **a read -that could not happen must not be reported as a read that found nothing.** - -Unchanged, and pinned in both directions because the defect was that a failed -read and an absent resource were indistinguishable: a genuine miss (both sources -read fine, neither holds the id) still answers `404 RESOURCE_NOT_FOUND`; a -composition with no protocol service is an absence rather than a failed read and -still reaches that same 404; a registry hit still answers `200` with -`source: 'registry'`; and a durable hit still answers `200` without consulting -the registry at all. No wire field is added — the response shape is a contract -decision this change does not carry. diff --git a/.changeset/rest-package-list-registry-read-refusal.md b/.changeset/rest-package-list-registry-read-refusal.md deleted file mode 100644 index 40d6e2a5ce..0000000000 --- a/.changeset/rest-package-list-registry-read-refusal.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**Fix:** `GET /api/v1/packages` no longer absorbs a failed **registry** read into a `200` — the other half of the two-source merge #11063 closed (#11130). - -The list door merges two sources: the in-memory registry, read through `protocol.getMetaItems({ type: 'package' })`, and the durable `sys_packages` rows, read through `PackageService.list()`. #11063 stopped the door swallowing a failure of the durable half. The registry half still carried its own bare `catch {}` commented *"Protocol unavailable — continue with database only"*, so the exact ambiguity #11063 closed stayed open on the other source: when `getMetaItems` threw, the door answered `200` with `{ packages, total }` built from the database alone, and `total` was presented as a **complete count** either way. The surviving entries kept `source: 'database'`, which reads as provenance rather than as a warning that the registry half is absent — nothing on the wire separated *"these are all the packages"* from *"these are the packages I could still see"*. Same standing family ruling as its sibling — #10965 · #10677 / PR #10788 · #10789 / PR #10964 · #11063: **a read that could not happen must not be reported as a read that found nothing.** - -**The producer already declared its refusal, so this is #11063's edit rather than a new posture.** The card left open whether a *present* `getMetaItems` even has a reachable throw. Measured on the live implementation — `ObjectStackProtocolImplementation` in `packages/metadata-protocol`, the object `MetadataProtocolPlugin` registers as the `protocol` service and the one `RestApiPlugin` hands to this registrar: it routes every non-benign `sys_metadata` overlay read failure through `rethrowUnlessMetadataStoreUnprovisioned` → `metadataStoreUnavailableError`, i.e. `SERVICE_UNAVAILABLE` / 503 with an ADR-0112 status+code **on the error** (#5532) — the same envelope #10965 gave `PackageService.list()`. Driving it with the door's own request answers `THREW status=503 code=SERVICE_UNAVAILABLE` on an `ECONNREFUSED` store. So route (b)'s first leg was already landed and only the second remained: stop swallowing. - -**What changes on the wire.** A registry read that refuses now reaches the client as the producer's own `503` / `SERVICE_UNAVAILABLE` through the declared envelope, instead of a `200` whose `total` lied. A registry read that throws something *undeclared* is answered as the `500 INTERNAL_ERROR` a fault deserves, instead of being eaten. Both were previously indistinguishable from a healthy, complete listing. - -**What deliberately does not change.** An **absent** protocol service is a different, already-handled case — the `if (options.protocol && typeof … === 'function')` guard is untouched, so a composition without the protocol service still answers `200` with the durable half alone. First boot is unaffected: `sys_metadata` not being provisioned yet is not a throw on that path (`isMissingTableError`), it is an empty overlay. The healthy merge — two sources, `source: 'registry' | 'database' | 'both'`, a truthful `total` — is byte-identical. The durable half is untouched (#11063 landed it), and so is the dispatcher twin in `packages/runtime/src/domains/packages.ts`, which serves a single in-memory read with nothing of this shape. - -**No response field is added.** The card's third shape — keep the `200` and make the tolerance visible with a partial-result marker — is a response-shape change, i.e. a contract decision, and was not authorized here. - -Pinned by `packages/rest/src/package-list-registry-read-refusal.test.ts`, which asserts the mechanism rather than "the door still answers": the status **and** the declared `code`, that no `total` is reported over a read that failed, that both halves of the one merge answer the same outage identically, and — as the overreach guards — that an absent protocol service and a healthy merge are unmoved. On the pre-fix tree 4 of its 6 cases fail, every one of them reading `200` where a refusal was owed. diff --git a/.changeset/rest-publish-meta-item-cast-retired.md b/.changeset/rest-publish-meta-item-cast-retired.md deleted file mode 100644 index 24fd68916c..0000000000 --- a/.changeset/rest-publish-meta-item-cast-retired.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -refactor(rest): the `publishMetaItem` call site is compiled against the declared contract (#11145) - -The `POST /meta/:type/:name/publish` door in `packages/rest/src/rest-server.ts` -reached its protocol method through `(p as any).publishMetaItem` — once for the -501 feature-detection guard, once for the call — so the compiler checked nothing -about the request literal it built. The cast was load-bearing on **member -existence**, not on request shape: #10350 measured that deleting it answered -`TS2339: Property 'publishMetaItem' does not exist on type 'RestProtocol'`, not -a `TS2353` about an unknown key. `publishMetaItem` was an ADR-0076 D9 -server-only extension, so no amount of widening the implementation's own -parameter type in `@objectstack/metadata-protocol` (which this package -deliberately does not depend on) could have retired it. - -#11006 (maintainer ruling 2026-08-22, option B) declared the member on -`MetadataProtocol` with a `PublishMetaItemRequest`, which is what removes the -prop. The guard is now `if (!p.publishMetaItem)` and the request is a named -const typed `TransportScopedMetaRequest` — the same -shape #9741 gave the meta-read doors and #9805 gave the non-door helpers. - -**No behaviour change of any kind, and nothing about the wire moves.** The -outgoing payload is byte-identical (same keys, same conditional spreads); the -edit hoists the literal into a const and drops a type-level cast. Two things -deliberately survive: - -- the 501 feature-detection guard, because the declared member is **optional** - (a kernel may not implement the promotion door at all) — and it is also what - narrows the member to callable at the call site; -- the transport-level `environmentId`, which stays layered on by the - `TransportScopedMetaRequest` envelope rather than becoming a protocol key, per - the #9741 ruling (2026-08-18). - -What the typing buys, measured rather than asserted: an undeclared key in this -request literal is now `TS2353` at compile time instead of a payload member no -contract has ever seen. The docblock that existed only to explain why the cast -had to stay is replaced rather than left behind — a rationale for a prop that no -longer exists is a declaration that outlived its subject. diff --git a/.changeset/retire-auth-scim-provider-schema.md b/.changeset/retire-auth-scim-provider-schema.md deleted file mode 100644 index 5f76c96322..0000000000 --- a/.changeset/retire-auth-scim-provider-schema.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -**BREAKING (public export removed):** `AUTH_SCIM_PROVIDER_SCHEMA` no longer exists. It was a `scimProvider` column mapping exported from `@objectstack/plugin-auth` (via `export * from './auth-schema-config.js'`) that nothing ever read — one repo-wide hit, its own declaration — and that nothing ever could: `@better-auth/scim` hardcodes its model and exposes no `schema` option, still true of the installed `@better-auth/scim@1.7.0-rc.1`, whose `SCIMOptions` declares no `schema`, `modelName` or `fields` member at all. Its sibling constants in the same file are genuinely passed to their plugins; this one had nowhere to go, by construction and by its own doc comment. - -Removed under ADR-0049 enforce-or-remove, because it was a **second source of truth** for the same four column names. The load-bearing one is the adapter layer — `AUTH_MODEL_TO_PROTOCOL` plus the mechanical camelCase-to-snake_case field resolution in `objectql-adapter.ts`, over the `sys_scim_provider` platform object that declares the columns — and it is pinned by the dedicated sso/scim block in `better-auth-schema-parity.test.ts`. A dead copy is worse than none: nothing fails when it drifts from the live names, and the next reader cannot tell which of the two is authoritative. A comment in its place records why no such mapping exists and what owns the names instead, so it is not re-added. - -Behaviour is unchanged. No SCIM column name, platform object, adapter mapping or wire shape moves. - -Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`). - - diff --git a/.changeset/retire-compound-name-metadata-addressing.md b/.changeset/retire-compound-name-metadata-addressing.md deleted file mode 100644 index 37f58522bd..0000000000 --- a/.changeset/retire-compound-name-metadata-addressing.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': minor -'@objectstack/client': minor ---- - -Retire compound-name metadata addressing (`/meta/:type/:section/:name`) - -Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. -Stage 1 declared the item-name grammar and refuses every slash-bearing name at -the publish door, so the routes removed here addressed only names that can no -longer be created. - -**BREAKING — three public REST routes stop answering:** - -| stops answering | use instead | -| :-- | :-- | -| `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | -| `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | -| `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | - -Each retired route folded its `:section` and `:name` segments back into one -slash-bearing key (`views/all_leads`) that the protocol layer then treated as a -single opaque string — the section half was never stored, filtered or -enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. - -The `@objectstack/runtime` dispatcher stops folding in the same way: its -`/meta` handler requires exactly two path segments for an item and three for -`…/published`, instead of re-joining every trailing segment. A `/meta` path -that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than -falling through to the adapter's anonymous 404. - -**FROM → TO for callers.** Address every item through the single-segment route -and percent-encode the name: - -``` -GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads -``` - -`@objectstack/client` now calls `encodeURIComponent` on every `/meta` item -address, so SDK callers need no change: the SDK already sends the new spelling. -Encoding is a **no-op** for every name the item-name grammar admits (lowercase -snake_case segments, optionally dot-qualified), so the bytes on the wire are -unchanged for every name that can be written today. - -A pre-grammar **residue** row whose stored name contains a slash remains -readable, writable and deletable: `%2F` matches the single-segment pattern and -the parameter is decoded back to the stored spelling before the handler runs. -Nothing that could be stored has become unaddressable. - -Two SDK doc comments that promised "compound names pass through unencoded" -(`meta.getPublished`, `meta.publishItem`) are corrected, and the -`SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored -at the compound door and published live — is closed at the source: there is one -door, and it reads every member of the options bag. - - diff --git a/.changeset/retire-lookup-fk-reference-to-branch.md b/.changeset/retire-lookup-fk-reference-to-branch.md deleted file mode 100644 index 776c2fe259..0000000000 --- a/.changeset/retire-lookup-fk-reference-to-branch.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): retire the lookup FOREIGN KEY branch gated on the rejected alias `reference_to`, and refuse the key instead of honouring it (#11567) - -`SqlDriver.createColumn` emitted `table.foreign(name).references('id')` for a -relationship field carrying `reference_to`. `reference` is the only relationship -spelling `@objectstack/spec` declares — `reference_to` is a **rejected alias**, -answered by `FieldSchema` with `unrecognized_keys` and *"Did you mean -`reference_to` → `reference`?"* — so that branch could not fire for any -spec-conformant lookup, and never had. - -**This is not a behaviour change for any authored deployment.** Measured across -all 44 exported platform objects on live PostgreSQL 16.13 and MySQL 8.0.46 -before the change: **0** FOREIGN KEY constraints. `reference_to` has zero -non-test assignments repo-wide; the branch was reachable only by metadata that -went around Zod through raw `registerObject` (which deliberately skips it). - -What changes is that the driver no longer disagrees with the spec in silence. A -field still carrying `reference_to` at DDL time now throws -`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same -words `FieldSchema` uses, rather than quietly changing the physical schema. One -key, one answer, on both doors. - -Fix, if you have such metadata — the same rename the schema has always asked for: - -| Wrote | Write instead | -|---|---| -| `{ type: 'lookup', reference_to: 'account' }` | `{ type: 'lookup', reference: 'account' }` | - -Referential integrity is unchanged and remains the **engine's**, applied via -`deleteBehavior` (the `409 DELETE_RESTRICTED`) — which is what -`content/docs/protocol/objectql/types.mdx` has documented since 2026-07-30. - -**Not graded as declared-breaking, deliberately.** ADR-0087's ledger reaches -upgraders about *authorable metadata* that must be rewritten. `reference_to` is -not authorable: the spec refuses it at the authoring door today and did before -this change, so no conformant object definition behaves differently and no -migration is owed to any deployment `objectstack migrate meta` can see. The -prescription above exists for metadata that bypassed validation, not for a -surface this repo ever published as writable. diff --git a/.changeset/retire-set-user-role-action.md b/.changeset/retire-set-user-role-action.md deleted file mode 100644 index 4f3a312055..0000000000 --- a/.changeset/retire-set-user-role-action.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/spec": patch ---- - -**Fix:** `sys_user`'s **`set_user_role`** action ("Set Platform Role") is retired — removed from the object's declared actions, not re-implemented (#9968). - -The action's only effect was `POST /api/v1/auth/admin/set-role`, which better-auth's `admin` plugin lowers to `internalAdapter.updateUser(userId, { role })` — a gated, UI-driven writer for the legacy `sys_user.role` scalar that ADR-0068 D2 stopped synthesizing. Platform-admin membership is granted through `sys_user_permission_set` / `admin_full_access`; a working "Set Platform Role" button was a supported, one-user-at-a-time channel for resurrecting the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3). - -**What an operator will now observe.** The "Set Platform Role" button is gone from the Users list row menu and the user detail header. It was already dead for every platform admin before this change — better-auth's vendor `adminMiddleware` gates on the same retired scalar, so the button 403'd with `YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE` for platform admins and plain members alike. Removing it removes a byte-identical-refusal dead affordance, not a working capability. - -**Unchanged.** The vendor's `POST /api/v1/auth/admin/set-role` route itself stays mounted and vendor-gated exactly as before — this change touches only the `sys_user` console action pointing at it. Every other `sys_user` admin action (`ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`) is unaffected. - -`@objectstack/spec`'s `PUBLIC_AUTH_FEATURES.admin.gatedInputs` registry drops the corresponding `sys_user.actions.set_user_role` entry in the same change (`packages/spec/src/kernel/public-auth-features.ts`) — internal completeness-guard bookkeeping only, no public export shape change. diff --git a/.changeset/richtext-code-text-family-emission.md b/.changeset/richtext-code-text-family-emission.md deleted file mode 100644 index 185d67f4a7..0000000000 --- a/.changeset/richtext-code-text-family-emission.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -A `richtext` field now takes an unbounded TEXT column instead of knex's `varchar(255)`, so an ordinary rich-text body over 255 characters can be written (#11794). `createColumn`'s text-family case listed `text` / `textarea` / `html` / `markdown`; `richtext` — the third member of the spec's own "Rich Content" grouping in `field.zod.ts` — was in neither that case nor `JSON_COLUMN_TYPES`, so it fell through to the catch-all's `table.string(name)`. Measured at 1000 characters on live MySQL 8.0.46 and Postgres 16: before this change the write was refused by the server (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, `22001 value too long for type character varying(255)`) while the same body in a `markdown` field on the same table was accepted; after it, the column reads back as `text` from `information_schema` on both and the value round-trips byte-identically. `code` moves with it for the same reason. - -Membership is now decided by a stated, measured test instead of the hand-maintained case list that let one member of a three-member spec group diverge in the first place: a type may take an unbounded TEXT column exactly when the **write seam** enforces its declared `maxLength`, which is the invariant `schema-drift.ts` already rests on ("A TEXT column refuses nothing a `maxLength` allows … the bound is enforced at the write seam"). objectql's record-validator applies its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` / `password` / `markdown` / `html` / `richtext` / `code` and to nothing else, so both moved types keep a field-named ADR-0112 refusal for an over-declared value and the physical surface is restored to the declared contract rather than widened past it. The set of types that take an unbounded column when unkeyed is pinned as a whole, so the next addition has to be stated on purpose. - -`signature` and `qrcode` are **not** moved, deliberately and against the first reading of this defect. Their stored value is the author's own and routinely far past 255 characters (a data-URI PNG), so `varchar(255)` refuses ordinary values for them too — but the record-validator has no `max_length` branch for either, so an unbounded column would accept values a declared `maxLength` forbids: over-accepting in place of under-accepting, which is a physical surface wider than the contract. They stay bounded until the write seam can bound them, and the live-dialect suite asserts that refusal out loud rather than leaving it undocumented. - -The #11374 keyed-and-bounded rule applies to the two new members unchanged: a keyed, bounded `richtext` / `code` column is still emitted as `varchar(maxLength)` so a declared index can key it on MySQL, and a keyed but unbounded one still gets the named `explainUnkeyableTextColumn` refusal rather than a silently weaker constraint. Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty. diff --git a/.changeset/root-entry-nameability-data-delta.md b/.changeset/root-entry-nameability-data-delta.md deleted file mode 100644 index 3817921d7d..0000000000 --- a/.changeset/root-entry-nameability-data-delta.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): make `BaseValidationRuleShape` and `FilterCondition` nameable from the root entry (#11709) - -Additive follow-up to the #11350 root-entry nameability fix, same invariant -(a type that appears structurally in an entry's public declarations must be -nameable from that same entry): a minimal consumer program with an -un-annotated `export default defineStack(...)` and **no** `@objectstack/spec/data` -import anywhere in its program still failed declaration emit with exactly two -TS2883 diagnostics — `BaseValidationRuleShape` and `FilterCondition`, both -mentioned structurally by `defineStack`'s return type but reachable only -through hash-named internal dist chunks. The root entry now re-exports both -types from their declaring `/data` modules (`data/validation.zod`, -`data/filter.zod`), exactly as #11350 did for `FormFieldInput` / -`NavigationItemInput` / `StateNodeConfig`. No runtime change; existing -consumers that already imported these names from `@objectstack/spec/data` are -unaffected. diff --git a/.changeset/root-entry-type-reexports-11350.md b/.changeset/root-entry-type-reexports-11350.md deleted file mode 100644 index d825c914c7..0000000000 --- a/.changeset/root-entry-type-reexports-11350.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Re-export `FormFieldInput`, `NavigationItemInput` and `StateNodeConfig` from the package root entry (#11350). These types appear structurally in the root entry's own public declarations — `defineStack` returns `ObjectStackDefinition`, declared `z.input`, which the declaration emitter expands structurally rather than preserving as an alias — but they were previously nameable only via the `/ui` and `/automation` subpaths. Any consumer letting TypeScript infer a type through a root-entry function (an un-annotated `export default defineStack(...)`) therefore hit TS2883 naming a hash-named internal dist chunk. With the re-exports, that consumer shape declaration-emits cleanly, with no annotation required. Invariant recorded: a type that appears structurally in an entry's public declarations must be nameable from that same entry. diff --git a/.changeset/runtime-config-telemetry-posture.md b/.changeset/runtime-config-telemetry-posture.md deleted file mode 100644 index 0abe29ff93..0000000000 --- a/.changeset/runtime-config-telemetry-posture.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/cloud-connection": minor ---- - -**Security (p0, upstream half):** `GET /api/v1/runtime/config` now carries a `telemetry` block, giving the Console SPA a **post-build off switch** for client error reporting (#10805, upstream half of `cloud#1508`). - -An air-gapped on-premises EE Console was measured sending **14 Sentry envelopes per session** to `sentry.io`, carrying IP and User-Agent PII, with no way for the customer to turn it off. objectui closed the half it owns — a build that never opts in now issues no third-party request at all — and could not close the other: every telemetry knob there is a Vite build-time variable inlined into the bundle as a frozen literal, so a build that **did** opt in (the hosted console, and the identical artifact shipped on-prem) had no switch that editing env vars on the deployed host could reach. The only server-to-SPA channel is this endpoint. - -```json -{ "telemetry": { "allowClientErrorReporting": false } } -``` - -Operators grant it with `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true`, or hosts with `new RuntimeConfigPlugin({ allowClientErrorReporting: true })`. The switch answers to the repo's usual truthy vocabulary (`1` / `true` / `on` / `yes`); an unrecognised spelling is refused and named at mount time rather than coerced. - -**Denied by default on every posture, not only the air-gapped one.** Deriving "connected therefore allowed" would have left the reported injury class open one deployment over: an internet-connected on-prem box runs the *same build artifact* as the hosted console, so the DSN cannot tell them apart, and its customer has equally never heard of Sentry. A universal opt-in satisfies "air-gap defaults off" strictly, and satisfies it without having to identify the posture correctly — which matters, because a posture predicate that is wrong in the *allow* direction is this card's own defect. - -**A permission, never a source.** The server supplies no DSN and cannot start telemetry for a build that carries none; `true` means only "this deployment does not object to the sink you were compiled with". The composed decision stays `Boolean(buildTimeDsn) && isClientErrorReportingAllowed(payload)`. - -**A runtime that declared its control plane off cannot grant it.** `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the grant and says so in the boot log — the copied-hosted-config-onto-an-air-gapped-box shape. That declaration is the repo's one existing network-posture signal and needs no new knob: the EE image's compose file already defaults `OS_CLOUD_URL` to `off`, so the operator this failed is safe with zero configuration. - -**Absence is denial, and the reading ships with the contract.** A new export, `isClientErrorReportingAllowed(payload)`, is the canonical fail-closed reader: an older runtime's payload, a malformed body, a 404 and a failed fetch (pass `undefined`) all answer `false`. It is exported rather than left to consumers because "absent means do not send" is a claim about *their* code, and a hand-written `?.` chain is one `!== false` away from re-opening the leak on exactly the legacy payloads the guarantee is for. The key is spelled as a permission for the same reason: a negative `disabled` flag would have read falsy — therefore "send" — on every one of those states. - -`isControlPlaneDeclined()` is factored out of `cloud-url.ts` so "what counts as off" has one definition shared by the URL resolution and the telemetry refusal. No behaviour change to `resolveCloudUrl()`. - -The consumer half (reading the key and gating `initSentry`) is objectui's and is filed separately. diff --git a/.changeset/schema-count-counts-the-definitions-shipped.md b/.changeset/schema-count-counts-the-definitions-shipped.md deleted file mode 100644 index 36f4130018..0000000000 --- a/.changeset/schema-count-counts-the-definitions-shipped.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the bundled JSON Schema's `x-schema-count` counts the definitions it carries (#12588) - -`json-schema/objectstack.json` ships in the tarball (`json-schema` is in the -package's `files`), and `content/docs/deployment/troubleshooting.mdx` publishes -what the field means: "its `x-schema-count` field reports the total number of -definitions". It did not. The generator took the number from `count` — a -counter incremented once per emitted schema — while the bundle's `$defs` is -assembled from a map keyed by `/`. Every def key written more -than once therefore widened a gap nothing reconciled: the published bundle -declared **1596** definitions while carrying **1585**. - -`$defs` is now assembled before the envelope and the field is taken from its -size, so the artifact describes itself. The per-schema files on disk already -agreed with `$defs` (1585) — the same key collapses the file writes — so this -brings the one disagreeing number into line with both of the others, and the -docs sentence is true as written without changing it. - -**The collapsed emits are now named rather than implied.** The 11 def keys -written twice are all **benign self-aliases** — `export const X = XSchema` -spelled as `Object.assign(XSchema, …)`, one schema object reached by two export -names, so the second write cannot change what is published. Eight in `api` -(`ApiEndpoint`, `RestApiConfig`, `RestServerConfig`, `ApiDocumentationConfig`, -`ApiTestCollection`, `OpenApiSpec`, `RestApiPluginConfig`, -`RestApiRouteRegistration`) and three in `system` (`MiddlewareConfig`, -`QueueConfig`, `Task`). No schema is being silently dropped: the existing -`findDefKeyCollisions` guard exits the build on any def key claimed by two -*different* schemas, so a build that produces a bundle at all has only exempt -ones — and `gen:schema` now prints that population instead of leaving it -visible only as a subtraction between two summary lines. - -No schema content changes; only the bundle's self-description and the -generator's console output. diff --git a/.changeset/schema-diff-unreachable-kind.md b/.changeset/schema-diff-unreachable-kind.md deleted file mode 100644 index 2c51e42f1a..0000000000 --- a/.changeset/schema-diff-unreachable-kind.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-datasource": patch -"@objectstack/runtime": patch ---- - -**Federation:** `SchemaDiffEntry` gains a distinct `unreachable` kind — "the remote could not be read" is no longer reported as `missing_table`, and a transient outage no longer aborts boot under the default `onMismatch: 'fail'` (#11166, maintainer ruling 2026-08-23). - -`ExternalDatasourceService.validateEach` used to convert **any** per-object validation throw — including `connect ECONNREFUSED` from remote introspection — into a `{ kind: 'missing_table', severity: 'error' }` row, indistinguishable from a genuinely dropped table. Downstream, that shape meant: the boot gate (`ExternalValidationPlugin.runValidation`) aborted startup for a 30-second network blip, and the background drift checker raised `external.schema.drift` events claiming the schema changed on every tick the remote stayed down. - -Now: - -- **`@objectstack/spec`** (minor): `SchemaDiffEntryKind` adds `'unreachable'` — the one kind that asserts *nothing about the remote schema*; it states that validation was indeterminate because the remote (or the object definition) could not be read. The throwing error's text is carried in `actual`. Every other kind remains a measured fact about a schema that was successfully read. Additive: existing entries and their meanings are unchanged. Consumers that exhaustively switch on the kind union (e.g. a `Record`) will get a compile-time prompt to label the new member; non-exhaustive consumers see a new string value at runtime and should render it as-is. -- **`@objectstack/service-datasource`** (patch): the per-object catch in `validateEach` classifies every throw as `unreachable` (rows stay `ok: false`, `severity: 'error'`). `missing_table` is still reported — but only from its measured branch: a table absent from an introspection that returned. -- **`@objectstack/runtime`** (patch): the boot gate no longer feeds `unreachable` rows to the `onMismatch` policy — no abort under `fail`; instead it logs a loud `warn` naming the datasource, the object, the underlying error, and that the object's schema is unverified for this boot, under every `onMismatch` value. Measured mismatches keep the existing policy behavior, including sitting beside an unreachable row in the same report. The drift checker still emits `external.schema.drift` for unreachable rows (consumers discriminate on `kind`), but its operator-facing summary now says "could not read the remote", never "drift detected", for them. diff --git a/.changeset/sdui-parser-inline-boundary-space.md b/.changeset/sdui-parser-inline-boundary-space.md deleted file mode 100644 index 10e6342074..0000000000 --- a/.changeset/sdui-parser-inline-boundary-space.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/sdui-parser": patch ---- - -The JSX-source parser no longer deletes the space that separates a text run -from an adjacent sibling element. `parseChildren` collapsed each text run's -whitespace to a single space (correct — that is HTML's own whitespace model) -and then `.trim()`ed it (not correct — HTML collapses a whitespace run to one -space, it does not delete it), so `A x page` compiled to -`['A', {strong}, 'page']` and the words ran together wherever that tree is -rendered. - -The rule now applied: collapse the run, then keep one leading space when a -sibling precedes it and one trailing space when a sibling element follows it; -at the parent's own start/end the edge space is still dropped, so -`

hi

` still compiles to `['hi']`. It is deliberately mechanical — it -invents no block/inline taxonomy for a schema tree that has none. Its one -bounded cost is that a whitespace-only run between two siblings survives as a -single space, so a pretty-printed `
    ` gains one `' '` child per inter-item -gap; the tests pin that bound. This matches the rule the downstream copy of -this parser already applies, so the two agree on the tree they produce. diff --git a/.changeset/security-plugin-console-backed-default-sink.md b/.changeset/security-plugin-console-backed-default-sink.md deleted file mode 100644 index a108acbe51..0000000000 --- a/.changeset/security-plugin-console-backed-default-sink.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -`SecurityPlugin`'s own report sink is now **console-backed by default** — loud until a host -injects one — instead of being initialised to an empty object. Its fail-closed refusals -(`getReadFilter … denying (fail-closed, #2852)` and `#4467`, `checkAuthoredRowWrite … -abstaining`, the ADR-0123 tenant-wall refusal) previously went nowhere at all on any instance -whose lifecycle had not yet reached the sink binding; they now reach `console.warn` / -`console.error`. A host that injects a logger is unaffected: `start()` assigns `ctx.logger` -over the default, above both of its early bail-outs (#10706), so a degraded boot still reports -through the host. - -**Operator-visible:** a deployment that never injects a sink will begin seeing these refusals -on the console. That is the intended change — the refusal itself is not moving, only whether -anyone can see it. - -Why `minor` and not `patch`: the observable output of a running deployment changes. The -declared shape changes with it — the field's `warn` channel is now non-optional, which is what -#9754 requires of a sink declaring an optional `error`, and what a default of `{}` made -impossible to state honestly. `error` deliberately stays optional (#9754 option C, falsified: -hosts do inject reduced sinks). The maintainer ruled on 2026-08-24 (#10556) that the default -becomes console-backed and that silent-by-declaration is rejected. diff --git a/.changeset/seed-name-lookup-unscoped-page-budget.md b/.changeset/seed-name-lookup-unscoped-page-budget.md deleted file mode 100644 index 64cc912f7c..0000000000 --- a/.changeset/seed-name-lookup-unscoped-page-budget.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): stop reading a truncated existence page as "absent" — the unscoped page cap is now measured, not trusted (#11518) - -`buildExistingByName` (`seed-name-lookup.ts`) is the batched existence oracle the -identity seeders consult in place of a per-item read. Its UNSCOPED page was -capped at `limit: names.length`, which is exact only while one row can exist per -name. Since #8461 / ADR-0120 D1 `sys_capability.name` and -`sys_permission_set.name` are unique **per organization**, and ADR-0066 D1 -explicitly encourages admins to EXTEND the registry inside their own -organization — so one name legitimately carries a row per organization plus the -platform's, and an unscoped page of N names can match far more than N rows. - -The rows that fall off a full page are the highest `id`s under #4363's -`ORDER BY id ASC` tie-breaker, so **whole names vanish from the page** — and a -name missing from the page reads as `absent`, which routes its caller to the -**INSERT** branch. #10103 had already found and repaired exactly this on the -SCOPED arm; the unscoped arm never got the repair, and two seeders on `main` -read unscoped (`bootstrapDeclaredCapabilities`, `permission-set-projection`'s -env-overlay pass). - -⛔ `names.length * 2` would have been the same defect with a larger constant: -rows-per-name is bounded only by the number of organizations, so no constant -multiplier is correct. Instead the cap stopped being a promise and became a -**measurement** — the read asks for one row MORE than it is willing to hold, and -a page that comes back carrying that extra row is a PREFIX of the answer rather -than the answer. It then joins the module's existing "could not answer" causes -and degrades to the per-item read, the fallback already there for a driver -without `$in`. Both directions are exact: no complete page is ever mistaken for -a truncated one, and no truncated page for a complete one. - -**Behaviour change, stated rather than slipped in.** In the truncating case the -two unscoped seeders go from a **silent wrong answer to a loud slow one**: names -that used to be reported `absent` (and re-inserted, or refused by the unique key -as a collision naming a row nobody ever saw) are now answered correctly, at the -cost of one read per name plus a warning naming the object and the budget it -could not fit inside. An install that does not overflow the budget — every stock -one, where a name carries a single row — issues exactly the same single read it -issued before and says nothing. - -The SCOPED arm keeps #10103's cap exactly (`names.length * 2`), because there the -number is a proven bound rather than a budget: `applyTenantScope` returns this -organization's rows plus organization-less ones, and the declared name index is -unique per organization. It gains the same probe, which turns a scoped page that -overflows that bound — reachable only where the unique index is absent or not yet -created — into the same loud degradation instead of a silent truncation. diff --git a/.changeset/seed-pass2-keyless-writeback.md b/.changeset/seed-pass2-keyless-writeback.md deleted file mode 100644 index b9af5edf36..0000000000 --- a/.changeset/seed-pass2-keyless-writeback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Seed pass 2 now writes a deferred reference back through the internal id captured at insert time, so a keyless dataset (`mode: 'insert'`, no `externalId` — the honest authoring for an engine-owned object with no natural key) heals an out-of-order reference exactly like a keyed one. Previously pass 2 re-resolved the source row through its `externalId`, so a keyless (or empty-keyed) row's resolved reference was dropped loudly and the load reported `success: false`; the same load now succeeds with the reference healed. The loud drop remains for the one case where it is true — the source row's pass-1 write failed, so there is nothing to write back onto. Unchanged and now measured against the real engine: a deferred column that is `required: true` is still rejected at pass-1 insert (the deferral deletes the column), so such datasets must still seed their target first. diff --git a/.changeset/seed-required-deferral-early-signal.md b/.changeset/seed-required-deferral-early-signal.md deleted file mode 100644 index be00d7d442..0000000000 --- a/.changeset/seed-required-deferral-early-signal.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): warn at load time when a seed defers a `required` column, before the engine rejects the row (#11674) - -The seed loader defers an unresolvable reference to pass 2 by **deleting the -column** from the pass-1 row. On a `required: true` column that turns the insert -into one the write contract rejects (ADR-0113), so the row never lands and pass -2 has no row to back-fill — measured against the real `ObjectQL` engine in -`packages/objectql/src/engine-seed-required-deferral.test.ts`. It is the second, -independent road to the same loud failure that the pass-2 internal-id write-back -could not clear, and it is why a `required` id half stays **order-dependent** -even though a declared pointer pair contributes no static ordering edge. - -Nothing about that failure was quiet — it is a write error naming the column, a -dropped-deferral error, and `success: false`. What was missing is **when** the -author learns: only after the engine rejected the row, from a driver-level -message that does not mention seeding order. The loader now says it first. - -- **Load-time warning, scoped to the required subset.** When a dataset defers a - reference on a column the write contract requires on insert, the loader logs - one `warn` naming the object, the field, the target object that is not seeded - yet, what deferring did to the row, and the fix — order the target dataset - first. Emitted before the row reaches the engine, once per dataset and field - rather than once per row. -- **⛔ The accept set is unchanged.** Nothing is counted, nothing reaches - `result.errors`, `success` is untouched, and every existing loud failure and - its tests are preserved verbatim. Only the log gains a line. -- **The predicate mirrors the write contract instead of approximating it** - (`required && !readonly && !system`, and only on rows headed for an INSERT). - Two configurations were measured on the real engine where a deferral on a - `required` column is accepted today — an upsert replay taking the UPDATE arm - (an omitted column is not a cleared one) and a `readonly` required column - (required-validation skips it on insert) — and the warning correctly stays - silent on both. They are pinned as controls. - -The constraint is also now documented at the four pointer-pair declaration -sites: `sys_approval_request`, `sys_record_share` and `sys_share_link` must seed -the target dataset first; `sys_audit_log` (optional id half) is genuinely -order-independent and says why it differs. diff --git a/.changeset/seed-tenancy-counter-handoff-writes-first.md b/.changeset/seed-tenancy-counter-handoff-writes-first.md deleted file mode 100644 index 18f4043ba9..0000000000 --- a/.changeset/seed-tenancy-counter-handoff-writes-first.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): the #8686 tenancy backfill writes the merged autonumber high-water mark before it deletes anything (#12394) - -The seed/API tenancy handoff destroyed the counter it was supposed to move. It ran two -independent statements — an `UPDATE` of the organization-scoped `_objectstack_sequences` -row, then an unconditional `DELETE` of the `'__global__'` one — and on a **fresh install** -there is no organization-scoped row yet, because no API create has happened. The `UPDATE` -matched nothing, which is a success on every dialect; the `DELETE` ran regardless; the -counter table was left empty. `SqlDriver.getNextSequenceValue` then re-entered the -`if (!existing)` bootstrap its own docstring reserves for first allocation, re-derived the -counter from `MAX(data)`, and **re-issued a business identifier that had already been -handed out** — measured on 17.1.0: `ACC-000009` on two different records. - -The zero-row case is the *normal* first-boot shape, not an edge case: it is precisely the -shape `buildSplitProbeSql`'s `LEFT JOIN` was widened to catch, so the repair fired on -exactly the installs where its merge loop body never executed. - -The handoff is now one ordered decision per scope: - -1. **write** the merged mark — `INSERT` when the organization-scoped row is absent, - `UPDATE` when it exists; -2. **read it back** — "the statement did not throw" was never evidence a row was written, - and an `UPDATE` matching zero rows is exactly the defect above; -3. **then** retire the `'__global__'` row, addressed by its own stored `key_hash`, so a - retirement can only ever hit the row whose mark was just merged. - -A throw at any step leaves the `'__global__'` row in place — which is the state the next -boot's split probe detects and retries — so a failed repair now loses nothing. - -Per **scope**, because a `{YYYYMMDD}` / `{field}` / per-parent format runs one counter row -per rendered prefix. The old merge was scope-blind in both directions: it could raise every -scope's counter to one merged value, and it deleted every scope's `'__global__'` row. - -The merge rule itself is unchanged and is the 2026-08-15 ruling's: the greater of the two -**counters**, never the data max. That rule is the whole point — a counter is allowed to -sit ahead of its rows (a rolled-back insert burns a number, by design), and that gap is -exactly what the old handoff threw away. - -Graded **patch**: a defect repair inside an existing migration. It adds no export to -`@objectstack/metadata-protocol`'s public index — the new SQL builders are module-scoped -for their own unit tests, matching the index's own recorded rule that an export added so a -test can import a value is the shape to catch before it ships. - -No change to the allocator. Reaching `if (!existing)` is not evidence of lost state — a new -tenant, a new day and a new `{field}` group each reach it legitimately, and a destroyed -counter leaves no row behind to tell the two apart — so a guard there would fire on the hot -path and still not detect this. The repair belongs where the state was destroyed. diff --git a/.changeset/send-template-input-org-retired.md b/.changeset/send-template-input-org-retired.md deleted file mode 100644 index ff4a3fee23..0000000000 --- a/.changeset/send-template-input-org-retired.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `SendTemplateInput.org` — the declared org-overlay resolution tenant id nothing ever read (#11832, ADR-0049 enforce-or-remove) - - - -**BREAKING** accept-set narrowing (compile-time), landing after the v17.0.0 cut -(the lockstep launch-window convention ships it as `minor`; the migration -prescription is registered under protocol major 18, where `os migrate meta` -users will look). - -**Removed member:** `SendTemplateInput.org` (`packages/spec/src/contracts/email-service.ts`). - -The member was declared as "Tenant id for org-overlay resolution (when -supported)" and no implementation ever read it: `@objectstack/plugin-email` — -the only `IEmailService` implementation — resolves templates on -`(name, locale)` only, so a caller passing `org` got no org-overlay resolution -and no error; the "(when supported)" hedge was the declaration admitting the -gap. After #11741 landed `organizationId` beside it, `SendTemplateInput` -carried two org-shaped keys of which one did nothing — exactly the shape that -invites an AI author to pick the wrong one. - -**FROM → TO:** `sendTemplate({ template, to, org: tenantId, … })` → -`sendTemplate({ template, to, … })` — delete the `org` key; it never changed -behaviour, so removing it changes none either. It is **NOT** replaced by -`organizationId`: that member is the delivery row's tenant stamp -(`sys_email.organization_id` pass-through, #11741) and opts into no template -overlay resolution. - -**What is refused:** authoring `org` in TypeScript is now an excess-property -`tsc` error (`SendTemplateInput` is a programmatic contracts interface with no -Zod surface, so the compiler is the enforcement channel — pinned in -`email-service.test.ts`). Runtime behaviour is unchanged: nothing ever read -the member, so a JavaScript caller still passing `org` keeps its exact -pre-removal outcome (the key is carried inert and ignored). - -**What stays:** `SendTemplateInput.organizationId` and -`SendEmailInput.organizationId` (#11741, Decision 2 of #11303) are untouched, -semantics included. `RenderTemplateInput` never carried `org`. D3 semantic -entry `send-template-input-org-retired`; no D2 conversion, because the key -only ever appeared in a call-time input bag — no metadata seam ever runs on it -(the `data.engine.update options.upsert` precedent). diff --git a/.changeset/sendemail-organization-id.md b/.changeset/sendemail-organization-id.md deleted file mode 100644 index 452cd5324d..0000000000 --- a/.changeset/sendemail-organization-id.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-email': minor -'@objectstack/service-messaging': minor -'@objectstack/plugin-auth': minor ---- - -Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). - -- `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). -- `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. -- `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). -- `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. - -Forward-stamping only: existing org-less `sys_email` rows are not backfilled. diff --git a/.changeset/senderror-carries-declaredcode.md b/.changeset/senderror-carries-declaredcode.md deleted file mode 100644 index 48372d7882..0000000000 --- a/.changeset/senderror-carries-declaredcode.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/types": minor ---- - -fix(types): let `sendError`'s `extra` carry `declaredCode`, so a nested-envelope route can emit the ADR-0112 open channel (#11719) - -`ApiErrorSchema` has declared `declaredCode` since #9106 — the open, -author-authored channel that carries a metadata app's own `.code` verbatim when -the spelling is not a member of the closed `code` vocabulary. ADR-0112's -2026-08-17 amendment rules that demote **platform-wide**, and #9232 extended it -to the flat `/data` door, which emits the pair today. - -The shared nested-envelope writer could not. `sendError`'s `extra` was typed -`Pick`, so -passing a demoted spelling was a **compile error** and every route answering the -nested envelope dropped it. Nothing invalid shipped — the closed `code` still -carried the member derived from the status — which is exactly what made the loss -silent and one-directional: the author's spelling gone, and a consumer told by -the ADR to read `declaredCode` finding nothing there. Declared-but-unemittable -is a `declared = enforced` gap, closed here at the one writer rather than per -module. - -Additive: `declaredCode` joins the `Pick`. No existing call site changes, no -wire byte moves for any body already being emitted, and the contract's accept -set is untouched — the schema has always permitted the field. - -⛔ Presence still MEANS demotion, and the writer does not re-derive that. The -caller passes `demotedDeclaredCode(thrown)` (`@objectstack/types`), exactly as -the flat door's `thrownCodeFields` does; that helper answers `undefined` when -the producer's spelling is already the vocabulary member sitting in `code`, so a -registered refusal never carries two spellings of one fact. Vocabulary and -position stay two decisions (#9232). - -Pinned in `response-envelope.test.ts` by driving the real pipeline — a -sandbox-shaped throw carrying a tenant-authored `.code` through -`resolveThrownHttpError` and `demotedDeclaredCode` — and by parsing the emitted -body with the real `ApiErrorSchema`, asserting the field is still on it *after* -the parse. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, -so a `.success` assertion alone would have passed against a schema declaring -nothing. diff --git a/.changeset/senderror-carries-usermessage.md b/.changeset/senderror-carries-usermessage.md deleted file mode 100644 index e69394585f..0000000000 --- a/.changeset/senderror-carries-usermessage.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/types": minor ---- - -fix(types): let `sendError`'s `extra` carry `userMessage`, so a nested-envelope route can emit the #9934 user-facing channel (#12404) - -`ApiErrorSchema` declares `userMessage` — the producer-side opt-in for "this -exact text is addressed to the END USER" (#9934; maintainer ruling 2026-08-19 on -objectui#5210, option 1), where **presence IS the marking** and a consumer that -sees the field renders it verbatim instead of substituting its generic string -(#3821 preserved by construction, for everything unmarked). - -Two of the three doors already emit it: the flat `/data` door through -`withDeclaredUserMessage` (`@objectstack/rest`) and the dispatcher door through -`thrown.userMessage` (`@objectstack/runtime`). The shared nested-envelope writer -could not — `sendError`'s `extra` was typed -`Pick`, -so passing the field was a **compile error** and every route answering the -nested envelope dropped it. Nothing invalid shipped, which is what made the loss -silent and one-directional: the author's deliberate, localized refusal text -gone, and a consumer told to read `userMessage` finding nothing there. -Declared-but-unemittable is a `declared = enforced` gap, closed here at the one -writer rather than per module. - -Additive: `userMessage` joins the `Pick`. No existing call site changes, no wire -byte moves for any body already being emitted, and the contract's accept set is -untouched — the schema has always declared the field. - -The channel is live on both ends, which is what makes this a repair rather than -a new declared-but-dead surface: a hook opts in at throw time (host-side, or a -metadata app's sandboxed body whose `e.userMessage` crosses the QuickJS boundary -through `SANDBOX_ERROR_PASSTHROUGH`), and `resolveThrownHttpError` already -carries it onto `ThrownHttpError` for every caller of the shared resolver. - -⛔ Unlike `declaredCode`, this field hands the caller **no invariant to -re-derive**. `declaredCode`'s presence means demotion, so its caller passes -`demotedDeclaredCode(thrown)`; `userMessage`'s presence means only that the -producer opted in, which `declaredUserMessage` has already decided (a non-empty -string, or nothing). The caller passes `thrown.userMessage` straight through, -exactly as the dispatcher door does. That difference is why `extra` stays an -explicit `Pick` rather than being derived from `ApiError`'s optional fields: a -derivation would admit every future optional on the day it lands, with nobody -asked what obligation the channel hands the caller — and these two fields needed -opposite answers to exactly that question. - -Pinned in `response-envelope.test.ts` by driving the real pipeline — a hook -refusal shaped like the one `hook-refusal-user-facing-marking.dogfood.test.ts` -drives, through `resolveThrownHttpError` — and by parsing the emitted body with -the real `ApiErrorSchema`, asserting the field is still on it *after* the parse, -paired with a control showing an undeclared sibling being stripped from the same -body. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, so a -`.success` assertion alone would have passed against a schema declaring nothing. -A blank marking is pinned ABSENT: the writer never invents a marked message for -a producer that wrote none. diff --git a/.changeset/serve-banner-flow-name-shadowing.md b/.changeset/serve-banner-flow-name-shadowing.md deleted file mode 100644 index 6977c2fa9f..0000000000 --- a/.changeset/serve-banner-flow-name-shadowing.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): the startup banner names a contested flow name and says which definition is armed (#12028) - -`os dev` / `os start` print an automation summary that reads binding STATE off the -live engine, because a flow that failed to arm emits no log line to go looking for -and the boot-quiet stdout window swallows the engine's own `warn` narration. That -summary was silent about the one failure it could not express as a count. - -The engine's flow map is keyed by BARE name. When a packaged flow and a -runtime-authored `sys_metadata` overlay both claim one name, ADR-0005 precedence -arms one and the loser is not in the map — so it is not in `listFlows()`, not in -`getFlowRuntimeStates()`'s rows, and therefore not in any number the banner -prints. `3 flow(s), 3 bound to triggers` was a true sentence about a set that did -not contain the definition the operator had just edited, and nothing on the banner -said otherwise. #11997 gave the engine the receipt (`getShadowedFlows()`, plus -`armedFrom` / `shadowed` on each runtime-state row) and the automation plugin -warns from it at `kernel:bootstrapped` — but that is a `logger.warn`, which is -exactly the channel this banner exists to work around. - -`collectAutomationSummary` now reads that receipt through a probe feature-detected -exactly like the `getTriggerBindingAudit` one beside it, and the banner prints one -line per contested name carrying all three facts an operator needs: - -``` - ⚠ flow 'send-welcome' is claimed by 2 definitions — a runtime-authored row - (sys_metadata) is ARMED, 1 shadowed (ADR-0005 overlay precedence; only the - armed definition dispatches) -``` - -Naming which body is armed is the point: a line reporting only the count tells an -admin something is wrong and withholds the answer they are standing there to get. - -Silent on every healthy boot — no contested name, no line. This banner is read on -every start, and a warning that also fires when nothing is wrong is one readers -learn to skip. Both directions are pinned on what the banner RENDERS, absence -included. diff --git a/.changeset/serve-node-env-production-default.md b/.changeset/serve-node-env-production-default.md deleted file mode 100644 index cebfb66f22..0000000000 --- a/.changeset/serve-node-env-production-default.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os serve` defaults `NODE_ENV` to `production` when unset, exactly as `os start` already does (#11113) - -**BREAKING for a deployment that runs `os serve` with `NODE_ENV` unset and -relies on a development-class convenience surviving into a real boot.** -Shipped as `minor` under the repo's launch-window convention for breaking -changes, not `patch` — this is a deliberate default flip, not a bugfix that -restores previously-intended behaviour. - -`os start` has forced `NODE_ENV='production'` on the unset case since #5673, -but it does so on the child environment it assembles for its **spawn** -(`start.ts:347`). `os serve` runs **in-process** — there was no equivalent -write, so the whole family of `NODE_ENV !== 'production'` gates across the -tree read the raw `undefined` and took the non-production branch on a boot -that never declared itself anything else. Filed as #11113, the declared -residual of #10366 (which closed the same gate's *set-but-wrong* case and -left this one for its own card, per the disposition precedent on #11035). - -One line: `serve.ts` now defaults `process.env.NODE_ENV` to `'production'` -when unset, at the same point it already defaults it to `'development'` under -`--dev` — before any of the runtime modules it dynamically imports, and before -every gate downstream reads the variable. An explicitly-set `NODE_ENV` -(`development`, `test`, anything else) is never overridden. - -The full behaviour-flip survey — every `NODE_ENV`-reading predicate in the -tree, which ones flip and which don't, and why — is in the PR body (#11113), -not repeated here. Highlights of what an unset-`NODE_ENV` `os serve` boot now -gets, that it did not before: - -- plugin-auth's localhost trusted-origin CSRF substitution closes (the - regression this card pins). -- plugin-auth's CSRF Origin/Referer synthesis for headerless requests closes. -- plugin-auth's missing-`OS_AUTH_SECRET` fallback to a forgeable - `dev-secret-` becomes a refusal to boot instead. -- plugin-auth stops printing invitation / magic-link URLs and OTP codes to - logs. -- plugin-dev's ADR-0115 D6 boot guard now refuses to initialize the dev - assembly (well-known auth secret, seeded dev admin) instead of loading it. -- the SQL driver's auto-DDL guard stops silently applying `safe` schema drift. -- the seed loader stops seeding dev-scoped datasets into what it previously - could not tell apart from production. -- service-settings' local crypto provider now requires a stable key instead - of tolerating an auto-generated / ephemeral one. - -Every one of those is the intended tightening this card exists to make: an -operator (or an AI-authored deploy script) that never exported `NODE_ENV` is -running a real deployment, and the safe direction is to treat it as one, loud -failures included, rather than silently keep a development-class door open. -`NODE_ENV=development` / `NODE_ENV=test` — including the flows `os dev` and -`os serve --dev` already carry — are unaffected; only the unset case moves. - - diff --git a/.changeset/serve-organizations-pkg-roster-key.md b/.changeset/serve-organizations-pkg-roster-key.md deleted file mode 100644 index b8c1928962..0000000000 --- a/.changeset/serve-organizations-pkg-roster-key.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -refactor(cli): `serve` resolves `@objectstack/organizations` through one declaration the spec roster pins (#11614) - -`PLATFORM_PLUGIN_WIRED_RUNTIMES` (`packages/spec`) is the provenance roster for -`plugins[]`-wired out-of-repo runtimes, keyed by npm package name — the single -machine-readable answer to "is this `@objectstack/*` package real, and where -does it ship from?". It exists because a **fabricated** `@objectstack/framework` -sat next to the real `@objectstack/organizations` in published docs for months, -indistinguishable by inspection (#10921). - -`serve` is the only runtime that prints one of those names AT OPERATORS — the -install remedy, the fatal refusal when a walled tenancy posture cannot load the -multi-org runtime, and the degraded-boot warning. It spelled the package as a -bare literal at the resolution site, under no pin at all, so the roster and the -name `serve` actually resolves could diverge in silence: rename the roster key, -or mistype the literal, and every gate stays green while boot reaches for a -package that does not exist and the fatal message tells the operator to install -it. - -The name is now declared once, as `Serve.ORGANIZATIONS_RUNTIME_PKG`, and both -resolution-path uses read it — `importFromHost(…)` and the `readHostDeclaration(…)` -that decides which of the two absence remedies to print. A drift pin in -`serve-capability-vocabulary.test.ts` (the suite that already holds the two -rosters to each other) asserts that value is a roster **key** and that its row is -the `enterprise` edition. - -**Load semantics are untouched.** Which postures load the runtime, the two-stage -import/mount failure classification, and what `OS_ALLOW_DEGRADED_TENANCY` does -and does not cover are all exactly as they were; the roster is deliberately not a -resolution registry and is not consulted at boot. This single-sources the -spelling and nothing else, so there is no behaviour or output change — the -declared value is byte-identical to the literal it replaces. diff --git a/.changeset/serve-organizations-prose-single-sourced.md b/.changeset/serve-organizations-prose-single-sourced.md deleted file mode 100644 index fa76511ec9..0000000000 --- a/.changeset/serve-organizations-prose-single-sourced.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -chore(cli): spell the multi-org runtime from its one declaration in `serve`'s operator-facing prose (#12151) - -`serve` prints `@objectstack/organizations` at operators in five places: the -two-branch install remedy, the ADR-0093 D5 fatal refusal when a walled tenancy -posture cannot load the runtime, the degraded-boot warning, the stage-2 mount -refusal, and the `isolated` posture description in the tenancy-posture fix list. -#11614 single-sourced the name `serve` RESOLVES onto -`Serve.ORGANIZATIONS_RUNTIME_PKG` and pinned that declaration against the -spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster, but the sentences kept their -own copies — so a roster-key rename would leave operator instructions naming a -package that no longer exists while boot reached for the new one, with every gate -green (the roster pin only sees the declaration). - -All five now interpolate the constant, and a new test asserts what they RENDER -rather than that the constant appears in the source — the affected line compared -whitespace-included against text built from the same declaration. The message -bodies moved into pure formatters (`formatOrganizationsInstallRemedy`, -`formatOrganizationsAbsentFatal`, `formatDegradedTenancyWarning`, -`formatOrganizationsMountFatal`), the seam shape `resolveTenancyPostureOrRefusal` -in the same file already uses, so the rendering is reachable without spawning a -boot; `chalk` and the `process.exit` stay at the call site. - -**No behaviour change.** The declared value is byte-identical to the literal it -replaces, and the rendered output was verified byte-for-byte against the -pre-change expressions across every branch (both remedy kinds, both walled -postures, `mountCode` present and absent). The three comments in `serve.ts` that -legitimately name the package are untouched — the tempting "no bare literal -outside the declaration" source scan is deliberately not built, since it would -have to exclude comments and that shape is easy to get wrong. diff --git a/.changeset/settings-getmany-all-or-nothing-doc.md b/.changeset/settings-getmany-all-or-nothing-doc.md deleted file mode 100644 index 8638452552..0000000000 --- a/.changeset/settings-getmany-all-or-nothing-doc.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/service-settings": patch ---- - -docs(service-settings): state `getMany`'s all-or-nothing key validation on the declaration that owns it (#11680) - -Documentation and a pin. **No behaviour change** — the accept set and every -resolved value are byte-identical. - -`SettingsService.getMany` validates **every** requested key against the -namespace manifest before it reads a single env override and before it loads a -single row, so one undeclared key rejects the whole call with -`UnknownKeyError` (`code: 'SETTINGS_UNKNOWN_KEY'`) and the caller receives -nothing — not the subset it was entitled to. N per-key `get()` calls behave -differently on exactly that input: each declared key still answers, and only -the undeclared one throws. - -The doc comment was otherwise detailed — it explained the grouped row load and -the env-override ordering, and claimed row-for-row equivalence with per-key -`get` "BY CONSTRUCTION" — but never drew this line. That equivalence claim -holds for every key that *resolves* and not for the refusal, so a batched -consumer had to rediscover the rule from a test. `resolveLocalizationContext` -was the first to inherit it and had to record the consequence locally: a host -registering a **partial** `localization` manifest loses all its keys at once -and drops to a shorter cascade, where the per-key path would still have -resolved the declared ones. - -`getMany`'s doc comment now states the rule, its blast radius, why validating -ahead of the grouped walk makes the refusal independent of key order and scope -grouping, and what a caller on a partial manifest should expect. - -The pre-existing pin asserted only `rejects.toThrow(/nope/)` — green whatever -the blast radius is. A sibling pin now asserts the property instead: the error -envelope (`code: 'SETTINGS_UNKNOWN_KEY'`), that **zero** rows were loaded -(the refusal is up-front, with the undeclared key last in the request), and -the contrast that per-key `get()` still answers each declared key. diff --git a/.changeset/settings-loadrows-upper-scope.md b/.changeset/settings-loadrows-upper-scope.md deleted file mode 100644 index f98df1819e..0000000000 --- a/.changeset/settings-loadrows-upper-scope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/service-settings": patch ---- - -`loadRows` user-keyed engine loads now include tenant/global rows (`$or` over `user_id`/upper scopes), mirroring the in-memory branch. Fixes the user→tenant→global read cascade dying at the user level and upper-scope locks never firing on user-scope writes, on engine-bound deployments (#11228). diff --git a/.changeset/shared-platform-row-org-resolver.md b/.changeset/shared-platform-row-org-resolver.md deleted file mode 100644 index 6d451ccaa6..0000000000 --- a/.changeset/shared-platform-row-org-resolver.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/plugin-approvals": patch -"@objectstack/service-automation": patch -"@objectstack/plugin-audit": patch ---- - -Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. - -- `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. -- `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. -- `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. -- `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. - -The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. diff --git a/.changeset/shared-tenancy-posture-hints.md b/.changeset/shared-tenancy-posture-hints.md deleted file mode 100644 index 240b445977..0000000000 --- a/.changeset/shared-tenancy-posture-hints.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -refactor(cli): single-source the tenancy posture hint table `os serve` and `os doctor` both print (#12492) - -`serve.ts` and `doctor.ts` each declared their own `TENANCY_POSTURE_FIX_HINTS`, -and the two tables were **byte-identical** — sha256 `97497ea8…` on both, modulo -the expression spelling the package name — under no cross-check of any kind. - -The `isolated` entry at least carried a package literal the spec-owned -`PLATFORM_PLUGIN_WIRED_RUNTIMES` roster could be pinned against (#12464 / PR -#12496). **`single` and `group` were the worse half**: they touch no roster, so -nothing anywhere could ever have noticed them drift apart. A reword of one -command's copy left the other describing the same posture differently to the -same operator, with every gate green. - -Both tables now come from one CLI-internal module, -`packages/cli/src/utils/tenancy-posture-hints.ts`, which also holds the single -`ORGANIZATIONS_RUNTIME_PKG` declaration. `Serve.ORGANIZATIONS_RUNTIME_PKG` -becomes a **re-export** of it rather than a second declaration, keeping the -stable handle `serve`'s boot path and the roster pin already address; the -module-local const #12464 added to `doctor.ts` is **deleted**, which is the -deletion condition that const's own docblock recorded against this card. The -literal is now declared twice (the roster key and this module) instead of three -times. - -Each command keeps its **own bullet assembly** — doctor renders -`• OS_TENANCY_POSTURE=

    ` inside a health-check fix list, serve renders -`• set OS_TENANCY_POSTURE=

    ` inside a FATAL refusal, at different -indents. Only the table was ever duplicated. - -The two sibling spelling pins are **retargeted, not dropped** — including the -roster leg, which is the load-bearing one (a rename of the package value leaves -the rendered leg green; only the roster leg catches it). Each gains one new leg -asserting that every posture bullet, `single` and `group` included, renders the -**shared** table's entry verbatim — so a command that re-grows a local copy goes -red instead of drifting in silence. - -**No behaviour change.** The rendered text is byte-identical before and after -for all three postures at both commands, verified by capturing both commands' -full rendered fix lists on `origin/main` and on this branch and diffing them. diff --git a/.changeset/sharing-options-logger-guaranteed-warn.md b/.changeset/sharing-options-logger-guaranteed-warn.md deleted file mode 100644 index 9755394a25..0000000000 --- a/.changeset/sharing-options-logger-guaranteed-warn.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/plugin-sharing": minor ---- - -⚠️ **Published-contract break for external hosts.** The `logger` option on the three -PUBLICLY EXPORTED options types — `SharingServiceOptions`, `ShareLinkServiceOptions` and -`SharingRuleServiceOptions` — now **requires** a `warn` channel, and its members carry real -signatures (`(msg: any, ...rest: any[]) => void`) instead of bare `Function`. A host that -constructs any of these services with `{ logger: { info, error } }` compiles today and stops -compiling after this release; add `warn` (or drop the `logger` option) to migrate. Nothing -about the services' runtime behaviour changes, and no call site inside this package moved: -the tightening was measured at ZERO compile errors within `plugin-sharing`, so the whole cost -falls on hosts, which is why it is declared here rather than shipped as a patch. - -Why: #9754 rules that a sink declaring an optional `error` must declare a NON-optional -`warn`, so a durability report always has somewhere to land — an optional `error` beside an -optional `warn` is a contract that permits silence. These three sinks were red against that -rule from the day it was written and were only invisible to its checker until #11069 taught it -to read bare `Function` as a channel. The maintainer ruled on 2026-08-24 (#10556) that they -tighten rather than stay baselined, shipped `minor` with the break named here. - -The bare-`Function` spelling was also its own defect: `Function` is not assignable to a -concrete signature, so `record-orphan-cleanup.ts` could not tighten its own `MinimalLogger` -while these producers stayed loose (#10692). That producer-side blocker is now clear. diff --git a/.changeset/sharing-sweep-logger-required-warn.md b/.changeset/sharing-sweep-logger-required-warn.md deleted file mode 100644 index ab277b00c3..0000000000 --- a/.changeset/sharing-sweep-logger-required-warn.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/plugin-sharing': minor ---- - -⚠️ **BREAKING (published parameter tightened):** `sweepOrphanedRowsByRecordExistence` — -publicly exported from `@objectstack/plugin-sharing` — now types its optional `logger` -parameter as `{ info?, warn }` with real signatures (`(msg: any, ...rest: any[]) => void`) -and a **required** `warn`, replacing the old `{ info?: Function, warn?: Function }`. A host -that passed this function a logger without a `warn` member (for example an -`{ info, error }`-only literal) compiles today and stops compiling after this release. The -one-line fix: add a `warn` callback to that logger object — or omit the `logger` argument -entirely, since the parameter itself stays optional. Nothing about the sweep's runtime -behaviour changes, and no call site in this repo moved: both in-package callers forward the -owning services' `logger` options, whose `warn` is already required since the producer -tightening (`SharingServiceOptions` / `ShareLinkServiceOptions` / -`SharingRuleServiceOptions`) shipped in the previous release. - -Why: every report this sweep emits lands on `warn` — the "could not check whether records -still exist", "stopped early" and "revoked N rows" lines — so a logger without a -guaranteed `warn` is one the sweep can lose its ONLY output into. That is #9754's -permit-silence shape, one module downstream of the producers #10556 tightened, and the -maintainer ruled (#10692, 2026-08-25) that this package's logger contracts refuse it -loudly at compile time rather than keep it. The bare `Function` members were also their -own defect: they documented no call shape and caught no arity mistake. The refusal is -pinned at compile time in `logger-required-warn.pin.ts`, which also pins the three -publicly exported options types above. - -Breaking ships as `minor` per the launch-window convention -(`scripts/check-changeset-no-major.mjs`). - - diff --git a/.changeset/signature-qrcode-bounded-text-family.md b/.changeset/signature-qrcode-bounded-text-family.md deleted file mode 100644 index 42ae919de5..0000000000 --- a/.changeset/signature-qrcode-bounded-text-family.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/driver-sql": minor ---- - -`signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: - -- **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. -- **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). -- **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. - -Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. diff --git a/.changeset/skill-catalog-projected-issue-ids.md b/.changeset/skill-catalog-projected-issue-ids.md deleted file mode 100644 index 18e69de4a5..0000000000 --- a/.changeset/skill-catalog-projected-issue-ids.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): strip the internal issue-id references that were projected into the published skill catalog - -The 2026-08-23 ruling stripped internal `#NNNN` citations from the published -skill corpus, but 14 of them were not authored in `skills/**` at all — they were -projected there from `.describe()` / TSDoc text in `packages/spec/src/**` by -`gen:skill-refs` and `gen:react-blocks`, so a hand-edit of the corpus could not -reach them and a regeneration would have put them straight back. - -Six source sites are rewritten to say the same thing without the citation, and -the artifacts are regenerated: the module summaries of `data/driver/common`, -`data/driver/config-registry`, `data/driver/turso`, `shared/retry-policy` and -`system/translation`, plus the `ListView.objectName` / `ListView.viewType` -deprecation notes and the `` summary in `ui/react-blocks`. The teaching in -each is kept, per the standing ruling of 2026-08-12, verbatim and untranslated: -「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」. - -Customer-facing text changes in three places from the one source edit: the -published catalog (`skills/*/references/_index.md`, -`skills/objectstack-ui/references/react-blocks.md` and its sibling -`contracts/react-blocks.contract.json`), and the docs site -(`content/docs/references/data/driver-common.mdx`, `driver-turso.mdx`). No -schema shape, no `.describe()` used for validation, and no accept/reject -behaviour changes — the edits are comment and documentation text only. - -The doc-authoring gate's path exemption for the generated artifacts is removed -in the same change: it existed only because those files still carried projected -ids, and an exemption over a surface that no longer needs one is where the next -regeneration would smuggle one back in. diff --git a/.changeset/skill-examples-extraction-fence-aware.md b/.changeset/skill-examples-extraction-fence-aware.md deleted file mode 100644 index 82a6e173e2..0000000000 --- a/.changeset/skill-examples-extraction-fence-aware.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`check:skill-examples`: make the marked-block EXTRACTION loop fence-aware, not just the orphan scan - -The orphan-marker scan learned to ignore an `os:check` marker shown as example text inside a -wrapping fence, so this gate's own convention could be documented in the very roots it governs. -The extraction loop was deliberately left out of that fix, and kept recognising a bare ` ```ts ` / -` ```tsx ` / ` ```typescript ` fence-open line wherever it appeared, with no notion of sitting -inside another fence. A marker *alone* nested in an illustration was therefore handled correctly, -while a **fully worked** one — the marker AND a real ts fence, both written as example text inside -a wrapper — was extracted and handed to `tsc` as a genuine example: compiling by luck, or failing -the whole gate with a diagnostic pointing at documentation prose. - -Both loops now read one `fenceOwners()` walk. It records, per line, which top-level fence owns it, -so `owners[i] >= 0` answers the orphan scan's question ("is this marker inside a fence?") and -`owners[i] === i` answers extraction's ("does this ts fence open at top level?"). Because the -extraction loop's fence-open pattern is a strict subset of the CommonMark one the walk uses, a -genuine top-level block always owns itself and the new guard cannot suppress one. - -No occurrence in the corpus tripped this, so the counts are unchanged either side of the fix (257 -marked examples across 99 files, three surfaces). The self-test is where the defect is measurable: -a nested worked illustration whose payloads are deliberately uncompilable now extracts nothing, -while the identical payloads with the wrapper removed all extract — and the same pair is pinned -inside a JSDoc-gutter-wrapped docblock, where fence ownership has to be judged on the -gutter-stripped lines. diff --git a/.changeset/skill-refs-exports-fallback-ranking.md b/.changeset/skill-refs-exports-fallback-ranking.md deleted file mode 100644 index e7790c2366..0000000000 --- a/.changeset/skill-refs-exports-fallback-ranking.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): keep machine constants off the skill-reference `Exports:` fallback - -When a `.zod.ts` has no module doc block, `build-skill-references.ts` falls back -to listing its exports. That line is TRUE — an accurate list of what the module -exports, which is why #12094 kept the fallback rather than refusing. What was -wrong is the RANKING: the list was whichever five exports happened to be -DECLARED FIRST, and the extraction had no notion of authorable surface, so any -`export const` qualified — including constants whose own names say they are not -for authoring. - -Three of the eleven modules that reach this fallback declare their machine -constants near the top, so three published rows headlined them: - -- `automation/approval.zod.ts` named `DEPRECATED_APPROVER_TYPES`, - `NON_AUTHORABLE_APPROVER_TYPES`, `ORG_MEMBERSHIP_LEVELS` and - `APPROVER_EXPRESSION_ROOTS` — four of its five slots -- `kernel/plugin.zod.ts` named `CORE_PLUGIN_TYPES`, `CONSUMER_INSTALLABLE_TYPES` -- `system/translation.zod.ts` named `LEGACY_OBJECT_FIRST_KEYS` - -`skills/**` is loaded whole into a customer agent's context window and its job -is to teach that agent what it may author, so a row headlining -`DEPRECATED_APPROVER_TYPES` and `NON_AUTHORABLE_APPROVER_TYPES` pointed an -authoring agent at exactly the vocabulary it must not use, with nothing on the -line marking them as such. No gate could see it: `check:skill-refs` compares the -artifact against the generator, and the generator ranked faithfully. - -`SCREAMING_SNAKE` exports are now dropped and source order is kept for what -remains, with the cap of five applied AFTER filtering so the authorable names -waiting behind the constants are promoted rather than the row merely shortened. -A module whose entire export surface is machine constants falls through to no -description at all rather than printing a bare `Exports:`. - -Sorting `*Schema` exports first was considered and NOT taken: on the very row -that motivated this it demotes `ApproverType` — the approver-type enum an author -actually writes — below four schema objects, which is worse by this surface's -own standard. The rule moves to `scripts/lib/export-list.ts` so it can be pinned -without running the generator, and `scripts/export-list.test.ts` enforces it -both as unit cases and as a corpus gate over the checked-in artifacts. diff --git a/.changeset/skill-refs-module-docblock.md b/.changeset/skill-refs-module-docblock.md deleted file mode 100644 index a0bf6d02f6..0000000000 --- a/.changeset/skill-refs-module-docblock.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): publish each skill reference's own module doc block, not the first doc block in the file - -`build-skill-references.ts` derived every `_index.md` pointer description from -the first doc block anywhere in the source file. That is a rule about ORDERING, -not about descriptions: whichever declaration happened to sit nearest the top of -a `.zod.ts` donated its comment to a customer-facing page, and moving a helper -up a file silently rewrote published text. - -This is the defect the docs-site generator fixed by converging on -`findModuleDocBlock()` — the block must start at column 0, precede the first -declaration, and document no symbol. The skill-references generator was never -converted, so the two generators disagreed about the same sources. It now -imports the same selector rather than restating the rule, and `skills/**` is -loaded whole into customer agent context windows, so it was paying the higher -price for the same defect. - -Thirteen pointer rows across seven skills change. The live victim named on the -issue is `system/translation.zod.ts`, whose `objectstack-i18n` entry opened with -"Shared history sentence for every shape in this file." — the comment on a -private `TRANSLATION_HISTORY` string constant, meaningless to the reader and not -a description of the Translation protocol. Twelve rows in that class now fall -through to the existing `Exports: …` line, and one gains a real module -description (`shared/metadata-types.zod.ts`), which also brings it into -agreement with the docs page for the same file. - -Falling back rather than refusing is deliberate: an export list states a true -fact about the file, where the wrong block asserted a false one about its -subject. Whether a `.zod.ts` on this surface should be required to carry a -module doc block at all is a separate authoring question, left open here. diff --git a/.changeset/skill-zod-examples-drop-retired-trigger-phrases.md b/.changeset/skill-zod-examples-drop-retired-trigger-phrases.md deleted file mode 100644 index a7f93d1b80..0000000000 --- a/.changeset/skill-zod-examples-drop-retired-trigger-phrases.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Docs:** `skill.zod.ts`'s two `@example` blocks stop handing the author a retired key that throws on parse (#11026). - -Both TSDoc examples in `packages/spec/src/ai/skill.zod.ts` — the one over `SkillSchema` and the one over `defineSkill` — passed `triggerPhrases`, removed in `@objectstack/spec` 17.0.0 (#3896 audit close-out) and carried as a `retiredKey()` tombstone ~120 lines below the first of them. `defineSkill` calls `SkillSchema.parse()`, so both documented blocks **threw** when run: `invalid_type` at path `triggerPhrases`, expected `never`. A reader copying either block got a refusal on their first move. - -The blocks now demonstrate the tombstone's own prescription instead of contradicting it. That prescription is a **split**, not a rename — routing intent belongs in `triggerConditions` (an AND of context field/operator/value), natural-language intent in `description` / `instructions`, the strings actually put in front of the model — and the `defineSkill` block shows both halves with each named. `tools` is required with no default, so both blocks carry it. Each block also opens with its own `import { defineSkill } from '@objectstack/spec';`, matching the convention the marked SDK examples in `packages/client-react/src` already use, so the block is self-contained as a consumer would resolve it. - -The tombstone and every line of its guidance prose are **unchanged**, verbatim — they were always correct, and they are what the repaired examples now agree with. - -Prose only: no schema shape, no `.describe()` text, no runtime behaviour, no authorable-surface movement, and `check:generated` moves nothing (the generated reference page `content/docs/references/ai/skill.mdx` renders the tombstoned row as `[REMOVED]` and carries neither example). It is graded rather than skipped because the text ships to consumers: the rewritten blocks are present in the built `dist/skill.zod-*.d.ts`, which is what an editor surfaces on hover, and `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts` so the docblock also travels in the npm tarball as source. diff --git a/.changeset/spec-audit-meta-item-member.md b/.changeset/spec-audit-meta-item-member.md deleted file mode 100644 index 68d7059b62..0000000000 --- a/.changeset/spec-audit-meta-item-member.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**`MetadataProtocol` declares the optional `auditMetaItem` member, and the audit door's request/response schemas join the spec** (#11678 — the #11006 maintainer-ruled pattern, 2026-08-22 option B, carried one door over). - -`GET /api/v1/meta/:type/:name/audit` — the ADR-0010 §3.6 compliance trail behind Studio's 审计日志 / Audit log tab — was a step behind the half-declared publish door #11006 adjudicated: **neither** side was declared (`auditMetaItem` appeared nowhere in `packages/spec`), so the REST door reached the verb through `(p as any)` twice (feature-detection guard + call) and its request literal was compiled against nothing. - -Additive, not breaking: - -- `AuditMetaItemRequestSchema` / `AuditMetaItemRequest` — `{ type, name, organizationId?: string | null, limit? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `organizationId` is nullable because the REST door always sends it, possibly `null` (#8747's fail-closed tenant scoping: `null`/absent = env-wide rows only, never every tenant's). `limit` declares no bounds because the implementation clamps to [1, 500] rather than refusing. `environmentId` stays out by the #9741 ruling (transport-level routing key) — and on this door it is not even on the wire any more (#8747 removed it; the implementation never read it). -- `AuditMetaItemResponseSchema` / `AuditMetaItemResponse` — the `{ events: [...] }` body, newest first, with the closed `operation` (save/publish/rollback/delete/reset) and `outcome` (allowed/denied/forced) vocabularies and the ADR-0010 §3.3 `lockState`. The #9426 miss-vs-fault honesty is recorded in the declared types: `{ events: [] }` is the honest answer for a clean trail, a find-less host engine, or an unprovisioned audit table — never for a missing capability (501 before the call) and never for a failed read (propagated, not invented into an empty trail). -- `MetadataProtocol.auditMetaItem?(request: AuditMetaItemRequest): Promise` — optional like its `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error. diff --git a/.changeset/spec-delete-meta-item-request-members.md b/.changeset/spec-delete-meta-item-request-members.md deleted file mode 100644 index 52ccf83083..0000000000 --- a/.changeset/spec-delete-meta-item-request-members.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**`DeleteMetaItemRequestSchema` declares the contract members the REST reset door sends** (#11679 — the #11006 maintainer-ruled pattern on the request-shape half). - -`MetadataProtocol.deleteMetaItem` was declared all along, but its request schema declared 2 of the 8 members `DELETE /api/v1/meta/:type/:name` sends — so the door's call site had to stay behind an `(p as any)` cast (removing it surfaced `TS2353` on six keys, the opposite half of the publish door's `TS2339`), and the one member most worth having a contract — `organizationId`, which selects WHICH overlay row a reset destroys (ADR-0005 org partition; an org-less delete reaches the environment-wide row) — was on the wire with no declaration behind it. - -Additive, not breaking — the five contract-level members join the schema, mirroring the implementation's parameter type in `@objectstack/metadata-protocol`: - -- `organizationId?` — tenant scope for the reset (#8805); load-bearing, decides which row the delete destroys. -- `parentVersion?` — the ADR-0008 optimistic-concurrency pin (REST: the `If-Match` header); absent = last-write-wins. -- `actor?` — identity recorded on the history tombstone row (one producer, #7749); absent = recorded actor-less, never "system" (#4556). -- `state?` — `'active' | 'draft'`; `draft` discards the pending draft overlay only. -- `dropStorage?` — destructive opt-in (default false): also drop the object's physical table (`object` + `active` only; never `sys_`). - -Two wire members stay out, by ruling rather than omission: `environmentId` (transport-level routing key per #9741, layered on by `packages/rest`'s `TransportScopedMetaRequest`) — and there are no internal coordination keys on this door (`_skipSeedApply` is publish-batch-only). diff --git a/.changeset/spec-publish-command-spelling.md b/.changeset/spec-publish-command-spelling.md deleted file mode 100644 index c5d3b2b250..0000000000 --- a/.changeset/spec-publish-command-spelling.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Route the stored-envelope refusal to a command that exists — `os package publish`, not the retired `objectstack publish` (#12223) - -An author who hand-writes one of the seven `STORED_ENVELOPE_KEYS` onto an `api` -declaration is refused, and the refusal tells them where publication state actually -comes from. It named a command that resolves to nothing: - -```text -before: Remove it — publication state is managed by `objectstack publish`, not authored. -after: Remove it — publication state is managed by `os package publish`, not authored. -``` - -`os publish` was the legacy direct-to-environment command, retired with the path that -wrote `sys_environment_revision`. Re-measured on this tree against the **built oclif -`Config`** rather than against docs — loading the CLI's plugin and reading the command -table oclif derives from `dist/commands/**`: **61** ids, of which the only two containing -`publish` are `package publish` and `plugin publish`. There is no bare `publish` id and no -`publish` topic, so the old spelling exits as an unknown command. The message's own -neighbouring sentence already names `publishPackage` as the writer, and -`packages/cli/src/commands/package/publish.ts` is the command that runs it. - -This is the shape #12177 deliberately left alone elsewhere inverted: those sentences are -*about* the removal and are correct as history, while this one is **present tense and -prescriptive** — text an AI author obeys at the moment its write is refused. - -Text only. No accept/reject behaviour changes: the same seven keys are refused on the same -declarations, with the same `unrecognized_keys` upgrade path; only the sentence an author -reads is corrected. The same stale spelling is fixed in the `publisher` doc comment of -`packages/spec/src/cloud/package.zod.ts`, which ships to consumers in the package's type -declarations. diff --git a/.changeset/spec-publish-meta-item-member.md b/.changeset/spec-publish-meta-item-member.md deleted file mode 100644 index 73df48a459..0000000000 --- a/.changeset/spec-publish-meta-item-member.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**`MetadataProtocol` declares the optional `publishMetaItem` member, and `PublishMetaItemRequest` joins the spec** (#11006, maintainer ruling 2026-08-22, option B). - -`POST /api/v1/meta/:type/:name/publish` — the promotion half of Studio's designer save-then-publish loop — was a half-declared door: #7294 declared the response (`PublishMetaItemResponseSchema`) while the request shape and the interface member stayed undeclared, so the one HTTP call site reached the verb through a cast and its request literal was checked by nothing (measured: deleting the cast answers `TS2339` — the cast carried member-existence weight, not request-shape weight). - -Additive, not breaking: - -- `PublishMetaItemRequestSchema` / `PublishMetaItemRequest` — `{ type, name, organizationId?, actor?, message?, packageId? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `packageId` is `string | null` with the absent-vs-`null` distinction documented (absent = match any package; `null` = pin to the package-unbound row). `environmentId` stays out by the #9741 ruling (transport-level routing key; `packages/rest`'s `TransportScopedMetaRequest` layers it on), and the internal `_skipSeedApply` batch-coordination key is deliberately not part of the wire contract. -- `MetadataProtocol.publishMetaItem?(request: PublishMetaItemRequest): Promise` — optional like its `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error. - -The follow-up that removes the `(p as any)` cast at the REST call site rides the engine lane; this change only declares the contract. diff --git a/.changeset/spec-refusal-message-issue-ids.md b/.changeset/spec-refusal-message-issue-ids.md deleted file mode 100644 index 3d4159315e..0000000000 --- a/.changeset/spec-refusal-message-issue-ids.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Strip internal tracker ids from the refusal messages an author actually reads (#12124) - -Fifteen zod refusal messages across nine `packages/spec/src` files ended a sentence with -an internal issue id. Those strings are printed **at** the author, verbatim, the moment -their metadata is refused — by `os validate`, by a publish gate, by a parse — and the -reader has no tracker to open. A `#NNNN` there is a citation-shaped token that resolves to -nothing, in the one place the sentence most needs to be actionable. - -```text -before: A field condition's keys are field names, never $-prefixed operators (#7711). -after: A field condition's keys are field names, never $-prefixed operators. - -before: ... refused at authoring time because the query path refuses it too - (400 INVALID_FILTER, #5869). -after: ... refused at authoring time because the query path refuses it too - (400 INVALID_FILTER). -``` - -Where a customer-resolvable anchor already carried the meaning it was kept and the id -dropped beside it: the second example above keeps `400 INVALID_FILTER`, which is the token -an author can actually match their query-path error against. Where the reference is -load-bearing for an *internal* reader only, it moved to an adjacent `//` comment (four -sites: the endpoint publish gate's two `#5040` section pointers, the summary-field rule's -founding incident, and the interim renderer precedence behind the doubled-redirect -refusal). Elsewhere it is simply gone — git history keeps the anchor. - -Text only. **No accept/reject behaviour changes**: the same inputs are refused on the same -schemas with the same issue `code`, `path` and error shape; only the sentence changes. -Test twins that pinned the old wording now pin the new text plus a negative assertion that -the message carries no issue id at all. - -The convention is held mechanically from here — `check:doc-authoring` gained a third rule -that parses `packages/spec/src` and reds on an id in any refusal-message string. It parses -rather than scanning lines because refusal prose here is written as multi-line string -concatenation: a single-line `message:.*#[0-9]{3,5}` grep sees 1 of the 16 literals. diff --git a/.changeset/spec-routes-doc-redirect-http-server.md b/.changeset/spec-routes-doc-redirect-http-server.md deleted file mode 100644 index 7bf280a97c..0000000000 --- a/.changeset/spec-routes-doc-redirect-http-server.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): stop recommending `contributes.routes` for code-handler endpoints — redirect the author-facing materials to the imperative `http.server` mount (#10726) - -`contributes.routes` (`packages/spec/src/kernel/manifest.zod.ts`) has **zero readers** -monorepo-wide: the only non-test read of `manifest.contributes` anywhere reads `kinds`, not -`routes` (`packages/objectql/src/engine.ts:4499`). An author following the shipped guidance -writes a `contributes.routes` entry, gets a clean parse, and serves nothing — ADR-0049's -silent no-op with a published recommendation attached. - -Per the maintainer ruling (2026-08-22, Option B), the four author-facing materials that -recommended the key are corrected **now**, ahead of and independent of the key's removal: - -- `skills/objectstack-api/SKILL.md` — the `apis:` decision table's second row now names the - imperative `http.server` mount and states explicitly that `contributes.routes` parses and - serves nothing. -- `packages/spec/src/api/dispatcher.zod.ts` — the HttpDispatcher protocol doc no longer - claims it "supports dynamic route registration from plugins via contributes.routes". -- `docs/adr/0088-metadata-kind-admission-and-retirement.md` — the `router` retirement row no - longer credits `contributes.routes` as a delivered form. The `router` KIND's retirement is - unaffected: its delivered forms are the imperative mount and, since #5040, declarative - `apis:`. -- `packages/spec/src/ui/app.zod.ts` — the `App.apis` removal message no longer sends - migrators to `contributes.routes`. - -The replacement recommendation was verified live on `main` before it was written, so this is -not a redirect to a second dead form: `http.server` is registered by -`packages/plugins/plugin-hono-server/src/hono-plugin.ts:271` (`providesServices` at :228) and -mounted by real in-tree consumers — `examples/app-showcase/src/system/server/recalc-endpoint.ts` -resolves it on `kernel:ready` and mounts `POST /api/v1/showcase/recalc`, and -`plugin-approvals`, `plugin-sharing`, `cloud-connection` and the CLI's `serve` all resolve the -same service. - -Schema-only change to prose: no key is added, removed or re-typed here. The -`contributes.routes` tombstone itself is #10724's, which is blocked on the `cloud` census -(#10812) — this changeset carries only the doc corrections the ruling ordered not to wait. -The two `content/docs/references/**` pages are the regenerated projection of the two `.zod.ts` -edits (`pnpm --filter @objectstack/spec gen:docs`), not hand edits. diff --git a/.changeset/spec-src-tsdoc-example-surface.md b/.changeset/spec-src-tsdoc-example-surface.md deleted file mode 100644 index 4db678491a..0000000000 --- a/.changeset/spec-src-tsdoc-example-surface.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Type-check the TSDoc code examples in `packages/spec/src` — the ADR-0033 authoring channel - -`check:skill-examples` now scans a third surface: `packages/spec/src/**` TSDoc blocks, -alongside `skills/` + `content/docs` and the client SDK sources. A schema's `@example` -is what an AI author copies, and it sits inches from the tombstone written for that same -reader — but nothing compiled it, so an example could name a retired key, a renamed -export or a tightened union and stay green indefinitely. (`check:doc-formula-expressions` -walks the same blocks but judges *formula expressions*, never TypeScript.) - -This is a new `SURFACES` entry, not a second extractor: the existing marker/tsc pipeline -was already surface-parameterised. Compilation stays **opt-in** via the `os:check` -marker, which matters more on this root than anywhere else — of its 146 fenced ts blocks, -128 carry no imports of their own and three more are ellipsis-placeholder prose -(`defineStack({ ... })`) that is correct as documentation and can never compile. Six -self-contained blocks are marked and now compile against the built declarations. - -The marker is an inert HTML comment, as on the other surfaces, and is stripped from -generated reference pages rather than published to them. diff --git a/.changeset/sql-emission-identity-one-source.md b/.changeset/sql-emission-identity-one-source.md deleted file mode 100644 index 55e93ea10a..0000000000 --- a/.changeset/sql-emission-identity-one-source.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/spec': minor ---- - -feat(driver-sql,spec): one emission-identity source — `redshift`/`cockroachdb` DDL is refused by name, `pgnative` joins the Postgres family (#11991, landing the #11756 ruling) - -**BREAKING** accept-set narrowing on `SqlDriver`'s DDL path, shipped as `minor` -under the repo's launch-window convention for breaking changes — and a widening -in the same edit, so read both directions. - -Maintainer ruling, 2026-08-25 (#11756, verbatim 「同意」 on 「C,但 pgnative -归入 Postgres 家族」). Three knex clients speak the PostgreSQL wire protocol -without being the PostgreSQL this driver emits DDL for, and the driver had no -opinion about any of them — it simply let knex compile whatever it compiles. -Measured on `origin/main` before the change, one `CREATE TABLE` per client: - -``` -pg / pgnative / cockroachdb "body" text primary key inline -redshift "body" varchar(max) primary key in a separate ALTER TABLE -``` - -So on Redshift the pre-ruling behaviour was not a failure — it was a table of a -different shape, built quietly, with the deployment finding out when it wrote -data into it. - -**Refused (narrowing).** A `redshift` or `cockroachdb` datasource that reaches -schema DDL — `initObjects` / `syncSchema`, `dropTable`, `rotateShards`, -`reconcileManagedSchema` — now gets an immediate -`UnsupportedDialectEmissionError`: code `SQL_DIALECT_EMISSION_UNSUPPORTED` -(newly registered under `@objectstack/driver-sql` in `ERROR_CODE_LEDGER`), -HTTP status `501`, and a message naming the client, every client the driver -DOES emit for, and the supported way to keep the database — manage its schema -out-of-band and boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`. It throws -before any statement is issued, so nothing is half-built. Connection, the -connect bound and the #11389 calendar-day parser are untouched: the boundary is -DDL only, drawn where behaviour was actually verified. - -**Recognised (widening).** `pgnative` is now a member of the Postgres emission -family — knex resolves it to the same `postgresql` dialect and the same query -compiler as `pg`, differing only in which npm binding carries the bytes. It was -previously in neither the emission set nor the wire table, so a `date` column -got a bare `CURRENT_TIMESTAMP` default (the server's calendar day, the exact -#11550 defect) and no calendar-day parser. It now behaves identically to `pg` -and carries the #11389 pin. - -**One source of truth.** The pair `cockroachdb, redshift` used to be -hand-written into the connect-timeout table and again into the wire table. It -is now declared once, as `POSTGRES_WIRE_ONLY_CLIENTS`, and both tables extend -the emission sets through it — as does the refusal, which reads the same set. -Adding a future pg-wire client is one edit, and the three answers cannot drift -apart. `mariadb` is explicitly out of the ruling's scope and keeps its third -state: neither recognised nor refused. - - diff --git a/.changeset/sqlite-audit-column-canonical-default.md b/.changeset/sqlite-audit-column-canonical-default.md deleted file mode 100644 index 021619067b..0000000000 --- a/.changeset/sqlite-audit-column-canonical-default.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -**Fix:** on SQLite the builtin `created_at`/`updated_at` audit columns now take the same canonical ISO-8601 `DEFAULT` a declared `Field.datetime` NOW() column in the same table already gets (#11321). - -`createAuditTimestampColumn`'s non-MySQL branch was `table.timestamp(name).defaultTo(this.knex.fn.now())`. On SQLite `knex.fn.now()` compiles to an unqualified `CURRENT_TIMESTAMP`, which renders a zone-**naive**, space-separated, second-precision `'YYYY-MM-DD HH:MM:SS'`. A declared `defaultValue: 'NOW()'` field in the **same table** already got `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))` from `nowColumnDefault`, so one table carried two spellings of one conceptual value: - -``` -created_at "2026-08-23 14:54:17" <- builtin audit (naive) -when "2026-08-23T14:54:17.796Z" <- declared field (canonical) -``` - -That naive spelling is the one `updatedAtStamp()`'s own docblock condemns: `Date.parse` reads a zone-less string as LOCAL time, silently shifting the instant by the host offset on a non-UTC runtime. It is also the pre-canonical storage form `backfillCanonicalDatetimes` exists to converge — reached here by a path writing it *today*, not by legacy data. - -The SQLite branch is now routed through `nowColumnDefault('datetime')` — the existing single source for "what does NOW() mean in DDL on this dialect" — rather than restating the expression, so the two cannot drift apart again. **Postgres and MySQL are untouched**: `knex.fn.now()` on Postgres is a real zone-aware `TIMESTAMP` that never had the ambiguity, and MySQL keeps the `now(3)` precision match from #11224. - -`rebuildSqliteTablePatched` — the whole-table rebuild SQLite drift reconciliation uses — re-emitted the audit default itself as `knex.fn.now()`. That method is SQLite-only, so leaving it would have silently **reverted** a canonically-created table the moment any unrelated drift (a relaxed NOT NULL, an orphaned column) triggered a rebuild. Fixed in the same change: a rebuild hands back the column `initObjects` would have built. - -**Graded `patch`, not `minor`, on a measurement rather than a judgement.** The change alters emitted DDL, so the question that decides the grade is what it does to databases that already exist: - -- **Existing tables are not altered.** Both call sites are `CREATE TABLE` only; `initObjects`' `alterTable` branch adds declared fields and never the audit columns. A table already on disk keeps `default CURRENT_TIMESTAMP`. -- **They do not start reporting drift.** Measured on live in-memory SQLite through the real `detectManagedDrift` entry point against a table carrying the old default: **zero** entries. Two independent guards — `BUILTIN_COLUMNS` skips `created_at`/`updated_at` in both of `diffManagedTable`'s loops, and the only `default_mismatch` producer is the #4560 runtime-token check, for which `isAppResolvedDefaultToken('NOW()')` is pinned `false`. The measurement carries a positive control: in the same call on the same table, drift reports `unmapped_column` **and** `default_mismatch` for a `current_user` column, so the default-reading dimension is demonstrably live and still says nothing about the audit columns. -- **Rows already written naive keep reading correctly.** `formatOutput`'s `repairNaiveUtcAuditTimestamp` folds them to canonical on read — the same disposition `nowColumnDefault` already documents for declared fields. - -So no deployment changes behaviour on upgrade; only newly-created tables get the corrected default. - -The population this actually repairs is wider than "writes that bypass the driver". `stampInsertTimestamps` fills both columns app-side, but it gates on `tablesWithTimestamps`, which only DDL-running paths populate. On the documented `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` posture, `registerObjectMetadata` (the DDL-free registration door) deliberately does not touch that set — so the set is empty, the stamp returns early, and **the driver's own `create()` door reaches the column DEFAULT**. Measured, one table, one row per boot posture: `created_at "2026-08-23T14:54:17.791Z"` on a normal boot versus `"2026-08-23 14:54:17"` on a `skipSchemaSync` boot, with the declared NOW() sibling canonical in both — because its canonical shape lives in the column DEFAULT rather than in an app-side stamp. That asymmetry is the argument for fixing this in DDL, and it is now closed. diff --git a/.changeset/sqlite-json-injective-codec.md b/.changeset/sqlite-json-injective-codec.md deleted file mode 100644 index 1a44e740c3..0000000000 --- a/.changeset/sqlite-json-injective-codec.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -fix(driver-sql): make the SQLite `Field.json` codec injective — one encoding across all three dialects (#12380) - -**BREAKING** storage-format change for `Field.json` columns on SQLite (and the -SQLite-backed `driver-turso` / `driver-sqlite-wasm`, which inherit this codec), -shipped as `minor` under the repo's launch-window convention for breaking -changes. Postgres and MySQL are **untouched** — this makes SQLite match what -they have always done. - -`formatInput` now `JSON.stringify`s every `Field.json` value on every dialect, -and `formatOutput` parses it back. That **deletes a dialect branch rather than -adding one**. - -## What was wrong - -Measured 2026-08-26 through the driver boundary on live SQLite, live Postgres -16.13 and live MySQL 8.0.46, with each stored cell read back through a separate -raw catalog query: **Postgres and MySQL were 17/17 faithful; SQLite was 13/17 -type-changed.** Three independent mechanisms, only two of them reversible: - -1. **Read-side.** `formatOutput` `JSON.parse`s every string in a json column, so - a stored string whose *content* is valid JSON came back type-changed — - `'true'` → boolean, `'null'` → null, `'[]'` → array, `'{"a":1}'` → object. -2. **Write-side.** The column is declared type `json`, which contains none of - `INT`/`CHAR`/`CLOB`/`TEXT`/`BLOB`/`REAL`/`FLOA`/`DOUB`, so SQLite's affinity - rules fall through to **NUMERIC** and a bound number-like string was converted - to INTEGER/REAL *before storage*: `'123'`, `' 123 '`, `'0123'`, `'1e5'`, - `'1.0'`, `'-0'` were destroyed on disk. ⛔ Not reversible. -3. **Native booleans.** `true` was stored as INTEGER 1 and read back as the - number `1` — `formatOutput`'s `booleanFields` pass is keyed to declared - `Field.boolean` *columns*, not to booleans inside a json payload. - -The contract decides which dialect is right, not strictness: `json`'s stored -contract is `z.unknown()` because *"openness is now an explicit decision, not an -accident of nobody checking"* (`packages/spec/src/data/field-value.zod.ts`). An -explicitly-open contract admits both `123` and `'123'` as legal values of one -field, so no driver may collapse them onto one representation. - -The live consumer is `sys_setting.value`, which is `Field.json`, and the settings -service persists verbatim and reads back with no re-coercion by declared type — -so the driver's answer is what the caller gets, on the dialect tenant -environments actually run. - -## What changes on disk, and what does not - -The DDL is unchanged — the column is still declared `json`, so NUMERIC affinity -is still in force. The encoded form defeats it because a string's encoding -carries its quotes (`'123'` → `"123"`, which is not a numeric literal). Pinned -live rather than reasoned. - -For **new** writes the on-disk delta is exactly two classes: - -- **strings** are now quoted JSON text; -- **booleans** are now TEXT `true`/`false` instead of INTEGER `1`/`0`. - -Objects, arrays, `null` and **numbers** are byte-identical to before (`123` bound -as a number and `"123"` bound as text both land as INTEGER `123`). - -⚠️ An out-of-band reader of a SQLite file — anything reading the table with its -own SQL rather than through this driver — now sees quoted JSON text where it saw -a bare value. - -## The migration, and the limits of what it can recover - -`backfillCanonicalJsonEncoding` runs on `syncSchema`/`initObjects` for existing -tables, the same posture and shape as the `backfillCanonicalDatetimes` and -`backfillCanonicalTimes` storage-format migrations beside it: one `UPDATE` per -column, failures logged and swallowed, correctness never contingent on it having -run. It converts the **one on-disk class the pre-fix encoding left unambiguous** — -a TEXT cell that is not valid JSON, which nothing but a stored plain string could -have produced — into its quoted form. Idempotent by construction: the `WHERE` is -the exact complement of the `SET`'s output, so a converted row cannot match again -and re-running costs one scan and zero writes. - -⛔ **It does not guess, because the rest cannot be guessed**, and two classes are -therefore left exactly as they are: - -- **INTEGER/REAL cells.** A number, a boolean, and a number-like string eaten by - NUMERIC affinity are the *same bytes* on disk — `123` the number and `'123'` - the string are one INTEGER `123`. No migration can know which was written. -- **TEXT cells that already parse.** A stored object `{"a":1}` and a stored - *string* `'{"a":1}'` were byte-identical before this change. Re-quoting them - would turn every legacy object and array into a string — corrupting the common - case to guess at the rare one. - -⇒ Those rows read after this change exactly as they read before it. **The class -stops growing; it is not retroactively repaired.** Maintainer ruling 2026-08-26, -with that cost accepted explicitly. - -The migration changes **no read**: a legacy plain string reads back as that -string before it runs (via `formatOutput`'s parse fallback, kept for exactly this -reason and now documented as the pre-#12380 read-side repair) and after it runs. -It is a canonicalisation that makes the on-disk format uniform and injective -going forward, not a repair of something that reads wrong today. - -## What upgraders may notice - -Values that were being **corrupted** now read back correctly. Code that adapted -to the corruption is what changes underneath: a boolean `Field.json` value that -read back as `1` now reads back as `true`, and a string whose content is valid -JSON now reads back as that string instead of the structure it looked like. -Filters are unaffected — every scalar comparison operator on a json column is -already refused by the driver (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), so no -predicate could have been keyed to the old stored text. - - diff --git a/.changeset/sqlite-primary-key-not-unique-introspection.md b/.changeset/sqlite-primary-key-not-unique-introspection.md deleted file mode 100644 index 155ee97980..0000000000 --- a/.changeset/sqlite-primary-key-not-unique-introspection.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`introspectUniqueConstraints` no longer reports a PRIMARY KEY column as unique on SQLite, so all three dialects now answer the same question (#11654). The SQLite arm read `PRAGMA index_list` keyed only on `unique === 1`, and SQLite materialises a non-INTEGER primary key as a unique auto-index — so a `varchar` key was reported while the Postgres and MySQL arms, which filter on `CONSTRAINT_TYPE = 'UNIQUE'`, never see a primary key at all. It also disagreed with itself: an `INTEGER PRIMARY KEY` is a rowid alias with no auto-index, so the same logical schema produced a different `isUnique` flag depending only on the declared type of its key. The arm now skips `origin: 'pk'` index rows, which closes both gaps at once (`WITHOUT ROWID` keys included). - -This continues #11202's convention: `isUnique` means a *declared single-column UNIQUE constraint*. Nothing is lost — primary-key membership is still reported losslessly through `IntrospectedTable.primaryKeys` and `IntrospectedColumn.primaryKey`. The filter is on the index's `origin`, not on whether the column is in the key, so a key column that separately carries its own unique index stays flagged. - -Consumer-visible effect: `introspectedSchemaToObjects` in `@objectstack/objectql` turns this flag into a drafted field's `unique: true`, so a federated-object draft (ADR-0015) taken from a SQLite table no longer gains a redundant `unique: true` on its key column that the same table drafted through Postgres or MySQL never had. Drivers extending `SqlDriver` (`driver-turso`, `driver-sqlite-wasm`) inherit the change. diff --git a/.changeset/sqlite-rebuild-applied-honesty.md b/.changeset/sqlite-rebuild-applied-honesty.md deleted file mode 100644 index 06a2040c3d..0000000000 --- a/.changeset/sqlite-rebuild-applied-honesty.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -**Fix:** on SQLite, `applyMigrationEntries` no longer reports an op as **applied** just because a table rebuild ran (#11722). - -`SqlDriver.applyMigrationEntries` splits by dialect, and the two arms disagreed about what `applied` means. The in-place arm (Postgres / MySQL) asks per entry and believes the answer — `applyDriftOpInPlace` returns `false` for an op its dialect cannot perform, and the entry goes to `skipped`. The SQLite arm did not ask at all: it called `rebuildSqliteTablePatched(table, ents)` and then `applied.push(...ents)`, every entry, unconditionally. But that rebuild honours exactly four op types — `relax_not_null`, `tighten_not_null`, `drop_column`, `drop_column_default` — and silently ignores everything else; its own docblock already said so for the varchar ops. An ignored op was still reported applied. - -**The failure mode is a false green, not an error.** Nothing throws and nothing is skipped, so every consumer announces work that never happened: `reconcileAndWarnDrift` logs `auto-reconciled on `, and the artifact boot gate prints `↪ migrated `. The finding is still physically present, so the next boot detects it again, reports drift again, and "migrates" it again — a loop with no failing signal anywhere in it. - -**What changes.** `rebuildSqliteTablePatched` now returns the entries it actually acted on, built in the same pass that fills the four column sets it already partitioned into — deliberately not a second list of op types to keep in sync, so the returned set cannot drift from the work done. The caller reports those as `applied` and routes the remainder to `skipped`, logging it in the **same sentence** the in-place arm uses for an op its dialect cannot do (` on
    . is unsupported on dialect 'sqlite' — skipped`), so one greppable line covers all three dialects. `@objectstack/driver-sqlite-wasm` and `@objectstack/driver-turso` extend `SqlDriver` without overriding either method, so both inherit the correction. - -**What deliberately does not change.** No op does anything different — this moves only what is *reported*. In particular the rebuild still runs for the whole table even when it honours nothing: it re-materializes every kept column's default (#11321, #4560) and the full declared index set from metadata (#3696), so it is not a no-op, and suppressing it would change what the reconciler DOES rather than what it says it did. `applied`/`skipped` remains a reported partition consumed by log lines and CLI counts; it is not an accept/reject door, and no public surface widens. - -**Latent when found, and fixed anyway.** The gap was unreachable at the time of the fix, held closed from two independent directions neither of which knew it was holding it: `enforcesVarcharLength` excludes SQLite, so the differ never emits `widen_varchar`/`narrow_varchar` there, and `multiValueColumnTypeIsLoadBearing` excludes SQLite for an unrelated measured reason, so #11535's `manual_column_type_change` is never emitted there either. The next column op that is not SQLite-rebuildable would have opened it silently. `manual_column_type_change`'s own docblock states that `applyMigrationEntries` reports it "skipped, never applied" — measured on Postgres and MySQL; that sentence is now also true on SQLite, and the docblock says so. - -Pinned by `packages/drivers/driver-sql/src/sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts`, which constructs the reachability rather than waiting for it — it substitutes only the differ's dialect guard, handing entries straight to the public `applyMigrationEntries` seam that `os migrate apply` and the artifact boot gate both call, with a real driver, dialect and database throughout. All five cases fail on the pre-fix tree, including the consumer-level one that catches `auto-reconciled` being logged for an op that never happened. diff --git a/.changeset/stale-fill-i18n-leaf-repair.md b/.changeset/stale-fill-i18n-leaf-repair.md deleted file mode 100644 index 856df7b691..0000000000 --- a/.changeset/stale-fill-i18n-leaf-repair.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/plugin-webhooks": patch -"@objectstack/service-messaging": patch ---- - -fix(i18n): re-translate the five leaves that served a superseded source revision (#12065) - -`os i18n extract` merges gaps only, so a revised source string leaves the previous -revision standing in every translated locale — in sync by key, green under -`check:i18n` and counted as translated by `check:i18n-coverage`. The five leaves -`check:i18n-stale-fill` froze in its baseline are re-translated here from the -**current** `en` source, and the baseline is ratcheted to empty in the same change. - -User-visible admin/Setup help text changes in `es-ES`, `ja-JP` and `zh-CN`: - -- `dataset.fields.measures.helpText` (metadata forms) — all three locales promised a - `"certified"` governance flag that was removed from the declaration in 16.0. -- `sys_webhook.fields.method.help` — all three locales served the pre-revision method - enumeration after the source became a prose description. -- `sys_webhook.pluralLabel` — `ja-JP` was an untranslated Latin fill and is now - Japanese; `zh-CN` keeps `Webhook`, which is the term this bundle's own Chinese prose - uses and which carries no plural inflection. -- `sys_http_delivery.fields.attempts.help` — `es-ES` / `ja-JP` held an English fill and - `zh-CN` a translation of the same superseded source; all three now carry the - PARKED / terminal-row clause the source documents. -- `sys_notification_subscription.fields.principal.help` — the selector list was missing - the `owner_of:object:id` and bare-email forms in all three locales. - -No schema, export or runtime behaviour changes: translated-locale leaf values only, -plus the shrink-only ratchet baseline. diff --git a/.changeset/stamp-organization-on-notification-writes.md b/.changeset/stamp-organization-on-notification-writes.md deleted file mode 100644 index 158a2d3263..0000000000 --- a/.changeset/stamp-organization-on-notification-writes.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/service-automation': patch -'@objectstack/service-messaging': patch ---- - -Stamp `organization_id` on flow-produced notifications and on `markRead` -receipts, so the notification family stops writing org-less rows - -An application project's read-only inventory found `sys_inbox_message`, -`sys_notification`, `sys_notification_receipt` and `sys_notification_delivery` -carrying `organization_id = NULL` on **100%** of their rows — existing rows and -same-day new ones alike, while `sys_approval_request` in the same database -carried an organization on every row. Ruled a gap, not a design choice. - -Everything below the messaging ingress was already threaded: `emit()` stamps the -`sys_notification` event, the inbox channel stamps `sys_inbox_message` and its -`delivered` receipt, and the outbox carries the value onto -`sys_notification_delivery`. Each of them reads `EmitInput.organizationId` — -and the `notify` flow node, the dominant producer, never supplied it. Its local -structural mirror of `emit()` did not even declare the field, so the value could -not have been passed. One missing argument, four tables at 100% null. - -The node now threads the organization from the run's own acting context -(`AutomationContext.tenantId`), the same source the `collab.mention` producer in -`@objectstack/plugin-audit` already uses, so the two notification producers agree -about whose organization a notification carries. - -A second producer of the same table is fixed alongside it: the `read` receipt -`markRead` inserts — written when a user reads a notification whose delivered -receipt never landed — named no organization at all. It now carries the -organization of the `sys_notification` row it is about. - -There is deliberately **no fallback limb** in either producer: not "the current -organization", not the install's first organization, not the recipient's first -membership. A run with no organization in scope still emits and still writes its -rows, and the `notify` node warns audibly naming the topic and the consequence. -A wrong `organization_id` is worse than a null — a null is visibly missing, -while a wrong value is silently authoritative to every report, export and -cleanup script that filters by organization. - -Forward-stamping only. Existing rows are not backfilled and no migration ships. diff --git a/.changeset/standalone-project-resolution-declared.md b/.changeset/standalone-project-resolution-declared.md deleted file mode 100644 index 05c15074e8..0000000000 --- a/.changeset/standalone-project-resolution-declared.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/runtime": patch -"@objectstack/cli": patch ---- - -Ship a **declared** `api.projectResolution` from the standalone boot path (#11999) - -`@objectstack/runtime`'s `createStandaloneStack()` / `createDefaultHostConfig()` -returned `api: { enableProjectScoping: false, projectResolution: 'none' }`, and -`os serve` forwarded it unchanged. `'none'` is not a member of the declared enum: -`RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) declares -`z.enum(['required', 'optional', 'auto'])`. Three packages disagreed about this -key's vocabulary, and the disagreement survived because nothing ever executed -the schema — `RestServer` cast its config instead of parsing it. - -`StandaloneStackResult['api']` now declares, and the factory now emits, -`projectResolution: 'auto'`. - -**Behaviour on the routing path is unchanged, and that is measured, not assumed.** -Every reader that acts on this key is gated on `enableProjectScoping` first: -`RestServer.registerRoutes` takes its `else` arm, `mountAndRecordDirectRoutes` -mounts `[versionedBase]`, and the Dispatcher plugin's two -`enableProjectScoping && … === 'required'` guards short-circuit. With scoping off -the strategy really is moot for routing — which is why this migrates the value -rather than teaching the enum a fourth member. - -**One reader is not gated, and that is the user-visible fix.** `RestServer`'s -discovery handler copies `api.projectResolution` into -`discovery.scoping.resolution` unconditionally, and `DiscoverySchema` declares -that field as the same three-member enum. So `GET /api/v1` on every `os serve` -boot advertised a payload the platform's own schema rejects. Clients that -validate discovery — or switch on `scoping.resolution` — now receive a declared -value. - -Both halves are pinned rather than described: `merge-boot-config.test.ts` parses -the CLI's real boot block against `RestApiConfigSchema` and against the discovery -field's enum, and `standalone-stack.test.ts` parses the block the factory -actually returns. Each pin asserts the refusal of `'none'` alongside the -acceptance of `'auto'`, so it can be seen to say no. The CLI constant is now -typed as `StandaloneStackResult['api']`, so it can no longer drift from the -producer without failing `tsc`. diff --git a/.changeset/storage-tombstone-download-live-holder.md b/.changeset/storage-tombstone-download-live-holder.md deleted file mode 100644 index 1e735009af..0000000000 --- a/.changeset/storage-tombstone-download-live-holder.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/service-storage": patch ---- - -**Fix:** a tombstoned `sys_file` that something still holds is downloadable again — no 30-day 404 in between (#10246). - -Re-pointing a `sys_attachment` join row onto a file inside its 30-day grace-window tombstone has always been byte-safe: the reap guard re-verifies references at sweep time, finds the new holder, un-tombstones the row and vetoes the reap. But the sweep is the only thing that ever asked, and `sys_file`'s declared lifecycle (`ttl { field: 'deleted_at', expireAfter: '30d' }`) nominates a tombstone only **after** the window expires — measured candidates inside the window: `[]`. So the file simply sat at `status='deleted'` while `GET /api/v1/storage/files/:fileId` and `/files/:fileId/url` refused anything not `committed`. A live attachment could point at a file that 404s for up to 30 days and then silently starts working. - -**What changed:** the two download endpoints stop treating the tombstone as the last word. They now ask the reap guard's own `findFileHolder` — the single definition of "is anything still holding this file?", a union over `sys_attachment` join rows and the `ref_*` ownership columns — and serve the file for exactly as long as that answers yes. - -**What did not change**, deliberately: - -- **No lifecycle verb was added.** There is no un-tombstone, revive or resurrect on the read path; the download writes nothing to the row. Revival remains solely the sweep guard's, which is why the fix is a read-side predicate and not a second revival mechanism (the duplicate-mechanism hazard #10241 avoided). The tombstone stays, and the sweep still reaps when the last holder goes. -- **`pending` is still refused.** Only the `deleted` limb widened; an upload that was never completed has no bytes to promise. -- **Authorization is untouched.** A served tombstone goes through the same `authorizeFileRead` gate as any other file — `AUTH_REQUIRED` (401) and `ATTACHMENT_DOWNLOAD_DENIED` / `FILE_DOWNLOAD_DENIED` (403) are unaffected. Servability is not authorization. -- **Bare kernels are unaffected.** With no data engine there is no holder question to ask, so tombstones stay refused exactly as before. - -The read side and the sweep now answer the same question from the same code, so a file the download path serves is by construction a file the next sweep would veto rather than reap — and the instant the last holder goes, both flip together. That pair is what the new tests pin; the 404 text on the refusal changed from "File not found or not committed" to "File not found or not downloadable" to match (the `FILE_NOT_FOUND` code is unchanged). diff --git a/.changeset/string-family-maxlength-varchar.md b/.changeset/string-family-maxlength-varchar.md deleted file mode 100644 index 9ae926f9d0..0000000000 --- a/.changeset/string-family-maxlength-varchar.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -driver-sql: a string field's declared `maxLength` now shapes the column it gets - -`createColumn` mapped the string family — `string` / `email` / `url` / `phone` / -`password` — with a bare `table.string(name)`, so every column took knex's -default width of 255 and the field's own `maxLength` was never read. A field -declaring a wider bound got a narrower column, and on a dialect that enforces -`varchar` length the write was refused: measured through the driver's own -`initObjects` on MySQL 8.0.46 and Postgres 16, a 300-character value written to -a `maxLength: 1024` column came back `ER_DATA_TOO_LONG` and `22001 value too -long for type character varying(255)` respectively. `schema-drift.ts` has always -treated `varchar(field.maxLength)` as the expected physical shape, so every such -column also reported permanent drift against a table the driver had just -created. - -**This changes emitted DDL for existing declarations.** A field declaring -`maxLength` now gets `varchar(maxLength)` in both directions — wider *and* -narrower than 255. Only newly created columns are affected: `createColumn` runs -on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, never on a column that already -holds rows, so nothing is truncated and no existing column is rewritten. -Narrowing a populated column remains what it was — the `narrow_varchar` drift -op, category `destructive`, behind `os migrate apply --allow-destructive`. - -A declared bound above 16383 characters (MySQL's utf8mb4 `varchar` ceiling) -makes the column `TEXT` rather than clamping it, since a clamp would reinstate -the same defect. Fields declaring no `maxLength`, or a malformed one, keep -`varchar(255)` exactly as before. `lookup` / `user`, `autonumber`, and the -catch-all branch are deliberately unchanged — none of them stores the value the -declared bound describes. - -Two matching corrections in `schema-drift.ts`, so the differ and the emitter -agree on which declarations count: a `maxLength` that is not a positive integer -is no longer read as a bound (`maxLength: 0` planned a destructive `varchar(0)` -ALTER), and a MySQL `TEXT` column is no longer diffed as a `varchar` 65535 wide -— MySQL reports `character_maximum_length` 65535 for `TEXT` where Postgres -reports NULL, so on MySQL alone every bounded unkeyed text column had been -reporting a permanent destructive `narrow_varchar` against itself. diff --git a/.changeset/success-threshold-binds-from-every-failed-status.md b/.changeset/success-threshold-binds-from-every-failed-status.md deleted file mode 100644 index 6b97768923..0000000000 --- a/.changeset/success-threshold-binds-from-every-failed-status.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/core": patch ---- - -fix(core): `successThreshold` now binds from every status that records a failure, so a declared count above 2 stops being unreachable (#11955) - -`PluginHealthMonitor` consulted `successThreshold` only while a plugin's status -was `unhealthy` or `degraded`. The first success in a recovery wrote -`recovering` — a status that gate did not name — so the **second** success took -the outer `else` and went straight to `healthy` without the counter being read -at all. `failed` was in neither set either, so a plugin whose check threw -recovered on its **first** success. - -The declared value was therefore capped in practice: - -| Status when the successes start | Consecutive successes actually required | -| :--- | :--- | -| `unhealthy` / `degraded` | 2, whatever `successThreshold` said | -| `failed` / `recovering` | 1, whatever `successThreshold` said | - -A declared `successThreshold: 5` was indistinguishable from `2`. The default is -`1`, which is exactly the value at which the defect is invisible — every -declared value above it was the one that misbehaved. - -The counter is now consulted on the way out of every status that records an -observed failure — `degraded`, `unhealthy`, `failed` and `recovering` — so -`successThreshold: N` requires N consecutive successes from each of them, as -its declaration says ("Consecutive successes needed to mark healthy"). The gate -is a map that is exhaustive over `PluginHealthStatus`, so a status added to the -spec fails to compile until it is placed on one side or the other; that is what -`recovering` slipped through before. - -`healthy` and `unknown` still promote on the first success, deliberately: the -count is declared as a **recovery** criterion ("Number of consecutive successes -to recover from unhealthy state") and neither of those records a failure to -recover from — `unknown` is the status `registerPlugin` writes before any check -has run. - -**Behaviour change, only for configs that declare `successThreshold` above 1.** -At the default `1` every route is byte-for-byte what it was: one success has -always been enough and still is. A plugin declaring a higher count now takes -the number of consecutive successes it asked for before it is reported -`healthy`, including after a `failed` round and after an `autoRestart`. - -This also makes #11852's `successCounters` reset load-bearing. That fix cleared -the counter on the thrown failure route, and could not be pinned: the counter's -only read site was unreachable with a stale non-zero value, so any test would -have passed for the wrong reason. With `failed` gated on the counter, a throw -that interrupts a recovery now demonstrably starts the count over. diff --git a/.changeset/surface-context-contract-1610.md b/.changeset/surface-context-contract-1610.md deleted file mode 100644 index 46ffc0a54a..0000000000 --- a/.changeset/surface-context-contract-1610.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`ToolExecutionContext.surfaceContext` (cloud#1610): an optional, advisory description of what the user is currently discussing — Studio pillar, the selected artifact WITH its type discriminator (page/object/dashboard/report), an optional finer selection, and the canvas mode. Strictly additive beside `currentObjectName`/`currentViewName`; consumers must treat every field as optional and never use it for access decisions. diff --git a/.changeset/sys-activity-declares-scheduled.md b/.changeset/sys-activity-declares-scheduled.md deleted file mode 100644 index 2dad6c3445..0000000000 --- a/.changeset/sys-activity-declares-scheduled.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -Declare `sys_activity.type: 'scheduled'` and record its writer in the type census - -A shipped app action writes the value today. `objectstack-ai/hotcrm` -`src/actions/global.actions.ts` builds a `schedule_meeting` action for -`crm_lead`, `crm_contact`, `crm_account`, `crm_opportunity` and `crm_case` -whose body runs `ctx.api.object('sys_activity').insert({ type: EVENT_STATUS -=== 'held' ? 'completed' : 'scheduled', … })`, and `SCHEDULE_MEETING_SPEC` -declares `eventStatus: 'planned'`, so that action always takes the `scheduled` -branch. Read at hotcrm `5eee1bd` on 2026-08-24. - -**Nothing accepts or rejects differently.** Every `sys_activity` field is -`readonly: true`, and `validateRecord` skips readonly fields on both the insert -and the update branch (`objectql/src/validation/record-validator.ts`), so the -`invalid_option` check that enforces a select field's declared options never -runs for one — measured here, not assumed: with the value added to the enum and -nothing else changed, exactly one of the package's 313 assertions moved, the -writer census below, which exists to force that row to be written. Every -behavioural case stayed green, including the one that inserts an undeclared -value into this very column and measures that it lands. Before and after, the -row is stored verbatim. What changes is that the declaration names the value -the platform stores, so the timeline filter offers it and the four generated -locale bundles carry a label for it (`已安排` / `予定` / `Programado`). - -The census (`sys-activity-type-vocabulary.test.ts`) gains the writer row, which -is what forces this to be measured rather than asserted: adding a value to the -enum without inventorying its writer fails that pin. Two further facts are -recorded there in passing — `completed` has a second writer at the same app -(`src/actions/contact.actions.ts`), and the census's in-repo sweep cannot see -either of them, because an app's server-side action reaches this column directly -through `ctx.api`. diff --git a/.changeset/sys-activity-type-open-vocabulary.md b/.changeset/sys-activity-type-open-vocabulary.md deleted file mode 100644 index 6983ad2434..0000000000 --- a/.changeset/sys-activity-type-open-vocabulary.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -Say out loud that `sys_activity.type` is an open, author-extensible vocabulary -— the declared options are the platform's **built-in** set, not a closed enum - -An author reading the declaration learned "writing another value will be -rejected". That was false in three independent ways, and the declaration was -the only place that did not say so. - -1. Every field on `sys_activity` is `readonly: true`, and `validateRecord` - skips readonly fields on both write branches, so the `invalid_option` check - a `select` normally implies **never runs** on this column. -2. ADR-0052 §5b.2 `activityMilestones[].type` is `z.string().optional()` in - the spec and is forwarded verbatim by the audit writer - (`if (milestone.type) activityType = milestone.type`) — a shipped, - documented, author-facing channel straight into the column. -3. An app's own server-side action writes the column directly - (`ctx.api.object('sys_activity').insert({ type: … })`); no grep of this - repository can see those sites. - -Maintainer ruling, 2026-08-24 (#11507, direction 4 of four): the column **is** -an open vocabulary, ADR-0052 §5b.2 **stays** a sanctioned write path, and -every closed map over this vocabulary is now the bug. The status quo was the -one option more dangerous than either end state — most of all to an AI writing -metadata, which reads the declaration and believes it. - -So the declaration now carries the semantics, in the field's own -`description` — the slot the spec declares for exactly this and, unlike a -source comment, one the contract carries wherever the metadata goes (the -metadata API, the i18n bundles, whatever an author or an AI reads about this -field). No new schema concept was invented: `FieldSchema` has no -open/closed-vocabulary key, and the pin measures that rather than asserting -it, so the day `packages/spec` grows one this declaration is told to move. - -Nothing about enforcement changed — that was direction 3 and it was **not** -ruled. `validateRecord` is untouched, the built-in set is unchanged (twelve -values), and both existing vocabulary tests keep every assertion they had. -What changed in them is what a red MEANS: the two cases that used to be filed -as "a defect, characterized — delete these when enforcement lands" now measure -a ruled contract, and say that rejecting an author-contributed value is a -contract change to re-open #11507 over, not a fix to adapt them to. diff --git a/.changeset/sys-file-stranded-orphan-inventory.md b/.changeset/sys-file-stranded-orphan-inventory.md deleted file mode 100644 index 3d85a28201..0000000000 --- a/.changeset/sys-file-stranded-orphan-inventory.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/service-storage": patch -"@objectstack/cli": patch ---- - -feat(storage): report-only inventory for stranded `sys_file` orphans, plus `os storage orphans` (#10950) - -The tombstone repairs in #10171 (update verb) and #10240 (delete verb) are forward-only: -they changed what the next write does and touched no row already written. Every -attachments-scope file orphaned before them still sits at `status='committed'` with -`deleted_at` NULL and no `sys_attachment` join row — and `sys_file`'s declared lifecycle -nominates a sweep candidate only via `ttl { field: 'deleted_at' }` or -`retention { onlyWhen: { status: 'pending' } }`, so such a row matches **neither**. It is -never a candidate, the reap guard is never asked about it, and its bytes are never -reclaimed. The leak is permanent rather than late. - -This ships the measurement half only, per the maintainer's ruling on #10950: - -- `inventoryStrandedFileOrphans()` — a read-only reconciliation pass that walks - attachments-scope committed `sys_file` rows and reports how many are stranded, their - byte magnitude, and why each excluded row was excluded. `formatStrandedOrphanInventory()` - renders it; those two plus the inventory's result types are what - `@objectstack/service-storage` publishes — the shared ownership predicate stays internal, - since nothing outside the package pulls on it. -- `os storage orphans` — the operator-invoked surface, with `--json` for a machine-readable - payload. There is no `--apply` and no write path, deliberately. - -**It writes nothing, tombstones nothing and deletes nothing.** Authorising the destructive -backfill is a separate decision that these numbers exist to inform; a tombstone written -here would start a 30-day clock ending in an irreversible byte delete. - -The ownership question is not reimplemented. `createSysFileReapGuard`'s "is anything still -holding this file?" test — zero `sys_attachment` join rows **and** empty `ref_*` ownership -columns — is extracted as `findFileHolder()` and called by both the guard and the -inventory, so "the same question, never a weaker one" is a property of the code rather -than a claim in a comment. A file with zero join rows that is `ref_*`-owned (ADR-0104 / -#3459) is a live file and is excluded from the count. - -Behaviour of the reap guard is unchanged — the extraction is a pure refactor, and the -guard's existing pins cover it. Both counts are labelled `attachments` scope: files in the -other scopes are governed by the field-reference seam and are reconciled by -`verifyFileReferences`, which skips attachments-scope files, so the two passes partition -the population rather than overlapping. diff --git a/.changeset/sys-metadata-activation-ledger.md b/.changeset/sys-metadata-activation-ledger.md deleted file mode 100644 index 9a1cd4f094..0000000000 --- a/.changeset/sys-metadata-activation-ledger.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/spec": minor ---- - -feat(platform-objects): declare `sys_metadata_activation`, the packaged-metadata activation ledger (#12155) - -Additive platform surface implementing **ADR-0126 §4 (D2)**: the disable+clone -family gets **one** data-plane platform object, declared beside its siblings so -it needs **zero `packages/spec` schema or contract surface** — it is an ordinary -platform object, not a metadata type. (The one spec file touched is the -mechanical name census described below, not protocol surface.) - -The whole schema, per §4: `metadata_type` · `name` · `package_id` · -`organization_id` (nullable, **reserved** — NULL on this entire line; the -per-org dimension is an additive column later, never a redesign) · `active`. -An earlier ADR draft carried designation columns (`replaced_by`, `cloned_from`); -amendment ruling 2 removed them — there is **no recorded linkage** between a -clone and its base, matching the landed #11513 posture ("an ordinary org-owned -set with no upgrade linkage"). The pin test asserts the column set by EQUALITY -and names both removed columns separately, so re-growing the linkage is loud. - -Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled -as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That -spelling is load-bearing, and the two obvious alternatives are both wrong here: - -- bare `unique: true` on a declared index is the positional spelling of - `'global'` — installation-wide over exactly the listed columns — and is - already warned by lint `unique/unscoped-declared-index` in 17.x; -- a hand-written `['metadata_type', 'name', 'organization_id']` composite is - NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row - by construction, so that index would enforce **nothing at all** (#5030, - measured) and one artifact could carry two contradictory `active` rows. - -`'organization'` is the arm that closes exactly that hole: the driver prepends -`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which -is what §4's "NULL-collapsed" names. - -The name is also registered in `@objectstack/spec`'s platform-object name census -(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a -curated set of REAL names, not a `sys_`-prefix pattern, precisely so a -cross-reference check can tell `sys_user` (real) from a fictional -platform-prefixed name; its module contract is explicit that "adding an object -to a platform package means adding its name here", and the owning package's -conformance pin fails otherwise. This is a one-name roster registration, **not** -protocol or schema surface — the ledger remains an ordinary platform object with -no zod/contract surface of its own, exactly as ADR-0126 §4 requires. Its -user-visible effect is that `isPlatformProvidedObjectName('sys_metadata_activation')` -now answers `true`, so lint stops reading a reference to the ledger as a typo. - -**No behavior change.** This leg ships the declaration only — the enable/disable -actions that write the ledger and the per-runtime consult points that read it -are separate legs, and nothing in the tree reads the object yet. Absence of a -row means the packaged default (**active**), so an empty ledger changes nothing -anywhere; there is no seeding mechanism, so a stock boot leaves the table empty. -The object deliberately declares **no `lifecycle` block** — unlike its telemetry -siblings `sys_flow_dispatch` / `sys_automation_run`, a row here is durable -configuration, and reaping one would silently re-arm an artifact an -administrator disabled. diff --git a/.changeset/sys-secret-tri-producer-field-help.md b/.changeset/sys-secret-tri-producer-field-help.md deleted file mode 100644 index a90e3f4b6b..0000000000 --- a/.changeset/sys-secret-tri-producer-field-help.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): `sys_secret` field help stops asserting the settings-only reading (#12550) - -`sys_secret` has **three** privileged producers (#4270) — the object's own -`managedBy` note has said so for a while — but its most load-bearing field -descriptions still described a settings-only table. Those strings are not -internal comments: they are the field help an operator reads in the -`sys_secret` grid, and they compile into the shipped translation bundles. - -Measured on `origin/main@f93df4db`, by producer symbol rather than by line: - -| producer | `namespace` | `key` | the reference lives at | -|---|---|---|---| -| `SettingsService` (`settings-service.ts`, `secretStore.insert`) | settings namespace | specifier key | `sys_setting.value_enc` | -| engine `encryptSecretFields` (`objectql/src/engine.ts`) | **object name** | **field name** | a `secret:` ref on the business row itself | -| datasource credential binder (`datasource-secret-binder.ts`) | **caller-supplied**, default `datasource` | datasource name | the artefact's `sys_secret:` credentialsRef | - -So `'Settings namespace this secret belongs to.'` / `'Specifier key within the -namespace.'` / `'Opaque handle referenced by sys_setting.value_enc.'` were each -true of one producer out of three, and the pair they describe was presented as -if it identified an owner. That is exactly the `(namespace, key)` attribution -reading #8103's re-measurement rejected — the reason -`sys-secret-orphan-report.ts` reports a row it cannot attribute as -`'unattributable'` rather than `'orphaned'`. Field help asserting the rejected -reading is the safety-relevant direction of this drift. - -Corrected here: the object description, and the `namespace` / `key` / `id` -field descriptions, now name the producer-scoped reality and point at -`managedBy`. The `en` bundle was regenerated with the repo's own -`pnpm i18n:extract`; the three translated locales carried translations of the -superseded English, so their four affected leaves were re-translated by hand — -the action `.objects.generated.ts`'s own header prescribes when a -source string changes — and the bundles plus their `--source-hashes` -companions then come from one extract run. - -Text only. No field is added, removed, renamed or re-typed; no validation, -persistence or access rule moves; every `sys_secret` payload that parsed before -parses identically. ⛔ A producer/owner column stays out of scope — that is a -persist-path change and belongs to whoever takes that decision. diff --git a/.changeset/thin-yen-keeps-no-cents.md b/.changeset/thin-yen-keeps-no-cents.md deleted file mode 100644 index 8b01f0042f..0000000000 --- a/.changeset/thin-yen-keeps-no-cents.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`CurrencyConfigSchema` no longer materializes the `precision` default onto a configuration the schema itself would refuse as authored (#11423). A bare `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }` used to parse to `precision: 2` — and the #7918 rule rejects an authored `precision: 2` against JPY's 0 fraction digits, with the materialized and authored spellings indistinguishable by design — so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse: `parse(parse(x))` threw for an input `parse(x)` accepts. - -Mechanism (the #9689 idempotent-materialization ruling, applied to its recorded currency twin): one conditional in the `.overwrite()` — when `currencyMode` is `fixed`, no `precision` was authored, and the currency's ISO 4217 / CLDR fraction digits contradict the default `2` (the JPY/KRW/KWD class), the parsed output OMITS `precision` instead of baking a value the schema refuses. Renderers already derive display width from the currency when the key is absent (objectui#4361), so absent is the honest spelling. Every other combination keeps byte-identical output: an authored `precision` is untouched, a bare fixed 2-fraction-digit config (USD/EUR/CNY…) still materializes `precision: 2` at its shape position, and `dynamic` mode and non-CLDR codes (crypto/custom, fail-open) keep materializing — none of those can be refused. The #7918 rejection of an authored contradictory `precision` is unchanged, message and path included. diff --git a/.changeset/translation-refs-flows-leg.md b/.changeset/translation-refs-flows-leg.md deleted file mode 100644 index 358e5c8ee1..0000000000 --- a/.changeset/translation-refs-flows-leg.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`validate-translation-references` now checks the `flows` group — an authored key naming a -flow, screen node or screen field that does not exist warns instead of resolving to nothing - -The rule walked `objects`, `globalActions`, `apps` and `dashboards`; an unrecognised -top-level namespace is skipped and never reported, and `flows` was one of them. So a -bundle keyed to `flows..screens..fields.` parsed, shipped, and -silently resolved to nothing — the wizard rendering its source-locale string while every -other label on the screen was translated, which is the exact failure this rule exists for, -one namespace over. - -All three levels are exact-match identifiers with an enumerable universe, so the leg -mirrors the `dashboards` → `widgets` leg one level further: flow → `Flow.name`, screen → -`FlowNode.id` on `type: 'screen'` nodes, field → `ScreenFieldConfig.name`. Findings are -`warning`, like every other finding in this rule (ADR-0072 D1 — an orphan key is inert, -not broken), and each names the declared universe it resolved against. - -Two shape facts the collector respects, both measured against the schemas rather than -assumed — either one read the obvious way would have made the leg a false-positive -generator: - -- **Screen nodes nest.** A screen inside an ADR-0031 region (`loop.config.body`, - `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) is a real screen the - runner pauses on, so the universe is collected through `walkFlowNodes` rather than the - flat `flow.nodes`. -- **`ScreenConfigSchema` has two mutually exclusive shapes.** An object-form screen - (`config.objectName`) renders that object's own create/edit form and declares no - `config.fields`; its input labels resolve through `objects..fields.*`, so a - field key there is reported with that redirect rather than a bare "not declared". - -A key naming a node that exists but is not a `screen` is diagnosed as the wrong node type, -not as a missing node. diff --git a/.changeset/translation-submit-label-retired.md b/.changeset/translation-submit-label-retired.md deleted file mode 100644 index 5dfa483a2a..0000000000 --- a/.changeset/translation-submit-label-retired.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the component-translation `submitLabel` copy key (#10926, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -The `pages..components.` copy face is measured, not mirrored: each -key exists because some component in `ComponentPropsMap` declares it. -`submitLabel`'s only declarer was `element:form`, and #9249 retired that -element whole — so the key had no declared component left to translate, and -the resolver overlay was its only reader. The maintainer ruled retire over -re-anchor (#10926): the live form surface (`object-form`) speaks `submitText` -(`I18nLabelSchema`), localizable at its own authoring site, so re-anchoring -would have widened the face for one word. The acknowledged cost is that the -bespoke-component route loses that one word. - -**What is refused:** `submitLabel` in any `pages..components.` -translation entry, and its `submit` alias spelling — both now land on a -`guidance` prescription in the strict unknown-key rejection (the face is -`.strict()`, so the strict-delete route applies: no `retiredKey()` tombstone, -the shape simply no longer declares the key). - -**What stays:** the other five copy keys (`title`, `description`, `label`, -`placeholder`, `emptyText`), the bespoke-component route for them, and the -shared `PAGE_COMPONENT_COPY_KEYS` list (now five entries) that drives both -`translatePage`'s overlay and the CLI `i18n-extract` skeleton — one list, both -sides import it, so extractor and resolver narrow together. - -The retirement kit: - -- strict-delete at the schema (`packages/spec/src/system/translation.zod.ts`): - key and `submit` alias dropped; `guidance` tombstones carry the prescription -- `PAGE_COMPONENT_COPY_KEYS` drops the slot - (`packages/spec/src/system/i18n-resolver.ts`) — the resolver no longer - overlays the key and the extractor no longer offers it -- ADR-0087 registration: D2 conversion - `translation-component-submit-label-removed` (protocol 18), wired into the - step-18 chain — `os migrate meta --from 17` strips the key from stored - translation bundles and items (pure lossless delete; nothing read it since - #9249) -- pin tests flipped, not deleted (`translation.test.ts` refusal pins assert - the prescription; `i18n-resolver.test.ts` pins that an off-spec bundle entry - carrying the retired key is ignored, not overlaid) -- generated baselines/docs follow the schema (json-schema manifest, - spec-changes, upgrade guide, api-surface signatures, reference docs) - -## FROM → TO - -```ts -// before — a component-translation entry could carry a submit label -translations: [{ - 'zh-CN': { - pages: { - sales_home_page: { - components: { new_lead_form: { submitLabel: '创建' } }, - }, - }, - }, -}] - -// after — delete the key (nothing has read it since #9249); submit copy for -// the live form surface is authored on the component itself, where it is -// localizable inline -{ - type: 'object-form', - properties: { - objectName: 'lead', - submitText: { en: 'Create', 'zh-CN': '创建' }, - }, -} -``` diff --git a/.changeset/twelve-dataengine-contract-adoption.md b/.changeset/twelve-dataengine-contract-adoption.md deleted file mode 100644 index f93759ea08..0000000000 --- a/.changeset/twelve-dataengine-contract-adoption.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -The five engine members the #11833 sweep measured as "real on ObjectQL, consumed cross-package, recoverable only through consumer-local structural re-declarations" are now on the contract, per the 2026-08-25 maintainer ruling (#12248). `IDataEngine` gains five optional members: `resolveEffectiveDatasource?(objectName)` (the #5288 effective-datasource name, `undefined` = rides the deployment default), `getDriverForObject?(objectName)` (the public driver-routing read, `IDataDriver | undefined`), and the datasource-lifecycle trio `registerDatasourceDef?` / `markDatasourceUnavailable?` (`kind: 'blocked' | 'failed'`, framework#3828) / `clearDatasourceUnavailable?` (#12010's inventory, adjudicated per the ruling's item 4). Engines without datasource routing stay conformant — every member is optional, preserving each graceful-degradation seam. And `IObjectQLEngine.getObject` / `EngineSchemaRegistryView.getObject` now return `ServiceObject | undefined` — the spec's own registered-object type (authored state, ADR-0122) — instead of `unknown`, so consumers reading `fields` / `external` off a registered object no longer need a private structural re-declaration to do it; an engine or registry fake answering a non-conforming shape now fails compile at the member instead of drifting silently (the #4251 gap, closed at this seam). diff --git a/.changeset/type-compat-text-signature-qrcode.md b/.changeset/type-compat-text-signature-qrcode.md deleted file mode 100644 index ab1c041003..0000000000 --- a/.changeset/type-compat-text-signature-qrcode.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`isCompatible` now reports a TEXT column as exactly compatible with `signature` and `qrcode` fields (#12117). The SQL ⇆ field-type matrix in `type-compat.ts` had drifted from the platform's own emitted physical shape: after #11875 (maintainer ruling 2026-08-25) `sql-driver.ts` emits both types in its TEXT family alongside `richtext` / `code`, and their stored value is a string — routinely a data-URI — per `STRING_VALUE_TYPES` and `valueSchemaFor`. The `text` row's exact set never listed them, so `IExternalDatasourceService.validateObject` diffing a table the driver itself had created reported a `type_mismatch` against the very field type that wrote the column, and `generateObjectDraft` would not offer either type for a TEXT column. - -`binary` is unchanged, and `signature` stays in its exact set. The matrix is many-to-many by design — each row answers "which field types can THIS column serve?" and `isCompatible` is keyed on the column, so `text` is already exact under `text` / `uuid` / `enum` and `tags` / `multiselect` under `json` / `array`; a type joining one row does not leave another. Measured across the monorepo when the `text` row moved: no path stores a `signature` field value as a binary payload (the driver's only `binary()` column emission is the remote-column introspection mapping, never a field-type mapping), so the `binary` entry is import-side reach into foreign schemas — the only kind of schema this matrix is asked about — on the same content-class reading that puts `file` and `image` on that row, neither of which round-trips as bytes either (both are `FILE_REFERENCE_TYPES`, stored as reference-id strings). diff --git a/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md b/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md deleted file mode 100644 index 1a2516187a..0000000000 --- a/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `cloud-connection:panel` / `marketplace:installed-list` in `ComponentPropsMap` — undeclared keys on the two are refused (#11575) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -These were two more instances of the #8691/#8744 silent no-op class: -console-registered widgets on `@objectstack/cloud-connection`'s published -Setup pages, reachable through the component type union's open string arm, -with registered renderers but no `ComponentPropsMap` row — so the #5068 -component-props gate's dispatch skipped them as unregistered and any authored -key rode through every validator in silence. - -The new rows are strict and **empty**, measured from the renderers' actual -read points at the objectui pin, not from the registrations' declared-input -lists (#8691/#8744 record where those diverge — here the two happen to -agree): both registrations discard the schema node entirely -(`() => `, `() => `) and neither -component function takes a prop, so the widgets accept **no configuration at -all**, and an authored key is now a publish-time refusal naming the surface -instead of a silent no-op. - -**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the -shape both plugin-shipped pages (`cloud_connection_settings`, -`marketplace_installed`) author today, byte-identically. Node-level keys -(`visibleWhen`, `id`, `style`, …) are unaffected: they live on the component -node, and the refusal's guidance says so. - -## FROM → TO - -```ts -// before — parsed green everywhere; the panel polls on its own schedule anyway -{ - type: 'cloud-connection:panel', - properties: { pollInterval: 5 }, // silent no-op: the widget reads nothing -} - -// after — any key is a publish-time refusal naming the zero-prop surface; -// write the measured shape -{ - type: 'cloud-connection:panel', - properties: {}, -} -``` - -There is deliberately no automatic rewrite: a key authored on either widget -configures nothing and is removed, not renamed — behaviour that seems to need -one is a renderer capability request against objectui, not a metadata key. -`os migrate meta` surfaces the change as a structured TODO (semantic entry -`ui-cloud-connection-widgets-unknown-keys-refused`, protocol major 18 — this -refusal is not part of the v17.0.0 cut). - - diff --git a/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md b/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md deleted file mode 100644 index bab79821b7..0000000000 --- a/.changeset/ui-mcp-connect-agent-unknown-keys-refused.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `mcp:connect-agent` in `ComponentPropsMap` — undeclared keys on the widget are refused (#12344) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -This was a third instance of the #8691/#8744 silent no-op class (#11575 closed -the previous two): a console-registered widget on `@objectstack/mcp`'s -plugin-shipped Setup page (`CONNECT_AGENT_PAGE`), reachable through the -component type union's open string arm, with a registered renderer but no -`ComponentPropsMap` row — so the #5068 component-props gate's dispatch skipped -it as unregistered, any authored key rode through every validator in silence, -and door 3 of the mcp canonical-envelope gate (#12269) had to carry a standing -exemption for the type (deleted here, with its two guard pins). - -The new row is strict and **empty**, measured from the renderer's actual read -points at the objectui pin, not from the registration's declared-input list -(#8691/#8744 record where those diverge — here the two happen to agree): the -registration discards the schema node entirely (`() => `) and -the component function takes no parameters — every value it renders comes from -`/discovery`, i18n and its own state — so the widget accepts **no -configuration at all**, and an authored key is now a publish-time refusal -naming the surface instead of a silent no-op. - -**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the -shape the plugin-shipped page (`connect_agent`) authors today, -byte-identically. Node-level keys (`visibleWhen`, `id`, `style`, …) are -unaffected: they live on the component node, and the refusal's guidance says -so. - -## FROM → TO - -```ts -// before — parsed green everywhere; the widget reads /discovery on its own -{ - type: 'mcp:connect-agent', - properties: { serverUrl: 'https://example.test/mcp' }, // silent no-op: the widget reads nothing -} - -// after — any key is a publish-time refusal naming the zero-prop surface; -// write the measured shape -{ - type: 'mcp:connect-agent', - properties: {}, -} -``` - -There is deliberately no automatic rewrite: a key authored on the widget -configures nothing and is removed, not renamed — behaviour that seems to need -one is a renderer capability request against objectui, not a metadata key. -`os migrate meta` surfaces the change as a structured TODO (semantic entry -`ui-mcp-connect-agent-unknown-keys-refused`, protocol major 18 — this refusal -is not part of the v17.0.0 cut). - - diff --git a/.changeset/updatemany-write-coercion.md b/.changeset/updatemany-write-coercion.md deleted file mode 100644 index 1fa62ce073..0000000000 --- a/.changeset/updatemany-write-coercion.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): route `updateMany()`'s payload through `formatInput` / `applyWriteColumnMap` (#11223) - -`updateMany()` was the only write door in `sql-driver.ts` that passed the caller's `data` -straight to `builder.update(data)`. Every other one — `create`, `update`, `bulkCreate`, -`upsert`, `rotatedUpdateById` — applies `applyWriteColumnMap(object, formatInput(object, data))` -first, and the WHERE side of the very same bulk statement was already being translated by -`applyFilters`. Measured on SQLite, live PostgreSQL 16.13 and live MySQL 8.0.46: - -- **`json` and `Field.multiple` values were refused.** Nothing stringified the structured - value for the bind, so each dialect refused it in its own voice: `22P02 invalid input - syntax for type json` on Postgres, `SQLite3 can only bind numbers, strings, bigints, - buffers, and null` on SQLite, and on MySQL the array expanded into the SET list itself - (``set `tags` = 'y', 'z'``) — a syntax error rather than a bind error. `update()` wrote - the identical values correctly in the same run. -- **A federated `external.columnMap` object's bulk update named a column that does not - exist.** The WHERE was mapped and the SET was not, in one statement: - ``update `legacy_p` set `name` = 'Bulk' where `full_name` = 'Renamed'`` → `no such - column: name`. The door was unusable on every remapped external object. -- **Temporal values were stored verbatim**, silently. On SQLite a zone-naive - `'2026-05-06 07:08:09'` landed as-is — the pre-#3912 storage form - `needsLegacyDatetimeRepair` exists to repair on read, written into a column - `canonicalDatetimeFields` had already certified as canonical and therefore stopped - repairing. Measured end to end: a range filter over that calendar day returned only the - `update()`-written row, with the bulk-written row on disk carrying the right day and - invisible to the query. On live Postgres the same literal was resolved in the **server's** - timezone rather than UTC — `2026-05-06 07:08:09` stored as `2026-05-05T23:08:09.000Z`, a - silent 8-hour instant shift on an `Asia/Shanghai` server. `Field.date` and `Field.time` - were affected the same way: stored verbatim on SQLite, refused outright on the live - dialects. - -The literal `'NOW()'` token now resolves on this door as it does on every other one; it -previously stored the four-character string `"NOW()"` into a datetime column on SQLite and -was refused by MySQL. - -#11176's `updated_at` stamping is unchanged in effect — the stamping decision now reads the -formatted payload, matching `update()` and `rotatedUpdateById`, and the stamp is still -applied afterwards as the literal post-map column name. diff --git a/.changeset/user-actions-three-keys.md b/.changeset/user-actions-three-keys.md deleted file mode 100644 index 8c3d6b7b56..0000000000 --- a/.changeset/user-actions-three-keys.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`UserActionsConfigSchema` adopts `group`, `hideFields` and `rowColor` (ruled Option A on objectui#5435): the three toolbar affordances ListView already honours become authorable in a spec-valid document, so the runtime fold of legacy `showGroup`/`showHideFields`/`showColor` flags now passes the save gate instead of being rejected by name. All three are booleans; the defaults copy the renderer's reads — `group` defaults on, `hideFields`/`rowColor` default off. Accept-set widening only: no existing document changes meaning, and the object-level `userActions` block (create/import/edit/delete/exportCsv) still rejects all view-vocabulary keys by name. diff --git a/.changeset/validate-json-failure-payload-warnings.md b/.changeset/validate-json-failure-payload-warnings.md deleted file mode 100644 index afa002e0de..0000000000 --- a/.changeset/validate-json-failure-payload-warnings.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os validate --json` carries the computed advisory lists on every failure exit, not the success payload alone (#12047) - -**Machine-contract widening on the `--json` failure payloads.** A consumer that -today branches on `warnings` being ABSENT from an `os validate --json` failure -payload, or that reads it as "the author-time rule advisories", sees a -different shape after this change. - -## What was wrong - -The text face prints its advisory blocks ending `— re-run with --json for the -full list`, but `warnings` lived on the TERMINAL SUCCESS payload only — plus -`ruleAdvisories` alone on two of the five failure exits. So the remedy the -notice named returned a payload that did not contain the list, and the author -could not reach the withheld entries by any route until an unrelated later -failure was fixed. - -The strongest instance is the parse-failure exit. `validate.ts` computes the -#3786 undeclared-authoring-key findings **before** the schema parse, precisely -so a finding survives an unrelated schema error — the parse is what strips the -key, so it cannot be recovered afterwards. That payload then dropped the list -anyway, defeating the one hoist that existed to prevent exactly this. - -## Which exits gain the field - -All five failure exits of `os validate --json`. Two already carried a partial -list; three carried none. `warnings` is now present on every one, alongside -each exit's existing keys, which are unchanged: - -| exit | existing keys | `warnings` before | after | -| --- | --- | --- | --- | -| protocol parse failure | `errors` | absent | undeclared-key findings | -| author-time rules failed | `errors` | `ruleAdvisories` | rule + key | -| capability provider check | `errors` | absent | rule + key + capability | -| package docs failed | `errors` | `ruleAdvisories` | rule + doc + key + capability | -| thrown / caught | `error` | absent | what the run had computed | - -The success payload is unchanged in content: its -`[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings, ...structuralWarnings]` -spread moved to a single `warningsSoFar()` site that every exit now reads, so -the member order cannot drift between exits. - -`structuralWarnings` is the one member `os validate` has that `os build` does -not, and it is **carried, not hoisted**: it is computed below all five failure -exits, so it rides each of them as an empty list and the success payload stays -the only exit that can ever show it non-empty. - -## What a consumer keying off its absence should do instead - -⛔ `warnings` is no longer a signal of which exit produced the payload. Read -`valid` (and `error` / `errors`) for that; a consumer that inferred "this is a -failure payload" from a missing `warnings` must switch to `valid === false`. - -⛔ `warnings` on a failure payload is no longer only the author-time rule -advisories. It is the same heterogeneous list the success payload publishes — -rule and doc findings as RECORDS, undeclared-key and structural advisories as -STRINGS — truncated to what the run had computed. A consumer that assumed every -entry was a rule finding must classify by shape. - -⛔ `warnings: []` on a failure payload does NOT mean "this tree raises no -advisories". It means **this run stopped before those advisories were -computed** — a config that fails to load reports `[]` by construction. A -consumer that needs the full advisory set for a tree must read it from a run -that reaches at least the gate that computes it. - -✅ `warnings` is always an array on every `os validate --json` payload, success -or failure, so it can be read unconditionally — that shape constancy is the -point of the change (maintainer ruling 2026-08-25 on #11772, option 1 of three, -inherited here under the same-family rule; option 2, "carry them only where the -text face printed them", was rejected as the hardest contract to declare). - -Exit codes are untouched: every failure exit still exits 1, and `--strict` -still reads the text face's own list, so `os validate --json --strict` reaches -the same verdict it did before. - -Advisories stay CARRIED, never recomputed: each list is still computed at -exactly the step that owns it, so an exit upstream of a step legitimately -reports that list empty and no failure path pays for a computation it did not -already do. diff --git a/.changeset/validate-json-strict-exit-parity.md b/.changeset/validate-json-strict-exit-parity.md deleted file mode 100644 index 76427a0251..0000000000 --- a/.changeset/validate-json-strict-exit-parity.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os validate --json --strict` exits 1 on the configs `--strict` already exits 1 for (#11174) - -`commands/validate.ts` emitted the `--json` payload and `return`ed *above* the only -`flags.strict` reader, which sat inside the text-rendering block. So on one config, -one flag, two answers: - -``` -os validate --strict → exit 1 ("Strict mode: warnings treated as errors") -os validate --json --strict → exit 0 -``` - -`--strict` was accepted, documented — `content/docs/deployment/cli.mdx` spells -`os validate --json --strict` twice in its CI/CD section, once as a GitHub Actions -step — and inert whenever `--json` was also passed. That combination is the one -audience the flag exists for: a pipeline gating on the exit status of the documented -invocation read 0 and concluded the stack was clean. - -The `--strict` gate now reads the text face's own warning list, which is assembled -once and consumed by both faces, so the two exit codes cannot drift apart again. The -gate deliberately does **not** read the payload's `warnings` field: the two differ by -the ADR-0087 load-time conversion notices, which the text face folds into its warning -block while the payload carries them under `conversions`. Gating on the field would -have left the same divergence in place for a config whose only advisories are -conversion notices. `specVersionGap` stays outside `--strict` on both faces, as it -always has been on the text one. - -`valid: true` beside a non-zero exit is the text face verbatim, not a contradiction: -that path prints "Validation passed" and *then* fails for strict. The stack is -schema-valid; `--strict` is what promotes its advisories to a failure. - -**BREAKING** for one caller shape, and the reason this is not a patch: a pipeline -running `os validate --json --strict` over a stack that raises non-blocking -advisories was green and will now be red. Nothing was removed or renamed and no -authored metadata changes — the accept set is identical and the exit status is the -only thing that moves — but a release a CI system can take unattended must not flip -a green build to red, so this does not belong in a patch. It is not a major either: -the new behaviour *restores* what `--strict` declares ("Treat warnings as errors") -and what the docs already advertise, rather than contradicting a contract. Under -this repo's launch-window convention (breaking changes ship as `minor` while the -stack versions in lockstep) `minor` is the honest slot. - -## What a pipeline gating on the payload has to read - -`--strict` gates on the text face's warning list, and that list is **not** the payload's -`warnings` field. The two differ by the ADR-0087 load-time conversion notices: the text -face folds them into its warning block, while the payload carries them separately under -`conversions`. So a pipeline that wants to reproduce `--strict` from the document must -read **both**: - -``` -warnings.length > 0 || conversions.length > 0 -``` - -Gating on `warnings` alone is strictly weaker than `--strict` — a config whose only -advisories are conversion notices passes that check and fails `--strict`. That is the -same silent under-reporting this change exists to remove, so do not reach for the -narrower spelling. - -The consequence is reachable and worth stating outright, because it is surprising: a -conversions-only config now exits **1** with `"warnings": []` and a populated -`conversions`. Predicting the exit code from `warnings.length` alone will be wrong for -exactly that config. Nothing is missing from the document — both advisory streams are in -it — but they sit in two fields and the exit code answers to both. - -If a pipeline genuinely wants the old exit status, the honest fix is to say so rather -than to keep passing a flag that means the opposite: drop `--strict` and read the -payload. If it goes red instead, the advisories were always there — the text face had -been printing them all along. - - diff --git a/.changeset/validate-sweep-introspection-memo.md b/.changeset/validate-sweep-introspection-memo.md deleted file mode 100644 index 17c99bb613..0000000000 --- a/.changeset/validate-sweep-introspection-memo.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/service-datasource': patch -'@objectstack/spec': patch ---- - -`validateAll`/`validateDatasource` now read each datasource's live schema once per sweep instead of once per federated object: the sweep threads a per-call introspection memo through the validation body, so M objects on one datasource cost one remote introspection round-trip (a rejected read is shared the same way — one connection attempt, M failure rows). The memo lives and dies inside a single call, so a long-lived service never serves a stale schema to a later sweep, and direct `validateObject` calls still read live every time. The `IExternalDatasourceService.validateAll` docstring, which promised "parallelised per datasource" while the implementation parallelised per object, now states the actual behaviour. diff --git a/.changeset/value-roundtrip-conformance-case-set.md b/.changeset/value-roundtrip-conformance-case-set.md deleted file mode 100644 index 2941f1569e..0000000000 --- a/.changeset/value-roundtrip-conformance-case-set.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -test(spec,drivers): add the `VALUE_ROUNDTRIP` conformance case-set — "what you wrote is what you read back", enforced per driver per dialect (#12393) - -The driver-conformance census was green at 9 of 9 dialect-scored cells after -#12136 promoted `MATRIXED`, and **none of its nine case-sets was about value -storage**. All nine ask *which rows come back*; none asks *what is in them*. So -that green was not weak evidence about a round-trip defect — it was no evidence -at all, and it would have stayed green forever with the defect in place. That is -why this family kept arriving one card at a time: #12380 (SQLite's `Field.json` -codec was not injective), #11535 (a multi-value field read back as the string -`'["x","y"]'`), #11782 (MySQL answering `1`/`0` for a declared boolean), #10995 -(PG json values bound without `JSON.stringify`). - -`VALUE_ROUNDTRIP_CASES` closes it as a class rather than as a tenth instance. It -is 41 cases over five declared value classes — `json`, `multiple: true`, -`string`, `number`, `boolean` — and every value in it is one some driver was -**measured** to change, or a control that stayed faithful in the same -measurement. Assertions pin **type as well as value**: the before-state of every -card above was a wrong type carrying a right-looking value, which survives -`toEqual`-style coercion and every truthiness check. `VALUE_ROUNDTRIP_COLLISION_PAIRS` -adds the injectivity half a per-value check cannot see — a string and the native -value whose encoding it resembles must stay distinguishable. - -Enrolled through the census's existing machinery rather than as a bespoke suite, -which is the whole argument for this route: `CLASSIFIED` obliges the new fixture -to be named in `CASE_SETS`, `CONSUMED` obliges every driver to run it, and -`MATRIXED` obliges `driver-sql`'s cell to be answered on **every dialect it -speaks** rather than on SQLite alone — the coverage shape that let #12380 survive -in the first place. The census now reads **50 covered cells across 5 drivers × -10 case-sets, 10 of 10 dialect-scored cells matrix-routed, 0 DEBT, 0 exempt**. - -**No shipped behaviour and no public surface changes.** This is `@objectstack/spec`'s -`data` export gaining one conformance fixture, six new test files, and one -`CASE_SETS` row in the census script. No Zod schema, no runtime, no driver -source, no API. Graded `patch` for that reason: the package's published surface -grows by a test fixture that only conformance suites consume, and nothing an -existing consumer resolves changes shape. - -The one non-test change is a **test-double fidelity fix** the new case-set -surfaced: `driver-turso`'s `makeLibsqlSqliteStub` did not model `@libsql/client`'s -client-side boolean → `1`/`0` conversion, so a declared `boolean` written through -the REMOTE transport could not be bound at all. Verified against the dependency's -own source rather than the transport's comment; the transport is correct and -unchanged. diff --git a/.changeset/visiblewhen-describe-bound-roots.md b/.changeset/visiblewhen-describe-bound-roots.md deleted file mode 100644 index d726d79b90..0000000000 --- a/.changeset/visiblewhen-describe-bound-roots.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`visibleWhen`'s describe now states the roots it actually binds, split by what is contract and what is renderer behaviour. - -`PageComponentSchema.visibleWhen` named three roots (`record`, `current_user`, `page.`) while the shipping renderer binds nine. The describe now separates the two claims instead of widening one into the other: - -- **Contract-bound**: `record`, `current_user` — with ADR-0068's aliases `user` and `ctx.user`, one object under three spellings — and `page.`. These are transcribed from a ruling, not from the renderer: ADR-0068 D1 rules a predicate "evaluates identically in a formula, an RLS policy, and a client `visible` gate", and `EvalUser`'s docblock already states the same alias set for "client UI gates". The platform's own `sys_user` alert gate uses `ctx.user`, which the old describe implied was unavailable. -- **Renderer-provided, not guaranteed**: `app`, `features`, `os.user` and `data`. ADR-0068's Non-goals fence its ruling to the user object, so nothing rules these on this surface; they are recorded as measured behaviour rather than promised. -- **`data` is surface-dependent** and is now called out as such: the data-source **adapter** on a component node, the record **row** on a `page:tabs` item-level `visibleWhen`. - -The `page:tabs` item `visibleWhen` (`ComponentSchema`) carried the identical three-root sentence plus a "binds the same environment as page-component `visibleWhen`" claim that measurement disproves; it is corrected the same way and now names its two real divergences (row-bound `data`, bare-field spread). - -Describe/prose only — no accept/reject change, no shape change, no new keys. diff --git a/.changeset/walled-elevation-verified-email.md b/.changeset/walled-elevation-verified-email.md deleted file mode 100644 index 8e21fb2af3..0000000000 --- a/.changeset/walled-elevation-verified-email.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/plugin-auth': patch ---- - -Walled platform-admin elevation now requires the owner-email match to be -VERIFIED, and the bootstrap re-runs on the verifying update (#11343) - -Under walled postures (`group`/`isolated`), `bootstrapPlatformAdmin` matched -the env-declared `OS_PLATFORM_OWNER_EMAIL` against the raw email string on -`sys_user` — with no `email_verified` condition, while email verification is -off by default. #11211 narrowed elevation from "whoever registers first" to -"the declared owner's address" (a real and large narrowing); this closes the -remainder that card #11343 records: in the window before the owner registers, -an account created with the owner's address would still be elevated. - -Two halves, deliberately in one change: - -1. **The elevation match requires `email_verified`** (fail-closed allow-list - over driver representations; an absent field on an imported/legacy row - reads as unverified). An unverified holder of the owner's address is - refused like any stranger — new reason `walled_owner_not_verified`, logged - loudly with the unblock in the line. Never falls back, same direction as - the undeclared-owner refusal. -2. **The bootstrap-replay middleware now also fires on `sys_user` updates - touching `email_verified` / `email`** (trigger set extracted as - `shouldReplayBootstrapFor`, consumed by the middleware and its pins alike). - Verification is an UPDATE — with the old insert-only replay, requiring - verification would have refused the genuine owner at sign-up and then - never looked again, leaving the platform without any administrator. - -`single` posture is untouched both ways: first-user promotion (ruled -reasonable in #11184) does not gain a verification requirement, and the -owner-email variable is still never consulted there. Both directions are -pinned: the unverified holder is refused AND the verified owner is elevated — -including across the refuse-then-verify-then-re-run sequence. - -The seeded dev admin (`maybeSeedDevAdmin`, dev-only) is now provisioned with -`email_verified` stamped: it is created by the deployment's own boot command -with operator-known credentials — the same trust shape as a trusted-SSO -insert, not an unknown self-registrant — so walled dev/harness boots keep a -promotable declared owner. The generic sign-up path is unchanged. diff --git a/.changeset/walled-owner-email-elevation.md b/.changeset/walled-owner-email-elevation.md deleted file mode 100644 index 23d4a5ea2e..0000000000 --- a/.changeset/walled-owner-email-elevation.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/plugin-auth": minor -"@objectstack/types": minor -"@objectstack/verify": patch ---- - -fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) - -**BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or -`isolated`), shipped as `minor` under the repo's launch-window convention for -breaking changes. Single-org deployments are byte-for-byte unchanged. - -Measured defect (cloud#1509): on a walled multi-tenant SaaS with -`OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the -FIRST self-registrant received the cross-tenant `admin_full_access` grant -(`platform_admin`, `isPlatformAdmin: true`) and — because the default-org -bootstrap binds "the platform admin" — was merged into the deployment's -Default Organization as its owner. Whoever curls the public sign-up endpoint -first owned the platform. - -Per the maintainer ruling of 2026-08-23 (verbatim: -「1509 选择 env 指定 owner 邮箱」): - -- **Walled postures: platform admin comes ONLY from the env-declared owner.** - `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest - human user when the requested posture is walled; it promotes exactly the - account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable - (case-insensitive, matched whenever that account registers — arrival order - is irrelevant). Self-registrants are never promoted and, since the shared - `ensureDefaultOrganization` helper binds only the platform admin, are never - auto-merged into the Default Organization either. -- **Fail-closed startup refusal.** A walled posture with no - `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` - with a message naming the variable — never a silent fallback to - first-registrant elevation. The elevation site itself also refuses - (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as - defense-in-depth for compositions that reach the bootstrap without - plugin-auth (`os meta resync`, bare embeddings). -- **Single-org posture unchanged.** "First user is owner" stays as ruled - reasonable there; the new variable is never consulted under `single`. -- The requested posture (`resolveTenancyPosture()`) is deliberately the input, - so a walled-requested deployment running degraded - (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. - -Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the -operator account's email address before upgrading. Deployments that already -hold a human platform admin are untouched (the bootstrap remains a no-op once -any human holds the cross-tenant grant); the variable governs installs that -have not yet minted their admin. `@objectstack/types` gains the -`resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` -constant; the verify harness declares the owner email (defaulting to its dev -admin) for walled fixtures. - - diff --git a/.changeset/walled-owner-no-verification-path-warning.md b/.changeset/walled-owner-no-verification-path-warning.md deleted file mode 100644 index fac59e5902..0000000000 --- a/.changeset/walled-owner-no-verification-path-warning.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -feat(plugin-auth): warn at boot when a walled deployment declares an owner it can never verify (#11640) - -A walled deployment (`OS_TENANCY_POSTURE=group|isolated`) that declares -`OS_PLATFORM_OWNER_EMAIL` but wires **no verification path** — no email -transport and no trusted federated sign-in — now emits a loud, named boot -warning (`walled_owner_no_verification_path`) on `kernel:ready`. - -Since #11343, walled platform-admin elevation requires the declared owner's -address to be **verified**, and verification can only arrive by an emailed -link or by a federated sign-in that inserts the account already verified. With -neither wired, the declared owner registers, is refused -(`walled_owner_not_verified`), and has no in-product way to satisfy the -condition — a dead end that previously surfaced only weeks later, at the -owner's rejected registration. The warning names both missing inputs and the -concrete wiring for either remedy (an email service, or SSO / a social -provider), since either one alone clears it. - -⛔ **Boot proceeds — this is not a refusal**, and no accept/reject behaviour -changes anywhere: the walled + undeclared-owner boot refusal (#11184) and the -fail-closed elevation refusal (#11343) are untouched. Deployments already -wiring either verification path see no new output, and neither does a -dev/harness boot whose declared owner is the dev-admin the seed provisions and -stamps verified. diff --git a/.changeset/walled-platform-bucket-diagnostic.md b/.changeset/walled-platform-bucket-diagnostic.md deleted file mode 100644 index bc91c77b05..0000000000 --- a/.changeset/walled-platform-bucket-diagnostic.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Stop the per-organization catalog pass from reporting the platform's own -permission sets as "pre-fix" leftovers with a remedy that recreates them - -On a fresh walled deployment (`OS_TENANCY_POSTURE=isolated`, three -organizations) the boot log warned, once per organization, that *"pre-fix -organization-less `sys_permission_set` rows are still present"* and offered -*"re-initialize the deployment, or adopt each row by hand"*. Both halves were -wrong there: - -- **Nothing was pre-fix.** The eight rows it named (`admin_full_access`, - `organization_admin`, `organization_admin_no_bypass`, `member_default`, - `viewer_readonly`, `mcp_agent_data_read`, `mcp_agent_data_write`, - `mcp_agent_restricted`) were minted 1.3 s earlier — before the deployment's - first organization existed — by `bootstrapPlatformAdmin`, the fifth seeder, - which the #10103 ruling deliberately left outside the per-organization - conversion. An operator on a deployment hours old was told they were carrying - legacy state they never had. -- **Its first remedy did not terminate.** Re-initializing a fresh walled - deployment mints exactly those eight rows again on the next boot, so only the - hand-adoption branch ends — and that one hands a platform-wide bucket to a - single tenant. - -The pass now separates the two classes it was conflating and reports each with -the remedy that fits, carrying a machine-readable `origin` -(`'platform-bucket'` / `'pre-fix-residue'`) beside the named rows: - -- the **platform bucket** — names an organization-less writer still seeds on - every boot — is reported as what it is, states that this organization's own - copies were created and no action is required, and says plainly that - re-initializing does *not* clear it; -- a **genuine pre-fix leftover** keeps the original wording and the original - remedy, unchanged. - -Membership is decided by name rather than by `managed_by`, because the question -the remedy turns on is "will a re-initialized deployment have this row again?" -— true for these names whatever provenance the current row carries (a -pre-#8692 install stores `'admin'` on the very same names). It falls back to the -shipped `defaultPermissionSets`, so a host that never threads the new -`platformBucketNames` option still classifies correctly; the option exists for -a host that overrode `SecurityPluginOptions.defaultPermissionSets`. - -`bootstrapPlatformAdmin` also declares what it wrote: under a walled posture it -now logs that the platform defaults were seeded *without* an organization and -that each organization's copies come from the catalog pass. The rig's boot line -read `{"seeded":8}` with nothing to indicate the rows carried no organization -at all, so the operator's first sight of them was the warning above. - -**No behaviour change to the seeding itself.** The eight rows are still minted, -still organization-less, still unreaped — that is the ruled outcome of #10103 -(2026-08-20), and `PLATFORM_ADMIN` is derived from an unscoped grant pointing at -the `admin_full_access` row *by row id*, so removing them would silently demote -every platform admin. Whether the platform bucket should be materialized per -organization remains the maintainer's open call, not this change. diff --git a/.changeset/where-id-predicate-conflict-refusal.md b/.changeset/where-id-predicate-conflict-refusal.md deleted file mode 100644 index 130c9ce5ac..0000000000 --- a/.changeset/where-id-predicate-conflict-refusal.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/objectql": minor ---- - -**BREAKING (accept-set tightening)**: a by-id `update` whose bound truthy scalar payload `data.id` stands beside a DECLARED but non-scalar `options.where.id` — `{ $in: [...] }`, an array, `null` — is now refused loudly (`UPDATE_ID_MISMATCH`, HTTP 400) instead of silently binding the payload row and discarding both the id predicate and any declared `multi: true` (#11230). - -`update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a', 'b'] } }, multi: true })` used to write exactly one row — `rec_1` — with no diagnostic: the payload id outranked `where` and `multi` alike (#5748), so the declared row SET and the declared bulk intent were both dropped, and `rec_1` need not even have been a member of the set. This was the LAST silent member of the dropped-declaration family (#5748 payload operator-objects, #11009 extra `where` keys, #11142 unequal scalar `where.id`); closing it reverses the remaining half of the #5748-pinned verdict `a SCALAR data.id still outranks where and multi`, which the maintainer ruling on #11230 (2026-08-23) authorizes. - -What changes, per call shape (`resolveEngineUpdateDispatch`, so every pinned test double inherits the same verdict): - -- A truthy scalar `data.id` beside a **non-scalar** `where.id` — an operator object, an array, `null`, or an explicitly-`undefined` `id` key — now **throws** `UPDATE_ID_MISMATCH` with `status: 400`, naming the payload id and the KIND of predicate the caller wrote. `multi: true` does not rescue the call (the payload id outranks `multi` per #5748, so the contradiction stands). Previously the write landed on the payload row with both declarations silently ignored. -- Boundaries that do **not** move: a **falsy** scalar `where.id` (`0`, `''`) is a scalar and keeps its #11142 verdict (by-id); a `where` that declares **no** `id` key at all (`{}`, or no `where`) is untouched; and with **no** scalar payload id the ladder is exactly as #5748 left it (`multi` when declared, otherwise `reject`) — the refusal lives only on the payload-sourced by-id arm. -- The refusal shares the #11142 error code deliberately — one ADR-0112 ledger member for one defect class, two messages. No new code is registered. - -A caller hitting the new refusal declared a row address and a row-set predicate in one call and meant one of them; each fix is a one-line edit at the call site: drop `id` from the payload to write EVERY row the predicate selects (`update(object, fields, { where: { id: { $in: [...] } }, multi: true })`), or drop `where.id` to write the single row the payload names (`update(object, { id, ...fields })`). The refusal text names both. Measured before shipping: **no in-repo call site constructs the pair** — every production `where.id` predicate (the outbox sweeps) carries a payload with no `id` — so the in-repo blast radius is nil; an external SDK caller can still write it, and today that silently drops both declarations. - - diff --git a/.changeset/wild-pears-remain.md b/.changeset/wild-pears-remain.md deleted file mode 100644 index dc16c2aae3..0000000000 --- a/.changeset/wild-pears-remain.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/service-automation": patch -"@objectstack/objectql": patch ---- - -Arm a deterministic flow when a runtime-authored flow reuses a packaged flow's name - -A runtime-authored flow that reused a packaged flow's name silently replaced it, -and which of the two ended up armed depended on registration order. The metadata -registry keys items `packageId:name` and deliberately coexists both (ADR-0048 -§3.4), `listItems('flow')` returns both with no precedence, and the automation -engine keys flows by bare name — so the boot pull registered both under one key -and Map iteration order picked the survivor. Measured: registering the package -first armed the runtime flow, registering the runtime row first armed the -packaged flow, with no warning and no way to tell which had won. - -The boot pull now collapses same-named definitions before anything is armed, -applying the ADR-0005 overlay precedence ADR-0048 §3.4 routes this case to: the -runtime/DB overlay wins over the packaged artifact, which is the sanctioned -override path. Two packages shipping one bare name resolve by package id, so -boot order no longer decides anything. - -Collisions are no longer silent. The pull warns once per colliding name — naming -the name, every contender, and which one is armed — and repeats it at bootstrap -beside the other automation audits. `getShadowedFlows()` is a new receipt listing -each contested name with its armed and shadowed definitions, and -`getFlowRuntimeStates()` rows now carry `armedFrom`/`shadowed` for contested -names; previously the displaced definition was invisible by construction, since -the flow map holds one entry per name. The `Pulled N flow(s)` line now counts -distinct names rather than registrations. - -`isCodeArtifactBody` is exported from `@objectstack/objectql` so consumers that -collapse same-named metadata answer "does a code package ship this?" with the -registry's own test instead of re-deriving it from `_packageId`. diff --git a/.changeset/write-path-refusal-code-parity.md b/.changeset/write-path-refusal-code-parity.md deleted file mode 100644 index e801df08dc..0000000000 --- a/.changeset/write-path-refusal-code-parity.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -Serve the `code` a sandboxed hook declared on `/api/v1/data` refusals, at every -status — not only where a bespoke arm happened to catch the condition first - -Measured on a booted 17.1.0 server: a hook throwing -`Object.assign(new Error(msg), { code: 'RECORD_LOCKED', status: 409 })` reached -the client as `409 {"error":"…","object":"crm_opportunity"}` — the status but no -machine-readable `code`. Same for `DUPLICATE_VALUE` on `POST` and `FORBIDDEN` on -`403`, while `DELETE_RESTRICTED` at the same 409 and `VALIDATION_FAILED` at 400 -carried theirs. A client that must tell "this record is frozen, do not retry" -from "this value is already taken, offer a merge" got `409` for both and had to -substring-match prose that is localised and deliberately reworded over time — -the failure mode the ADR-0112 `code` vocabulary exists to remove. - -The branch is `classifyDataError`'s **sandbox unwrap door** in -`error-response.ts` (`typeof error?.innerMessage === 'string'`), which rendered -from the raw error and emitted no `code` at all, while every arm around it -renders from the resolved envelope. That is the whole of the reported -correlation between "no `code`" and "the unwrapped message": they are one -branch, not cause and effect. - -It was never the status policy it looked like from outside. The door dropped -`code` on a declared **400** exactly as on a declared 409, and kept it on a -declared 5xx by falling through to the passthrough below — one sandboxed -producer, its code surviving 503 and lost at 409. What made the reading look -status-shaped is which codes have a bespoke arm above the door: -`DELETE_RESTRICTED` and `VALIDATION_FAILED` do and never reach it, -`RECORD_LOCKED` / `DUPLICATE_VALUE` / `FORBIDDEN` do not and did. - -The code now rides via `thrownCodeFields`, the one definition the three sibling -arms already use, so the door joins the closed ADR-0112 vocabulary: a registered -spelling arrives verbatim, an unregistered one is demoted to `declaredCode` -beside the status-derived member. Nothing is invented — a producer that declared -no code still gets a body carrying none. - -Unchanged: the business message is still the unwrapped `innerMessage` (never the -`hook 'x' threw: …` debug wrapper), a crashing hook body is still the sanitised -`500 INTERNAL_ERROR`, a declared 5xx still withholds its prose, and a refusal -that declares no status still answers 400. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 1ed1ce73e1..0133fcca3b 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.2.0 + ghcr.io/objectstack-ai/objectstack:17.3.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.2.0 + ghcr.io/objectstack-ai/objectstack:17.3.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.2.0 +FROM ghcr.io/objectstack-ai/objectstack:17.3.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.2.0 +RUN npm install -g @objectstack/cli@17.3.0 WORKDIR /srv/app RUN chown node:node /srv/app diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 510d215b40..77533b9404 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -38,7 +38,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.2.0 +image: ghcr.io/objectstack-ai/objectstack:17.3.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 041d537695..a360bfe4f7 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.2.0 +FROM ghcr.io/objectstack-ai/objectstack:17.3.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.2.0 + ghcr.io/objectstack-ai/objectstack:17.3.0 ``` `OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -62,5 +62,5 @@ reverse-proxy / multi-node guidance: ## Local build of this image ```bash -docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.2.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.3.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index aeb721d311..4361e8fb88 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,125 @@ # @objectstack/example-crm +## 4.0.95 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c68c670] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [9981f31] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + ## 4.0.94 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 36d9602e52..52e8f9f7e7 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.94", + "version": "4.0.95", "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-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index b17ee6edd2..50a51f07d2 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,165 @@ # @objectstack/example-showcase +## 0.3.17 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [178f90c] +- Updated dependencies [84de7e3] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [3d79144] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [f9ffd01] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [9d3c04d] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [12e306a] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [107bb4b] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [bbf1167] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c68c670] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [7986d97] +- Updated dependencies [1c66fe4] +- Updated dependencies [033a34c] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [4d98d9e] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [9981f31] +- Updated dependencies [c49afd0] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/service-datasource@17.3.0 + - @objectstack/cloud-connection@17.3.0 + - @objectstack/connector-mcp@17.3.0 + - @objectstack/connector-openapi@17.3.0 + - @objectstack/connector-rest@17.3.0 + - @objectstack/connector-slack@17.3.0 + ## 0.3.16 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 14e8337712..7b3ddb7abd 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.16", + "version": "0.3.17", "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 e9e32fd1c9..b6de68acb7 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,153 @@ # @objectstack/example-todo +## 4.0.95 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [887b97d] +- Updated dependencies [7899f57] +- Updated dependencies [6274a1a] +- Updated dependencies [1fa05a6] +- Updated dependencies [22c42c9] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [1524927] +- Updated dependencies [f4e7ae5] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [4ceae8a] +- Updated dependencies [48318f7] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c5b9ccc] +- Updated dependencies [c68c670] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8649b39] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [9981f31] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/client@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/mcp@17.3.0 + - @objectstack/driver-sqlite-wasm@17.3.0 + - @objectstack/knowledge-memory@17.3.0 + - @objectstack/service-knowledge@17.3.0 + ## 4.0.94 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index f5efb426c6..0e20505d1f 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.94", + "version": "4.0.95", "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 5c6f39ce56..44976cbd5c 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,129 @@ # @objectstack/example-embed-objectql +## 0.0.35 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c5b9ccc] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] + - @objectstack/spec@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/driver-memory@17.3.0 + ## 0.0.34 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index 7aeb7d194e..476208f119 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.34", + "version": "0.0.35", "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 c216a565da..528843917b 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/hono +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [e5ce2ed] +- Updated dependencies [c68c670] +- Updated dependencies [2cf5a96] +- Updated dependencies [fb5fbb8] +- Updated dependencies [7986d97] +- Updated dependencies [735f5c7] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [9981f31] +- Updated dependencies [9735662] + - @objectstack/runtime@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-hono-server@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 2c058107f6..a24d20a546 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.2.0", + "version": "17.3.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 ddec04a5a6..257005cc5e 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,123 @@ # @objectstack/account +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index 4c13c1b396..f84317e009 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.2.0", + "version": "17.3.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 b05040bc77..8edbbe86ff 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,123 @@ # @objectstack/setup +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index fcf250b258..8a664845d6 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.2.0", + "version": "17.3.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 d9de273284..28bc8a630f 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,123 @@ # @objectstack/studio +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index 23f587d4c5..09315e787f 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.2.0", + "version": "17.3.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 4b56decb6d..e8bbbdf216 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,1606 @@ # @objectstack/cli +## 17.3.0 + +### Minor Changes + +- d114d5e: feat(cli): `os build --json` carries the computed advisory lists on every failure exit, not the success payload alone (#11772) + + **Machine-contract widening on the `--json` failure payloads.** A consumer that + today branches on `warnings` being ABSENT from an `os build --json` failure + payload sees a different shape after this change. + + ## What was wrong + + The text face prints its advisory blocks before the gates that can stop the + run — the #11529 author-time advisories at step 3b, the #3786 undeclared + authoring-key findings at 3d — and both end in `— re-run with --json for the + full list`. But `warnings` lived on the TERMINAL SUCCESS payload only (plus, + for `ruleAdvisories` alone, the author-time-rules failure). On a tree with 60 + undeclared authoring keys *and* a package-docs error: + + ``` + os build Undeclared authoring keys (60) … 50 rows … + … and 10 more … — re-run with --json for the full list + os build --json {"success":false,"error":"docs validation failed","issues":[…]} + ^ the 60 keys nowhere + ``` + + The remedy the notice named returned a payload that did not contain the list, + and the author could not reach the withheld entries by any route until an + unrelated later failure was fixed — the "the remedy named is unreachable" + shape of #11643 and #11391. + + ## Which exits gain the field + + All nine failure exits of `os build --json`. Six already had a payload of their + own; three more were found while enumerating (the filing card's table listed + six). `warnings` is now present on every one, alongside each exit's existing + keys, which are unchanged: + + | exit (step) | existing keys | `warnings` before | after | + | --- | --- | --- | --- | + | `strict-body: missing body` (2b) | `issues` | absent | `[]` | + | protocol parse failure (3) | `errors` | absent | `[]` | + | `author-time rules failed` (3b) | `issues` | `ruleAdvisories` | unchanged | + | `capability provider preflight failed` (3c) | `issues` | absent | rule + capability | + | `access matrix drift` (3e) | `changes` | absent | rule + key + capability | + | `docs validation failed` (3f) | `issues` | absent | all four lists | + | `--no-runtime-bundle` refusal (4b) | `error` | absent | all four lists | + | `runtime bundle failed` (4b) | `error` | absent | all four lists | + | thrown / caught (bottom) | `error` | absent | what the run had computed | + + The success payload is unchanged in content: its + `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings]` + spread — `os validate --json`'s order minus its trailing `structuralWarnings` + — moved to a single `warningsSoFar()` site that every exit now reads, so the + member order cannot drift between exits. + + ## What a consumer keying off its absence should do instead + + ⛔ `warnings` is no longer a signal of which exit produced the payload. Read + `success` (and `error` / `errors`) for that; a consumer that inferred "this is + a failure payload" from a missing `warnings` must switch to `success === false`. + + ⛔ `warnings: []` on a failure payload does NOT mean "this tree raises no + advisories". It means **this run stopped before those advisories were + computed** — the two early exits above (`strict-body`, protocol parse) run + before any advisory step, so their list is empty by construction. A consumer + that needs the full advisory set for a tree must read it from a run that + reaches at least the gate that computes it, or from `os validate --json`. + + ✅ `warnings` is always an array on every `os build --json` payload, success or + failure, so it can be read unconditionally — that shape constancy is the point + of the change (maintainer ruling 2026-08-25, option 1 of three; option 2, + "carry them only where the text face printed them", was rejected as the hardest + contract to declare). + + Advisories stay CARRIED, never recomputed: each list is still computed at + exactly the step that owns it, so an exit upstream of a step legitimately + reports that list empty and no failure path pays for a computation it did not + already do. +- 3637731: `os lint` and `os i18n extract` gain the flow/screen bucket — a screen-flow + copy gap is reportable, and the `flows..screens..` skeleton is scaffoldable, + for the first time + + #11287 gave the bundle a `flows` group and a resolver that applies it. Nothing + on the CLI side walked it: `COVERAGE_SOURCE` had no flow bucket, so the + `i18n/missing-*` family could not report a screen-flow copy gap **at all**, and + `os i18n extract` never wrote the keys, so an author had no way to discover the + vocabulary. Measured on #11287: HotCRM reported **0 `i18n/missing-*` issues** on + a tree whose six screen dialogs rendered English in all four locales. An app + whose i18n gate is green is green because the surface is invisible to it. + + The shared walker (`collectExpectedEntries` — one definition of what is + translatable, feeding both the gate and the extractor) now harvests, per flow: + + ``` + flows..label + flows..screens..title + flows..screens..fields..label + flows..screens..fields..placeholder + ``` + + Screens are keyed by `FlowNode.id` and fields by `ScreenFieldConfig.name` — the + identifiers the runner already holds at render time, not a second naming + scheme. Missing keys report as `i18n/missing-flow`, per locale, with the same + opt-in rule as every other bucket: a project that declares no locales and ships + no bundle still reports nothing. + + The copy keys are **imported** from `@objectstack/spec/system` + (`FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`), never restated. They + are exported precisely so the extractor and the resolver cannot drift; the + schema-to-list agreement is pinned spec-side in `translation.test.ts`, and the + list-to-walker agreement is pinned here. + + Two seeding rules follow what the reader actually sees rather than which key + the author filled in. A screen's `title` falls back to the node `label`, + because the executor builds the wire title as `config.title ?? node.label` and + one bundle key covers whichever the runner draws. A field's `label` falls back + to its `name` as a *derived* seed: the skeleton stays usable while the gate + demands no translation for a string nobody authored. +- 79cf692: feat(cli): `os validate --json` and `os build --json` carry the computed `conversions` on every failure exit, not the success payload alone (#12125) + + **Machine-contract widening on the `--json` failure payloads.** A consumer that + today branches on `conversions` being ABSENT from an `os validate --json` or + `os build --json` failure payload sees a different shape after this change. + + ## What was wrong + + `conversionNotices` is filled by the `onConversionNotice` sink handed to + `normalizeStackInput`, which runs at **step 2** — above the schema parse and + above every later gate in both commands. The notice was therefore already in + hand when any failure exit fired, and was then discarded: `conversions:` was + published on the terminal SUCCESS payload alone. + + An ADR-0087 D2 conversion notice is the one advisory class that carries an + **expiry** — `retiresIn` names the protocol major where the old shape stops + loading. So a CI job gating on `os validate --json` / `os build --json` could + not see that its tree depends on a conversion about to retire for as long as + the tree also tripped any unrelated gate — the notice was withheld exactly + while the tree was broken, which is when an author is most likely editing it. + + This is the same "computed, then dropped on a failure exit" shape as the + `warnings` family (#11643 / #11391 / #11772 / #12047), one field over. #12079 + added `warnings` to all nine `os build` failure exits and deliberately left + `conversions` untouched, so closing those cards did not close this one. + + ## What changed + + `conversions` is now present on every `emitJson` exit of both commands — + 6 in `validate.ts` (5 failure + success), 10 in `compile.ts` (9 failure + + success) — alongside each exit's existing keys, which are unchanged. + + | command | exit | `conversions` before | after | + | --- | --- | --- | --- | + | `os validate --json` | protocol parse failure | absent | the computed notices | + | `os validate --json` | author-time rules failed | absent | the computed notices | + | `os validate --json` | capability provider check | absent | the computed notices | + | `os validate --json` | package docs failed | absent | the computed notices | + | `os validate --json` | thrown / caught | absent | what the run had computed | + | `os build --json` | all nine failure exits | absent | what the run had computed | + + The success payloads are unchanged in content. + + Notices are **carried, not recomputed**: the fix is a pure scope change — the + sink array is declared above the `try` so the catch-all exit can read it — and + `normalizeStackInput` still runs at exactly step 2. A run that throws in + `loadConfig`, above step 2, therefore reports `[]` honestly. + + ⭐ Note the two fields differ on `os build`'s two earliest exits. For `warnings`, + `--strict-body` and the protocol parse are empty by construction (nothing + advisory is computed that early); step 2 is **above** both, so `conversions` is + populated there. The field was measured per exit rather than inherited from the + sibling change. + + ## What a consumer keying off its absence should do instead + + ⛔ `conversions` is no longer a signal of which exit produced the payload, nor + of success. Read `valid` (validate) / `success` (build), and `error` / + `errors`, for that; a consumer that inferred "this is a failure payload" from a + missing `conversions` must switch to the explicit status field. + + ⛔ `conversions: []` on a failure payload does NOT mean "this tree converts + nothing". It means **this run stopped before the conversion layer ran** — a + config that fails to load reports `[]` by construction. A consumer that needs + the true conversion set for a tree must read it from a run that reaches at + least step 2. + + ✅ `conversions` is always an array on every `os validate --json` and + `os build --json` payload, success or failure, so it can be read + unconditionally — that shape constancy is the point of the change (maintainer + ruling 2026-08-25 on #11772/#12047, option 1 of three, applied here under the + same-family rule; option 2, "carry it only where the text face printed it", was + rejected as the hardest contract to declare). + + ✅ Each entry keeps its structured fields — `conversionId`, `surface`, `from`, + `to`, `path`, `toMajor`, `retiresIn` — on failure exits exactly as on the + success payload, so a CI job can gate on `retiresIn` without a second run. + + Exit codes are untouched: every failure exit still exits 1. `--strict` on + `os validate` still reads the text face's own list, which folds conversion + notices in, so `os validate --json --strict` reaches the same verdict it did + before. + + `warnings` and `conversions` remain **separate fields**. Whether the two should + be folded into one is a live question raised on #12125 and not settled by the + ruling; this change deliberately mirrors the `warnings` shape rather than + merging either field into the other. +- 0043c92: feat(cli): `os serve` announces a shifted port, naming the one you asked for and the one it took (#12543) + + In development (`os dev`, `--dev`, or `NODE_ENV=development`) `os serve` hops to + the next free port when the requested one is taken, so several example apps can + run side by side. That behaviour is unchanged and deliberate — production still + refuses to drift (#11113). What changed is that the hop is no longer silent. + + Previously the only trace of a shift was the ready banner printing the port that + was *bound*; nothing said it was not the port that was *asked for*, so every + reader had to already know the requested port and compare the two by hand. A + boot that shifts now prints, before anything else: + + ``` + ⚠ Port 32869 is in use — serving on 32871 instead. + Development auto-shift: 32869 was not free, so this server took + the next one that was (32871). Anything still pointed at 32869 — a + proxy, an OAuth callback URL, another terminal, a test harness — is + talking to whatever holds 32869, not to this server. + ``` + + The notice is written to **stderr**, like every other `os serve` diagnostic: + `stdout` carries JSON-RPC frames whenever the stdio MCP transport is mounted, so + nothing but protocol may go there. It prints only when the bound port actually + differs from the requested one — an ordinary boot on a free port is unchanged, + byte for byte. +- 8542bd4: **Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). + + A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. + + What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. + + ```js + // this record — nameField, formula or stored column alike + await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); + // a related record, through the lookup column that holds its id + const account = await ctx.title('account_id'); + ``` + + **Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. + + **Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. + + **It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. + + Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. +- 09b4f4e: feat(platform-objects,cli): record which source revision a generated translation leaf was filled from (#11671) + + Closes the half of the sticky-translation-drift class that no value comparison + could reach, under maintainer ruling #12069 Option A — by extending the existing + #8765 Option B source-hash mechanism to the generated bundles rather than + building a second one. + + **The hole.** `os i18n extract --fill=default` fills gaps only: any non-empty + value in a translated locale wins forever. So the ordinary sequence — extract, + revise the source string, extract again — rewrites `en` and strands the previous + source text in every other locale. The bundle is still in sync by key, so + `check:i18n` reports OK; the leaf is still present, so `check:i18n-coverage` + counts it translated. Measured on #11659 at `bbe0b17`: three locales serving a + 602-char superseded draft of a 411-char help string under 31 green checks. Once + the source has moved, that stale fill is indistinguishable **by value** from a + real translation — 2648 of 3010 leaves differ from `en`, so "untranslated AND + differing from the source" describes an empty set, not a noisy one. + + **What is new.** `os i18n extract --source-hashes` writes + `.source-hashes.generated.ts` beside each generated bundle: per leaf, + the digest of the source revision that leaf is **still a byte copy of**. + `withSourceFallback` takes that table as a fourth argument and now judges the + `objects` / `metadataForms` sections as well as the hand-authored ones, so a + leaf whose source has moved underneath it serves the current source string + instead of a superseded draft — the same degradation an untranslated key already + produces, which is the invariance the #8765 ruling turned on. + + The generated half needs one conjunct the hand-authored half does not: the leaf + must still hold the recorded bytes. Its hash table is itself generated, so a + translator cannot be asked to refresh a digest by hand the way + `.source-hashes.ts` asks; without that conjunct, re-translating a stale + leaf would leave the old record standing and report the fresh translation as + stale forever. With it, editing the value clears the flag by itself. + + **Behaviour on the day it lands: unchanged for every leaf.** Records are + written only where a leaf is currently a byte copy of the **current** source, so + every record equals the current digest and nothing is stale. Measured across the + nine bundle sets: 9030 translated leaves, 1543 byte-equal to `en` (records + written), 7487 differing (left with no record — legacy-trusted, per the ruling's + property 1, since nothing in the tree says which revision they were made from). + No committed bundle changed a byte. + + **Scope.** `--source-hashes` is off by default and `@objectstack/platform-objects` + is the one bundle set that opts in, by documenting the flag in its extract + config. The other eight sets keep exactly today's behaviour and can be enabled + file-by-file later; a set with no companion is entirely legacy-trusted. + + The false "this hole cannot occur there" note that kept the generated sections + out of the mechanism is corrected in `source-hash.ts`, with the measurement that + falsifies it. +- 0e5bea6: New operator-run command `os migrate multi-value-columns`: migrates a stale `varchar`/`text` column to `json` where the field declares `multiple: true` — the `manual_column_type_change` drift `os migrate apply` reports and deliberately never reconciles for you (#11535, ruled C on #11700). Flags: `--apply` (default off), `--yes`/`-y`, `--force`, `--table ` (repeatable), `--database-url`, `--json`. **Dry-run contract: without `--apply` the command executes nothing at all** — it prints the exact statements and the database they would run against, opens no seam and issues no probe, and a run is verified to have left the column type and every row unchanged. `--apply` runs `@objectstack/driver-sql`'s own `manualJsonConversionSql` — newly re-exported from that package's index for this consumer, its only other change — i.e. the statement the drift finding itself prints (Postgres: one `ALTER … USING (CASE …)` with `json_build_array`; MySQL: the two row-shaping `UPDATE`s then `ALTER … MODIFY … json`), refuses to execute anything the finding does not contain verbatim, re-runs detection afterwards and exits non-zero if the finding has not cleared. SQLite is excluded — the stale column round-trips a real array there, so the finding is never raised. Rows corrupted before the column is migrated are out of scope, and the command is never invoked automatically: nothing on the boot path reaches it. +- 918988a: fix(cli): `os serve` defaults `NODE_ENV` to `production` when unset, exactly as `os start` already does (#11113) + + **BREAKING for a deployment that runs `os serve` with `NODE_ENV` unset and + relies on a development-class convenience surviving into a real boot.** + Shipped as `minor` under the repo's launch-window convention for breaking + changes, not `patch` — this is a deliberate default flip, not a bugfix that + restores previously-intended behaviour. + + `os start` has forced `NODE_ENV='production'` on the unset case since #5673, + but it does so on the child environment it assembles for its **spawn** + (`start.ts:347`). `os serve` runs **in-process** — there was no equivalent + write, so the whole family of `NODE_ENV !== 'production'` gates across the + tree read the raw `undefined` and took the non-production branch on a boot + that never declared itself anything else. Filed as #11113, the declared + residual of #10366 (which closed the same gate's *set-but-wrong* case and + left this one for its own card, per the disposition precedent on #11035). + + One line: `serve.ts` now defaults `process.env.NODE_ENV` to `'production'` + when unset, at the same point it already defaults it to `'development'` under + `--dev` — before any of the runtime modules it dynamically imports, and before + every gate downstream reads the variable. An explicitly-set `NODE_ENV` + (`development`, `test`, anything else) is never overridden. + + The full behaviour-flip survey — every `NODE_ENV`-reading predicate in the + tree, which ones flip and which don't, and why — is in the PR body (#11113), + not repeated here. Highlights of what an unset-`NODE_ENV` `os serve` boot now + gets, that it did not before: + + - plugin-auth's localhost trusted-origin CSRF substitution closes (the + regression this card pins). + - plugin-auth's CSRF Origin/Referer synthesis for headerless requests closes. + - plugin-auth's missing-`OS_AUTH_SECRET` fallback to a forgeable + `dev-secret-` becomes a refusal to boot instead. + - plugin-auth stops printing invitation / magic-link URLs and OTP codes to + logs. + - plugin-dev's ADR-0115 D6 boot guard now refuses to initialize the dev + assembly (well-known auth secret, seeded dev admin) instead of loading it. + - the SQL driver's auto-DDL guard stops silently applying `safe` schema drift. + - the seed loader stops seeding dev-scoped datasets into what it previously + could not tell apart from production. + - service-settings' local crypto provider now requires a stable key instead + of tolerating an auto-generated / ephemeral one. + + Every one of those is the intended tightening this card exists to make: an + operator (or an AI-authored deploy script) that never exported `NODE_ENV` is + running a real deployment, and the safe direction is to treat it as one, loud + failures included, rather than silently keep a development-class door open. + `NODE_ENV=development` / `NODE_ENV=test` — including the flows `os dev` and + `os serve --dev` already carry — are unaffected; only the unset case moves. + + +- 33e81a5: feat(cli): `os validate --json` carries the computed advisory lists on every failure exit, not the success payload alone (#12047) + + **Machine-contract widening on the `--json` failure payloads.** A consumer that + today branches on `warnings` being ABSENT from an `os validate --json` failure + payload, or that reads it as "the author-time rule advisories", sees a + different shape after this change. + + ## What was wrong + + The text face prints its advisory blocks ending `— re-run with --json for the + full list`, but `warnings` lived on the TERMINAL SUCCESS payload only — plus + `ruleAdvisories` alone on two of the five failure exits. So the remedy the + notice named returned a payload that did not contain the list, and the author + could not reach the withheld entries by any route until an unrelated later + failure was fixed. + + The strongest instance is the parse-failure exit. `validate.ts` computes the + #3786 undeclared-authoring-key findings **before** the schema parse, precisely + so a finding survives an unrelated schema error — the parse is what strips the + key, so it cannot be recovered afterwards. That payload then dropped the list + anyway, defeating the one hoist that existed to prevent exactly this. + + ## Which exits gain the field + + All five failure exits of `os validate --json`. Two already carried a partial + list; three carried none. `warnings` is now present on every one, alongside + each exit's existing keys, which are unchanged: + + | exit | existing keys | `warnings` before | after | + | --- | --- | --- | --- | + | protocol parse failure | `errors` | absent | undeclared-key findings | + | author-time rules failed | `errors` | `ruleAdvisories` | rule + key | + | capability provider check | `errors` | absent | rule + key + capability | + | package docs failed | `errors` | `ruleAdvisories` | rule + doc + key + capability | + | thrown / caught | `error` | absent | what the run had computed | + + The success payload is unchanged in content: its + `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, ...capProviderWarnings, ...structuralWarnings]` + spread moved to a single `warningsSoFar()` site that every exit now reads, so + the member order cannot drift between exits. + + `structuralWarnings` is the one member `os validate` has that `os build` does + not, and it is **carried, not hoisted**: it is computed below all five failure + exits, so it rides each of them as an empty list and the success payload stays + the only exit that can ever show it non-empty. + + ## What a consumer keying off its absence should do instead + + ⛔ `warnings` is no longer a signal of which exit produced the payload. Read + `valid` (and `error` / `errors`) for that; a consumer that inferred "this is a + failure payload" from a missing `warnings` must switch to `valid === false`. + + ⛔ `warnings` on a failure payload is no longer only the author-time rule + advisories. It is the same heterogeneous list the success payload publishes — + rule and doc findings as RECORDS, undeclared-key and structural advisories as + STRINGS — truncated to what the run had computed. A consumer that assumed every + entry was a rule finding must classify by shape. + + ⛔ `warnings: []` on a failure payload does NOT mean "this tree raises no + advisories". It means **this run stopped before those advisories were + computed** — a config that fails to load reports `[]` by construction. A + consumer that needs the full advisory set for a tree must read it from a run + that reaches at least the gate that computes it. + + ✅ `warnings` is always an array on every `os validate --json` payload, success + or failure, so it can be read unconditionally — that shape constancy is the + point of the change (maintainer ruling 2026-08-25 on #11772, option 1 of three, + inherited here under the same-family rule; option 2, "carry them only where the + text face printed them", was rejected as the hardest contract to declare). + + Exit codes are untouched: every failure exit still exits 1, and `--strict` + still reads the text face's own list, so `os validate --json --strict` reaches + the same verdict it did before. + + Advisories stay CARRIED, never recomputed: each list is still computed at + exactly the step that owns it, so an exit upstream of a step legitimately + reports that list empty and no failure path pays for a computation it did not + already do. +- ab23c67: fix(cli): `os validate --json --strict` exits 1 on the configs `--strict` already exits 1 for (#11174) + + `commands/validate.ts` emitted the `--json` payload and `return`ed *above* the only + `flags.strict` reader, which sat inside the text-rendering block. So on one config, + one flag, two answers: + + ``` + os validate --strict → exit 1 ("Strict mode: warnings treated as errors") + os validate --json --strict → exit 0 + ``` + + `--strict` was accepted, documented — `content/docs/deployment/cli.mdx` spells + `os validate --json --strict` twice in its CI/CD section, once as a GitHub Actions + step — and inert whenever `--json` was also passed. That combination is the one + audience the flag exists for: a pipeline gating on the exit status of the documented + invocation read 0 and concluded the stack was clean. + + The `--strict` gate now reads the text face's own warning list, which is assembled + once and consumed by both faces, so the two exit codes cannot drift apart again. The + gate deliberately does **not** read the payload's `warnings` field: the two differ by + the ADR-0087 load-time conversion notices, which the text face folds into its warning + block while the payload carries them under `conversions`. Gating on the field would + have left the same divergence in place for a config whose only advisories are + conversion notices. `specVersionGap` stays outside `--strict` on both faces, as it + always has been on the text one. + + `valid: true` beside a non-zero exit is the text face verbatim, not a contradiction: + that path prints "Validation passed" and *then* fails for strict. The stack is + schema-valid; `--strict` is what promotes its advisories to a failure. + + **BREAKING** for one caller shape, and the reason this is not a patch: a pipeline + running `os validate --json --strict` over a stack that raises non-blocking + advisories was green and will now be red. Nothing was removed or renamed and no + authored metadata changes — the accept set is identical and the exit status is the + only thing that moves — but a release a CI system can take unattended must not flip + a green build to red, so this does not belong in a patch. It is not a major either: + the new behaviour *restores* what `--strict` declares ("Treat warnings as errors") + and what the docs already advertise, rather than contradicting a contract. Under + this repo's launch-window convention (breaking changes ship as `minor` while the + stack versions in lockstep) `minor` is the honest slot. + + ## What a pipeline gating on the payload has to read + + `--strict` gates on the text face's warning list, and that list is **not** the payload's + `warnings` field. The two differ by the ADR-0087 load-time conversion notices: the text + face folds them into its warning block, while the payload carries them separately under + `conversions`. So a pipeline that wants to reproduce `--strict` from the document must + read **both**: + + ``` + warnings.length > 0 || conversions.length > 0 + ``` + + Gating on `warnings` alone is strictly weaker than `--strict` — a config whose only + advisories are conversion notices passes that check and fails `--strict`. That is the + same silent under-reporting this change exists to remove, so do not reach for the + narrower spelling. + + The consequence is reachable and worth stating outright, because it is surprising: a + conversions-only config now exits **1** with `"warnings": []` and a populated + `conversions`. Predicting the exit code from `warnings.length` alone will be wrong for + exactly that config. Nothing is missing from the document — both advisory streams are in + it — but they sit in two fields and the exit code answers to both. + + If a pipeline genuinely wants the old exit status, the honest fix is to say so rather + than to keep passing a flag that means the opposite: drop `--strict` and read the + payload. If it goes red instead, the advisories were always there — the text face had + been printing them all along. + + + +### Patch Changes + +- f648cbe: Carry the capability-provider (#3366) and package-docs (ADR-0046) warnings in + the `os build --json` payload, so its `warnings` list matches + `os validate --json` on the same tree + + `os build --json` reported a strictly smaller `warnings` list than + `os validate --json` did for the same stack. #11643 closed the gap for the + undeclared-authoring-key findings; two lists were still behind it — the #3366 + installable-provider hints (an unknown capability token, or a provider that is + absent but addable with `pnpm add`) and the ADR-0046 package-docs advisories. + A CI job gating on `os build --json` therefore read an empty advisory list for + a stack that names a typo'd capability and ships a doc whose frontmatter tags + were silently dropped, while the identical job gating on `os validate --json` + read both. + + Measured over one temp project at `origin/main` `589758d22`, both commands + exiting 0: + + ``` + os build ⚠ requires: "zzz_unknown_capability_token" is not a known platform capability — check for a typo. + ⚠ src/docs/advparity_guide.md: Frontmatter `tags:` … is not a list this reader understands … + os validate --json warnings: [ {doc record}, {token,message}, "No apps or plugins defined …" ] + os build --json warnings: [] ← both lists dropped + ``` + + `compile.ts` computed both and then rendered them **inside** the + `if (… && !flags.json)` print blocks, which put them structurally out of reach + of the payload: computed, then discarded, for the one audience `--json` exists + to serve. This is the fourth measured instance of that shape in these two files + (#10953, #11174, #11643), and it takes the established fix — hoist the + formatting to the computation site so one list feeds both faces and they cannot + report different sets. + + **Order and shape are mirrored from `os validate --json`, not chosen here.** + That payload reads `[...ruleAdvisories, ...docWarnings, ...unknownKeyWarnings, + ...capProviderWarnings, ...structuralWarnings]`; `os build --json` now emits + that list minus its last member. Doc advisories ride as the issue records + `collectAndLintDocs` returns and capability hints as `{ token, message }`, + which is what validate ships for each, so a consumer reads one shape per class + from either command rather than learning two. + + **No new key.** Both lists land in the `warnings` key the payload already + declared — "the whole registry's advisory set, in the shape `os validate --json` + reports", as its own comment has always said. The payload's top-level key set is + unchanged and pinned as unchanged. + + **`structuralWarnings` is deliberately not included.** `os validate` derives + four structural advisories ("No objects defined", "No apps or plugins + defined", and two manifest ones) from `collectMetadataStats`; `os compile` + calls that same helper but computes none of them, in any face. That makes it a + missing computation rather than a dropped list, and whether a command that + writes an artifact should raise them is a judgment rather than a mechanical + port. It is split out as #11896 and pinned as the only remaining residue between + the two payloads, so the question stays visible and a fifth genuinely dropped + list cannot hide in the gap. + + Text output is unchanged. +- 945ffbe: Carry the undeclared-authoring-key warnings in the `os build --json` payload, + so its `warnings` list matches `os validate --json` on the same tree + + `os build --json` reported a strictly smaller `warnings` list than + `os validate --json` did for the same stack, and the missing members were + exactly the "your key was dropped at load" ones (#3786 / ADR-0087). A CI job + gating on `os build --json` therefore could not see the class of warning that + silently discards authored metadata — while the identical job gating on + `os validate --json` did. + + Measured over one temp project at `origin/main` `4ceae8ab0`, three faces of one + authored stack whose field carries an undeclared key nested in `visibleWhen`: + + ``` + os build ⚠ Undeclared authoring keys (1) — dropped at load (#3786) + os validate --json warnings: [ {rule record}, "…zzzUnknownKey…" ] + os build --json warnings: [ {rule record} ] ← the dropped list + ``` + + `compile.ts` computed the findings and then formatted them **inside** the + `if (… && !flags.json)` print block, which put them structurally out of reach + of the payload: computed, then discarded, for the one audience `--json` exists + to serve. `os validate --json` had this exact defect on its own face and fixed + it by mapping the findings through `formatUnknownAuthoringKey` at the + computation site; `os build` now does the same, so one list feeds both faces + and they cannot report different sets. + + **No new key.** The findings land in the `warnings` key the payload already + declared — its own comment has always said "the whole registry's advisory set, + in the shape `os validate --json` reports" — carried as formatted strings + beside the authoring-rule records, which is byte-for-byte the heterogeneous + shape `os validate --json` already ships. Consumers reading `warnings` off + either command now read one shape for one class of warning. The payload's + top-level key set is unchanged and pinned as unchanged. + + This also makes an existing promise true. The truncation notice added in + 17.2.0 ends with "re-run with `--json` for the full list"; that pointer was + honest about the authoring-rule advisories and would have been false about the + undeclared-key list, which is why that change left the second list without a + notice. + + Text output is unchanged. +- a422843: `os build` says how many author-time warnings it withheld, instead of stopping + dead at 50 + + The author-time advisory printer emitted a fixed 50 detailed entries and then + stopped, with nothing in the output saying the list had been cut. Measured on + `objectstack-ai/hotcrm` with the published 17.1.0 CLI: two `objectstack build` + runs over the same tree, before and after a five-warning fix, printed 50 + detailed entries each — 184 output lines and 52 warning lines both times — + while the summary line counted 80 and then 75. The two numbers disagreed and + nothing explained why. + + The defect is the **silence**, not the cap. Truncated output that carries no + notice is not merely incomplete, it is indistinguishable from complete: an + author who reads the report and sees their file is clean has read a list that + stopped early. Because advisories are ordered by surface (pages, then views, + then flows), a repo whose page warnings alone exceed the cap keeps every `view` + and `flow` advisory permanently invisible — and fixing warnings then makes new + ones *appear*, which reads as a regression caused by the fix. + + The cap stays, and over it the output now names the exact remainder: + + ``` + ⚠ … and 30 more author-time warning(s) not shown (50 of 80) — re-run with --json for the full list + ``` + + At or under the cap no such line appears, and the detail entries themselves are + byte-for-byte what they were. The pointer is `--json`, which already publishes + the whole set under `warnings` — an existing complete-output path rather than a + new flag. No new verbosity tier, no paging, no configuration surface. + + `os validate` was checked at the same time and does **not** truncate its + advisory list: it prints every warning it collected. Only the `build`/`compile` + printer had the cap. +- e278a29: fix(cli): `register`/`whoami`/`logout` examples no longer spell `os auth ` in live `--help` output (#11221) + + `static override examples` is printed verbatim as part of oclif's `--help`. `register.ts`, + `whoami.ts` and `logout.ts` live at the **root** of `packages/cli/src/commands/`, so oclif's + pattern-strategy loader registers them as `register` / `whoami` / `logout` — but their + `examples` spelled an `os auth ` shape that has never resolved. A user copy-pasting + straight out of `--help` hit `Error: Command auth:whoami not found.` (exit 2), the same dead + command #10927 fixed in `packages/cli/README.md` and #10967 fixed for the `environments` + topic, this time on the root auth-family commands. + + Measured against the built CLI (`packages/cli/bin/run.js`) before the fix: `os auth whoami`, + `os auth register` and `os auth logout` each exited 2 with `Error: Command auth: not + found.`, while the bare `os whoami` / `os register` / `os logout` each exited 0 and printed + help — so the examples named the one spelling that could not work. All seven `examples` + entries now say the bare, registered spelling. + + The exported default class on each file is renamed to match its real, file-path-derived + command id (`AuthRegister` → `Register`, `AuthWhoami` → `Whoami`, `AuthLogout` → `Logout`). + oclif derives a command's id purely from its file path, never from the class name, so this + changes no runtime resolution — confirmed by rebuilding the CLI and re-running `--help` on + all three. The rename also brings them onto this package's measured convention: every other + root-level command class is exactly the PascalCase of its filename. `login.ts` keeps + `AuthLogin` — its `examples` were already correct (`$ os login`), so it is outside this + card's file surface; that lone remaining class-name holdout is reported, not swept. + + `environments.test.ts`'s `#10967` pin carried a deliberately self-retiring `EXCLUDED` entry + for each of these three files, asserting the defect was *still present* so the exemption + could not outlive its cause. This fix removed the last unresolved entry, that assertion went + red exactly as designed, and the three entries are retired — the map is now empty and all + three files are scanned by the main assertion like every other command source. +- 63da586: fix(cli): the "no stored credentials" error stops telling a stuck user to run `os auth login` (#11313) + + `readAuthConfig()` (`packages/cli/src/utils/auth-config.ts`) throws the one instruction a user + gets at the moment they are **already stuck**: they have no stored credentials, the command + they wanted has just failed, and this string is what tells them how to recover. It said + `os auth login`, which does not resolve — `login.ts` sits at the **root** of + `packages/cli/src/commands/`, so oclif's pattern-strategy loader registers it as `login`, and + no `auth` topic has ever existed. The second failure reads as "the tool is broken", not as + "a typo in a help string", which is why this is graded on its own terms rather than as another + stale-`examples` docs nit (#11221, #10967, #10927 were all `examples` arrays and README prose + — a user reading *ahead*). + + Measured against the built CLI (`packages/cli/bin/run.js`, after building the package and its + dependency closure) before the fix: `os auth login --help` → `Error: Command auth:login not + found.` (exit 2), `os login --help` → exit 0. Loading the built oclif `Config` enumerates 61 + registered ids, **zero** containing `auth`, and no `auth` topic — with `login`, `logout`, + `register`, `whoami`, `dev` and `serve` all present as the control that the zero is a real + absence rather than a broken probe. The message now says `os login`, the spelling `login.ts`'s + own `examples` already used. + + One further invocation in the same file is corrected in the same pass: + `AuthConfig.activeEnvironmentId`'s doc comment said `os projects switch`, and `projects` was + renamed to `environments` in v5.0 with no aliases (ADR-0006) — the same enumeration shows no + `projects` topic and no id containing `project`, while `environments switch` is registered. It + is fixed rather than excluded because the new pin scans the whole file, and an exclusion is how + a line stops being checked without anyone deciding to stop checking it. + + The pin (`packages/cli/src/utils/auth-config.test.ts`) asserts the **property**, not the new + spelling: every command invocation this file documents must resolve to an id the CLI actually + registers, with the id set re-derived from `src/commands/**` using oclif's own path→id + algorithm. A pin on the literal text `os login` would still pass on the day someone renames + `login.ts`; this one reds. It has two legs — the real `readAuthConfig()` driven into its real + ENOENT branch against a redirected `$HOME`, so what is checked is the message a user actually + reads, and a source-wide scan so a guidance string added to this file later is held to the same + property without anyone remembering to extend the pin. `environments.test.ts`'s `#10967` pin + reads `static override examples` via AST and structurally cannot see a thrown-error string, + which is why this class needed its own pin rather than an extension of that one. +- 7fa3851: fix(cli): the `os environments` family calls the entity an environment, not a project, in every string it prints (#12153) + + Per ADR-0006 the v5.0 rename `project` → `environment` has no aliases, and AGENTS.md + states "Project now only means the npm/monorepo sense". #10967 (PR #11227) renamed the + **command** (`os projects …` → `os environments …`) across these same five files; the + **entity noun** inside the strings oclif prints was left behind. A user ran + `os environments switch ` and the tool answered `✓ Active project: …`. + + 25 user-visible string literals in `packages/cli/src/commands/environments/` are swapped + to the post-rename noun. No behaviour, no flag or argument names, no exit codes, and no + `--format json` / `--format yaml` payloads change — those are produced by + `formatOutput(res, …)` straight from the control-plane response and are untouched. + + | where | count | printed by | + | --- | --- | --- | + | `static override description` | 5 | `os environments --help` | + | flag / arg `description` | 7 | each command's own `--help` | + | success-path console output | 9 | `list` · `bind` · `create` · `show` · `switch` | + | `examples` arg placeholder (`` → ``) | 3 | `os environments bind --help` | + | the `switch` id-not-found error | 1 | `os environments switch` on a bad id | + + The five `static override description` strings now read as + `content/docs/deployment/cli.mdx`'s command table has described them since the rename + ("List environments visible to the current session", "Provision a new environment", …), + so the shipped `--help` and the shipped docs agree for the first time. + + What is deliberately NOT renamed, because it is API surface in other packages rather than + CLI wording, and each needs its own decision: `client.projects.*` (the `@objectstack/client` + SDK method names), the `res.project` / `res.projects` response fields, the locals bound + directly from them, and the docblock comments in these files. +- 05181e8: **Fix:** the `Runtime:` row of the `os validate` / `os info` / `os compile` summary is no longer dropped on a stack with no plugins, and `MetadataStats` no longer counts a metric nothing prints (#11172). + + Two separate holes in one function, `printMetadataStats` in `packages/cli/src/utils/format.ts`. Both were measured against the real CLI (`bin/run-dev.js validate`, `NO_COLOR=1`) on a stack declaring nothing. + + **1. `Runtime:` vanished at zero.** The row was rendered *outside* the `sections` loop, as a standalone `if (stats.plugins > 0 || stats.devPlugins > 0)` after the loop closed, so the whole summary was: + + ``` + Data: 0 Objects + UI: 0 Apps + Logic: 0 Flows + Security: 0 Positions 0 Permissions + ``` + + with no `Runtime:` line at all. That is the same "reads as never asked, not as zero" shape #10504 and #10952 removed from the four sections — a stack that declares no plugins is indistinguishable from a summary that simply does not report on the runtime. It now prints: + + ``` + Data: 0 Objects + UI: 0 Apps + Logic: 0 Flows + Security: 0 Positions 0 Permissions + Runtime: 0 plugins + ``` + + `Runtime:` was **folded into the `sections` array** rather than fixed where it stood. Being outside the loop was not incidental to the defect: it is why #10952's `zeroFallback` mechanism was structurally unable to reach this row, and a zero case hand-rolled beside the loop would have been a second, un-enforced copy of the same invariant — while `zeroFallback` is a *required* field on the array's element type precisely so the next row cannot be added without naming what it prints at zero. The per-item `> 0` filter this row already applied is the same filter the loop applies, so the only thing that had to be carried across was its fragment style, and it is carried exactly: the shipped non-zero rendering stays `Runtime: 2 plugins, 1 devPlugins` — comma-joined, fully dim, lowercase item names — rather than being restyled into the sections' ` ` two-space shape. The ruling was about the row's presence at zero, not its typography. + + `plugins` is the row's zero signal: `devPlugins` is a dev-only overlay on it, so `Runtime: 0 devPlugins` would have reported the narrower fact and stayed silent about the broader one. A row with one non-zero peer still reports only that peer (`Runtime: 4 devPlugins`), exactly as `Security:` behaves. + + **2. `translations` was counted on every run and read by nothing.** `MetadataStats` declared `translations: number` and `collectMetadataStats` populated it with `count(config.translations)`, but no render path ever read it — a stack with 40 translation bundles reported them nowhere in the summary, at *every* value rather than only at zero. The field is removed implementation-first (zero readers); giving it a rendered home, in `UI:` or a new `i18n:` row, was considered and explicitly not taken. + + The invariant that replaces it is enforced from both ends: TypeScript already requires `collectMetadataStats` to populate every field `MetadataStats` declares, and a new pin requires every field it collects to reach the rendered output. Declared ⇒ collected ⇒ rendered — a metric counted on every `os validate` and shown nowhere cannot satisfy the chain, whatever it is called, so the pin fails for the next unread metric as well as for this one. + + **One externally visible consequence beyond the summary text.** All three commands spread the whole `stats` struct into their `--json` payload, so `os validate --json`, `os info --json` and `os compile --json` no longer carry a `stats.translations` key. That field was undocumented (the CLI docs describe `--json` for these commands but declare no payload shape for `stats`), carried no schema, and has no reader anywhere in the repo — a repo-wide search for `stats.translations` returns zero consumers. The other 18 keys are unchanged. +- e278a29: docs(cli): drop the `os studio` row from the README command table — the CLI ships no such command (#11180) + + `packages/cli/README.md`'s **Development** command table listed + `` | `os studio [config]` | Launch Studio UI with development server | ``. The CLI has no + `studio` command and has not had one: the oclif command set is pattern-derived from + `packages/cli/src/commands/**`, and loading the built CLI's own `Config` enumerates 60 + registered ids with **zero** matching `studio` (control: `dev`, `serve`, `login`, `logout`, + `register`, `whoami` are all present in the same enumeration, so the check is not vacuous). + Running it confirms the same from the outside — `os studio --help` exits 2 with + `Error: Command studio not found.` + + The row is deleted rather than rewritten. Studio is not reached by a CLI command at all — + it is served by the console at `/_console/studio` after `os dev` or `os serve`, both of + which the same table already lists — so a replacement row would reintroduce the category + error that made this one wrong: a Commands table is a list of commands, and a browser route + is not one. +- 82b65ec: fix(cli): `os serve` resolves app-declared optional service packages from the app, not the CWD (#11185) + + `serve` takes its config as an **argument**, so `objectstack serve /srv/app/objectstack.config.ts` + is a supported invocation and the app being served need not be the directory the operator + stood in. Every host-anchored optional load nevertheless used `process.cwd()` as its + resolution base, so with that invocation the CLI read the wrong `package.json`: a package the + app really does declare, and really does carry in its own `node_modules`, came back + `undeclared`, fell through to the framework-side fallback, and boot died — + + ``` + Cannot find package '@objectstack/service-cluster': the host app does not declare it. + host app: /tmp/os-neutral-cwd-jXHXdF ← the CWD, not the app + (fallback resolution also failed: Cannot find package '@objectstack/service-cluster' + imported from …/packages/types/dist/node.mjs) + ``` + + Measured on the released EE 4.1.0 image as `OS_CLUSTER_DRIVER=redis` ⇒ migrate exits 1 ⇒ the + whole stack cannot start. This is the same class as cloud#1013 and #10645 with the base wrong + for a different reason: those fixed the **importer** at these load sites (bare `import()` → + `importFromHost`); this fixes the **base** that importer is handed. + + `serve` now resolves the config path and the app root in one call (`anchorServedApp`), so the + anchor cannot be written too late or left out by a future author — the absolute config path + every later line needs is produced by the same call that sets it. Every host-anchored load in + the file defaults to that root, which is what generalises the repair to the next app-declared + optional service rather than fixing this one instance. The alternative route — declaring + `@objectstack/service-cluster*` in `packages/cli`'s own manifest — was rejected: it would make + the open-core CLI take a published dependency on packages it never imports, still leave every + third-party or future optional service broken, and change nothing for an app whose config is + addressed by path. + + The adopted root is the config's directory **only when that directory holds a `package.json`**, + and the CWD otherwise. `readHostDeclaration` reads a manifest — reachability is deliberately + not the contract (#4719) — so a directory with no manifest declares nothing and anchoring + there could only turn a working boot into an `undeclared` refusal. No layout that resolves + today resolves differently after this. + + The #4719 declaration gate is untouched: a package present in the app's `node_modules` but + absent from its `package.json` is still refused. The refusal's remedy (`host app: …`) now + names the app being served instead of an unrelated directory the operator happened to be in. +- a4cb781: **Fix:** `os serve`'s host importer now states its own resolution base, so a package the served app does not declare resolves from `packages/cli` instead of from `@objectstack/types` (#11157). + + `createHostImporter` has two legs. The **declared** leg resolves out of the served app's `node_modules` (#4719; #11185 fixed *which* app that is). The **undeclared** leg falls back to "the importing package's own resolution" — and which package that is depends entirely on where the `import()` is physically written, because Node ESM resolves a bare specifier against the module containing the call. #10943 turned that into an explicit parameter, `options.fallbackImport`, so a caller can hand in its own `import()`. `@objectstack/verify` (`bootStack`) and the `packages/qa/dogfood` enterprise probe both pass theirs; `serve`'s `importFromHost` did not, so it advertised the CLI's resolution and actually used `@objectstack/types`', which under a pnpm-isolated layout sees only `@objectstack/spec`. + + **Measured accept-set delta**: the undeclared fallback now reaches exactly what `packages/cli` itself declares, and nothing else. Re-measured with `import.meta.resolve` from a probe in each package — `chalk`, `@objectstack/plugin-auth` and `@objectstack/plugin-audit` resolve from `packages/cli` and not from `@objectstack/types`; every specifier `serve` itself routes through the helper (`@objectstack/service-cluster` and its drivers, `@objectstack/service-i18n`, `@objectstack/organizations`, `@objectstack/service-ai`, `@objectstack/service-ai-studio`) resolves from **neither**, which is why this was harmless in every shape that ships today. The #4719 declaration gate on the declared leg is untouched: a package that is merely reachable is still refused, and no app gains a way to load something it has not declared. + + **Two user-visible consequences.** A `plugins: [...]` entry naming a package the app does not declare but the CLI ships now resolves through the host importer rather than a separate local `import()` — same module, one attempt instead of two. And the undeclared-package diagnostic drops its "the caller did not pass `fallbackImport`" note, which `@objectstack/types` emits only for callers that withhold their base; the note existed so this gap would report itself, and it has now been closed rather than silenced. + + `Serve.importConfigPlugin`'s three-branch shape collapses to two in the same change. The undeclared branch kept a local `import()` *because* the helper's fallback resolved from the wrong package; with the base threaded, that branch and the re-entry branch are the same call, so the declaration is read once — by `readHostDeclaration` inside the helper — instead of being asked there and again here. Behaviour was measured case by case first: the app's declared copy still wins, a declared-but-uninstalled package still reports the install remedy, a package present-but-throwing still propagates as a crash (both paths gate on the one shared `isModuleNotFoundError`), and a package resolvable nowhere still produces the #4719 "declare it in that app's package.json" text. +- 87d83ac: `os build`, `os validate` and `os init` say how many diagnostics they withheld + + Nine more renders across the three authoring commands cut their list at a fixed + cap and printed nothing saying so — the `--strict-body` refusal list, the + author-time rule failures, the undeclared-authoring-key findings, the + access-matrix drift, the package-doc errors, and both halves of `os init`'s + scaffold self-test. The defect is the **silence**, not the cap: truncated + output that carries no notice is indistinguishable from complete output, so an + author who reads it and sees no further problems has read a list that stopped + early. Two of them even stated the true total in their own header and then + showed fewer rows, so the report gave two numbers that disagreed and explained + neither. + + On the gating lists it also undoes the thing `os validate` went out of its way + to provide. Its own comment records why every failing rule reports at once: + "the command used to exit at the first failing gate, so an author with three + unrelated problems fixed them in three round trips and could not see how deep + the hole went". Past the cap that is exactly what came back, one cap-width at a + time, with each round of fixes revealing a new batch that reads as fresh + breakage. + + Every cap stays. Over it the output now names the exact remainder: + + ``` + ⚠ … and 30 more author-time rule failure(s) not shown (50 of 80) — re-run with --json for the full list + ``` + + **The pointer is verified per site, and two notices deliberately omit it.** + `--json` publishes each of these lists at the very exit whose text face carries + the notice, so re-running really does return the complete set. `os init` + declares no `--json` flag at all, so both of its notices state the remainder + and name no remedy — a notice whose remedy does not work is worse than a silent + cut, because it sends the author down a path that returns the same truncated + view. + + At or under a cap, nothing new is printed and the rendering is byte-for-byte + what it was. +- c770bda: chore(cli): pin the multi-org runtime name `os doctor` prints, so a roster rename cannot drift it silently (#12464) + + `doctor.ts`'s `TENANCY_POSTURE_FIX_HINTS` repeated `serve`'s `isolated` posture + sentence verbatim, carrying its own bare `@objectstack/organizations` literal + under no check at all. PR #12463 single-sourced and pinned every operator-facing + occurrence in `serve.ts`; this copy was outside that card's file surface and was + correctly left alone. The defect it left behind is the same class: a roster-key + rename would leave `os doctor` naming a package that boot no longer resolves, + **with every gate green** — the roster pin only ever sees the declaration, and + nothing read this hint table's text. + + The `isolated` hint now interpolates a module-level `ORGANIZATIONS_RUNTIME_PKG` + in `doctor.ts`, and a new sibling test pins it on two legs: the **rendered** + bullet compared whitespace-included against text built from that declaration + (the #12463 shape — asserting what the operator sees, never that a constant + appears in source), and a **roster** leg asserting that declaration IS a key of + the spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES`. The roster leg is the load-bearing + one: without it the hint and its expectation move together under a rename and + nothing goes red. + + **This deliberately does not single-source the spelling, and the constant's + docblock says so at the site.** The literal is still declared three times (the + roster key, `Serve.ORGANIZATIONS_RUNTIME_PKG`, and now this const). The roster + cannot supply the name — it is keyed BY package name, its row type carries no + `package` field by design, and its own header records that it is "not a + resolution registry" — and importing `serve`'s export into a diagnostic command + would be a worse coupling than the duplication it removes. What changes is that + this copy can no longer drift in silence. The duplication ends properly when a + shared tenancy-hint table lands (tracked at #12492); the docblock carries that + deletion condition. + + **No behaviour change.** The declared value is byte-identical to the literal it + replaces and the rendered bullet is unchanged. +- 50fb191: `os generate` writes every scaffold as `NAME.TYPE.ts`, with the infix read from + the metadata type registry instead of the harness + + `os g object customer` wrote `src/objects/customer.ts`. The metadata loader + discovers files by globbing each type's own `filePatterns` from + `DEFAULT_METADATA_TYPE_REGISTRY` — `MetadataPlugin._loadFromFileSystem` does + this for **every** registered type, and it is the default `eager` bootstrap + whenever no compiled artifact is configured. Measured across the seven + generators: the bare `NAME.ts` the harness wrote matched **zero** of those + patterns for all seven, while `NAME.TYPE.ts` matched exactly one, every time. + A scaffold that matches nothing still type-checks, still passes `os validate` + and still publishes, and is then never loaded, with no diagnostic at any step + — the silent-strip shape ADR-0063's retirement of `os g agent` closed. + + The previous release closed this for `skill` alone, through an optional + per-generator filename override, on the belief that the other six types were + not filesystem-discovered. The loader says otherwise, so the override is + retired rather than copied five more times: the filename is now derived from + the type's declared pattern, which is also why it reads the pattern rather than + interpolating the type key — `email_template` declares `*.email-template.ts` + and `external_catalog` declares `*.external-catalog.ts`, so an interpolated + infix would re-create the same invisibility for the next generator added. + + Now written: `customer.object.ts`, `customer.view.ts`, `approve.action.ts`, + `customer.flow.ts`, `sales.dashboard.ts`, `crm.app.ts`, + `lead_qualification.skill.ts`. The barrel `index.ts` line is derived from the + filename that was actually written, so it names the real module. + + **Existing files are not renamed and keep working.** A `NAME.ts` file already + on disk matched no `filePatterns` entry before this change either, so nothing + it relies on moves: if your app loads it through the barrel `index.ts` the + generator wrote, that import is untouched. Rename it to its type's pattern if + you also want the loader's own glob to find it. +- 68c5dba: fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624) + + `os lint` computes i18n coverage and runs the authoring-rule registry in a + single pass over the same stack, and for the `flows` translation group the two + halves pointed opposite ways: + + | the author does | which rule fires | what it says | + |---|---|---| + | omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X | + | adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it | + + Measured on one stack, one run: omitting produced **4** `i18n/missing-flow` + findings and 0 liveness findings; authoring produced 0 demands and **2** + `liveness-planned-property` findings ("sets `flows` but this translation + property is planned"). There is no per-rule suppression in `os lint`, only + `--skip-i18n`, which silences the entire `i18n/missing-*` family — so the + author's only escape cost them every other coverage signal. Under + `--i18n-strict` the demand side is an **error**, so a project could be forced + to author keys it is then warned for. + + ⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner + reads the group, so a translated wizard string is stored and never shown — the + failure mode `validationMessages` was removed in 17.0.0 for. The premature half + is the demand. + + **The fix.** `collectExpectedEntries` — the single definition of what is + translatable at all, shared by the coverage gate and the `os i18n extract` + skeleton — now leaves out any translation group the liveness ledger warns + authors for authoring. It reads that set from `@objectstack/lint`'s new + `authorWarnedProperties(type)`, which returns the very warn-map + `lintLivenessProperties` iterates, so the demand side and the warn side cannot + drift into disagreeing about the same keys again. + + Two properties fall out of reading the ledger rather than switching on `flows` + by name: the bucket **turns itself back on** the day an objectui screen-flow + runner lands and the row flips to `live` (no flag, no follow-up edit), and any + future group that acquires an `authorWarn` is covered on the day it is marked + rather than re-opening this collision one group at a time. Today `flows` is the + only such group — pinned as an equality so a second one goes red instead of + shipping. + + No other bucket changes: `objects`, `apps`, `pages`, `dashboards`, + `globalActions` and `metadataForms` are all `live` and are reported exactly as + before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` + + `authorWarn: true`. +- 134a506: fix(cli): `os init` scaffolds its starter object as `_item.object.ts`, the spelling the registry declares (#11598) + + `objectstack init` wrote its starter object to `src/objects/_item.ts` + while `DEFAULT_METADATA_TYPE_REGISTRY` declares the `object` type as + `**/*.object.ts` / `.yml` / `.json`. Measured with `node:path`'s `matchesGlob` + against the registry read at runtime: `src/objects/my_app_item.ts` matched + **zero** of the three patterns, `src/objects/my_app_item.object.ts` matches + exactly one. Both `srcFiles` tables (the `app` and `plugin` templates) and the + barrel specifier they emit now carry the type infix. + + **This was a naming inconsistency, not breakage — measured, not assumed.** A + scaffolded project declares its objects in code (`import * as objects from + './src/objects'` → `objects: Object.values(objects)`), so the object reaches the + stack through the barrel's *module specifier*, and `os dev` / `os serve` then + boot from the compiled `dist/objectstack.json` rather than by globbing source. + Three real scaffolds were compiled with the real `os compile` to establish it: + the old-spelled file **did** land in the artifact (so nothing was ever silently + skipped — this is not the #10359 silent-strip shape), a `*.object.ts` file + dropped into `src/objects/` but *not* re-exported from the barrel did **not** + land in it (so the registry glob was never on this load path), and the new + spelling lands identically. + + What it *was*: one CLI teaching two spellings for one metadata type. After + #11071 an author who runs `os init` and then `os g object customer` gets + `src/objects/my_app_item.ts` beside `src/objects/customer.object.ts` in the same + directory, from the same CLI. The registry spelling is the authority — the same + convergence #11071 settled — and it is already what `create-objectstack`'s blank + starter ships (`note.object.ts`), what the examples use + (`app-crm/src/objects/account.object.ts`), and what the getting-started docs + list as the house convention two lines under the callout that described the old + name. + + **Existing scaffolded projects need to do nothing.** The old filename still + loads exactly as it did — the barrel imports it by specifier and the filename is + not consulted. Renaming `src/objects/_item.ts` to + `src/objects/_item.object.ts` (and the matching `from './_item'` → + `from './_item.object'` in `src/objects/index.ts`) is an optional + consistency cleanup, not a migration: it changes nothing about how the project + builds, boots or behaves. Only newly scaffolded projects get the new name. +- 93949f1: docs(cli): `invocation.ts`'s `isProcessEntry` doc now names its two siblings + + The three-copy `argv[1]`-vs-`import.meta.url` predicate (`isProcessEntry` here, + `isEntrypoint`/`invokedAs` in this repo's `scripts/invoked-as.mjs`, and + objectui's own `scripts/invoked-as.mjs`) carried the "change one, change the + others" sync obligation in only one of the three copies — objectui's. Neither + objectstack copy pointed at the other two, so an agent editing either file + here had no way to discover that a third copy exists elsewhere (#12013). + + Both objectstack copies were otherwise correct and are not changed in + substance; only a reciprocal pointer is added to each, comment-only. +- a2ec377: Stop naming a package nobody can install: `@objectstack/framework` is not a real + package, and the multi-org remedy now says it is not publicly obtainable + + Four `@objectstack/` names appear in this repo's published docs and runtime text + without this repo building any of them. Measured against the public npm registry + (unauthenticated `GET https://registry.npmjs.org/@objectstack%2F`, with + `@objectstack/spec` and `@objectstack/cli` as positive controls so a 404 is a + fact about the name and not about access), they are **not one population but + three**: + + - `@objectstack/framework` — **404, and fabricated.** Unlike the others, nothing + in this tree describes it as enterprise, cloud, or private; it is presented as + the *default public* install. There is no umbrella package and there never was. + - `@objectstack/security-enterprise` (404) and `@objectstack/organizations` + (404) — **real, and deliberately not public.** This tree calls them + "closed-source" and "cloud-private" in a dozen places, and + `PLATFORM_CAPABILITY_PROVIDERS` declares `security-enterprise` with + `edition: 'enterprise'`. Their 404 is the caveat npm's API carries for any + private package, not evidence of fabrication. + - `@objectstack/service-tenant` — **published, at 4.1.0**, exactly as + `platform-object-names.ts` describes it. Untouched. + + What changes: + + - **`@objectstack/console`'s README** no longer opens with + `pnpm add @objectstack/framework`. The mechanism it described is real, just + misnamed: `@objectstack/cli` declares `@objectstack/console` as a dependency + and both ship at one version from the Changesets `fixed` group, so any app + that installs the CLI — every `npx create-objectstack` scaffold does — already + gets a version-matched Console. The instruction is corrected rather than + deleted, so the reader is left with something they can run. + - **`serve`'s multi-org fail-fast** kept telling an operator to add + `@objectstack/organizations` to their app without saying the runtime ships + only with an enterprise/cloud subscription. That is the un-followable "add it + to your dependencies" that framework#3366 exists to make legible. The remedy + now states it, so an operator without a licence can see that the two bullets + below it are their actual path. The declared-but-unresolvable branch is + unchanged — that operator does have the package. + + No behaviour changes: boot outcomes, exit codes and the posture wall are + untouched, and `@objectstack/security-enterprise`'s install hint is deliberately + left alone — it already names its edition boundary, and the test pinning it is + strengthened to assert that it keeps doing so. +- 9bed0b0: fix(cli): the startup banner names a contested flow name and says which definition is armed (#12028) + + `os dev` / `os start` print an automation summary that reads binding STATE off the + live engine, because a flow that failed to arm emits no log line to go looking for + and the boot-quiet stdout window swallows the engine's own `warn` narration. That + summary was silent about the one failure it could not express as a count. + + The engine's flow map is keyed by BARE name. When a packaged flow and a + runtime-authored `sys_metadata` overlay both claim one name, ADR-0005 precedence + arms one and the loser is not in the map — so it is not in `listFlows()`, not in + `getFlowRuntimeStates()`'s rows, and therefore not in any number the banner + prints. `3 flow(s), 3 bound to triggers` was a true sentence about a set that did + not contain the definition the operator had just edited, and nothing on the banner + said otherwise. #11997 gave the engine the receipt (`getShadowedFlows()`, plus + `armedFrom` / `shadowed` on each runtime-state row) and the automation plugin + warns from it at `kernel:bootstrapped` — but that is a `logger.warn`, which is + exactly the channel this banner exists to work around. + + `collectAutomationSummary` now reads that receipt through a probe feature-detected + exactly like the `getTriggerBindingAudit` one beside it, and the banner prints one + line per contested name carrying all three facts an operator needs: + + ``` + ⚠ flow 'send-welcome' is claimed by 2 definitions — a runtime-authored row + (sys_metadata) is ARMED, 1 shadowed (ADR-0005 overlay precedence; only the + armed definition dispatches) + ``` + + Naming which body is armed is the point: a line reporting only the count tells an + admin something is wrong and withholds the answer they are standing there to get. + + Silent on every healthy boot — no contested name, no line. This banner is read on + every start, and a warning that also fires when nothing is wrong is one readers + learn to skip. Both directions are pinned on what the banner RENDERS, absence + included. +- f86839d: refactor(cli): `serve` resolves `@objectstack/organizations` through one declaration the spec roster pins (#11614) + + `PLATFORM_PLUGIN_WIRED_RUNTIMES` (`packages/spec`) is the provenance roster for + `plugins[]`-wired out-of-repo runtimes, keyed by npm package name — the single + machine-readable answer to "is this `@objectstack/*` package real, and where + does it ship from?". It exists because a **fabricated** `@objectstack/framework` + sat next to the real `@objectstack/organizations` in published docs for months, + indistinguishable by inspection (#10921). + + `serve` is the only runtime that prints one of those names AT OPERATORS — the + install remedy, the fatal refusal when a walled tenancy posture cannot load the + multi-org runtime, and the degraded-boot warning. It spelled the package as a + bare literal at the resolution site, under no pin at all, so the roster and the + name `serve` actually resolves could diverge in silence: rename the roster key, + or mistype the literal, and every gate stays green while boot reaches for a + package that does not exist and the fatal message tells the operator to install + it. + + The name is now declared once, as `Serve.ORGANIZATIONS_RUNTIME_PKG`, and both + resolution-path uses read it — `importFromHost(…)` and the `readHostDeclaration(…)` + that decides which of the two absence remedies to print. A drift pin in + `serve-capability-vocabulary.test.ts` (the suite that already holds the two + rosters to each other) asserts that value is a roster **key** and that its row is + the `enterprise` edition. + + **Load semantics are untouched.** Which postures load the runtime, the two-stage + import/mount failure classification, and what `OS_ALLOW_DEGRADED_TENANCY` does + and does not cover are all exactly as they were; the roster is deliberately not a + resolution registry and is not consulted at boot. This single-sources the + spelling and nothing else, so there is no behaviour or output change — the + declared value is byte-identical to the literal it replaces. +- 27b6902: chore(cli): spell the multi-org runtime from its one declaration in `serve`'s operator-facing prose (#12151) + + `serve` prints `@objectstack/organizations` at operators in five places: the + two-branch install remedy, the ADR-0093 D5 fatal refusal when a walled tenancy + posture cannot load the runtime, the degraded-boot warning, the stage-2 mount + refusal, and the `isolated` posture description in the tenancy-posture fix list. + #11614 single-sourced the name `serve` RESOLVES onto + `Serve.ORGANIZATIONS_RUNTIME_PKG` and pinned that declaration against the + spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster, but the sentences kept their + own copies — so a roster-key rename would leave operator instructions naming a + package that no longer exists while boot reached for the new one, with every gate + green (the roster pin only sees the declaration). + + All five now interpolate the constant, and a new test asserts what they RENDER + rather than that the constant appears in the source — the affected line compared + whitespace-included against text built from the same declaration. The message + bodies moved into pure formatters (`formatOrganizationsInstallRemedy`, + `formatOrganizationsAbsentFatal`, `formatDegradedTenancyWarning`, + `formatOrganizationsMountFatal`), the seam shape `resolveTenancyPostureOrRefusal` + in the same file already uses, so the rendering is reachable without spawning a + boot; `chalk` and the `process.exit` stay at the call site. + + **No behaviour change.** The declared value is byte-identical to the literal it + replaces, and the rendered output was verified byte-for-byte against the + pre-change expressions across every branch (both remedy kinds, both walled + postures, `mountCode` present and absent). The three comments in `serve.ts` that + legitimately name the package are untouched — the tempting "no bare literal + outside the declaration" source scan is deliberately not built, since it would + have to exclude comments and that shape is easy to get wrong. +- 1c0b9a5: refactor(cli): single-source the tenancy posture hint table `os serve` and `os doctor` both print (#12492) + + `serve.ts` and `doctor.ts` each declared their own `TENANCY_POSTURE_FIX_HINTS`, + and the two tables were **byte-identical** — sha256 `97497ea8…` on both, modulo + the expression spelling the package name — under no cross-check of any kind. + + The `isolated` entry at least carried a package literal the spec-owned + `PLATFORM_PLUGIN_WIRED_RUNTIMES` roster could be pinned against (#12464 / PR + #12496). **`single` and `group` were the worse half**: they touch no roster, so + nothing anywhere could ever have noticed them drift apart. A reword of one + command's copy left the other describing the same posture differently to the + same operator, with every gate green. + + Both tables now come from one CLI-internal module, + `packages/cli/src/utils/tenancy-posture-hints.ts`, which also holds the single + `ORGANIZATIONS_RUNTIME_PKG` declaration. `Serve.ORGANIZATIONS_RUNTIME_PKG` + becomes a **re-export** of it rather than a second declaration, keeping the + stable handle `serve`'s boot path and the roster pin already address; the + module-local const #12464 added to `doctor.ts` is **deleted**, which is the + deletion condition that const's own docblock recorded against this card. The + literal is now declared twice (the roster key and this module) instead of three + times. + + Each command keeps its **own bullet assembly** — doctor renders + `• OS_TENANCY_POSTURE=

    ` inside a health-check fix list, serve renders + `• set OS_TENANCY_POSTURE=

    ` inside a FATAL refusal, at different + indents. Only the table was ever duplicated. + + The two sibling spelling pins are **retargeted, not dropped** — including the + roster leg, which is the load-bearing one (a rename of the package value leaves + the rendered leg green; only the roster leg catches it). Each gains one new leg + asserting that every posture bullet, `single` and `group` included, renders the + **shared** table's entry verbatim — so a command that re-grows a local copy goes + red instead of drifting in silence. + + **No behaviour change.** The rendered text is byte-identical before and after + for all three postures at both commands, verified by capturing both commands' + full rendered fix lists on `origin/main` and on this branch and diffing them. +- 9981f31: Ship a **declared** `api.projectResolution` from the standalone boot path (#11999) + + `@objectstack/runtime`'s `createStandaloneStack()` / `createDefaultHostConfig()` + returned `api: { enableProjectScoping: false, projectResolution: 'none' }`, and + `os serve` forwarded it unchanged. `'none'` is not a member of the declared enum: + `RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) declares + `z.enum(['required', 'optional', 'auto'])`. Three packages disagreed about this + key's vocabulary, and the disagreement survived because nothing ever executed + the schema — `RestServer` cast its config instead of parsing it. + + `StandaloneStackResult['api']` now declares, and the factory now emits, + `projectResolution: 'auto'`. + + **Behaviour on the routing path is unchanged, and that is measured, not assumed.** + Every reader that acts on this key is gated on `enableProjectScoping` first: + `RestServer.registerRoutes` takes its `else` arm, `mountAndRecordDirectRoutes` + mounts `[versionedBase]`, and the Dispatcher plugin's two + `enableProjectScoping && … === 'required'` guards short-circuit. With scoping off + the strategy really is moot for routing — which is why this migrates the value + rather than teaching the enum a fourth member. + + **One reader is not gated, and that is the user-visible fix.** `RestServer`'s + discovery handler copies `api.projectResolution` into + `discovery.scoping.resolution` unconditionally, and `DiscoverySchema` declares + that field as the same three-member enum. So `GET /api/v1` on every `os serve` + boot advertised a payload the platform's own schema rejects. Clients that + validate discovery — or switch on `scoping.resolution` — now receive a declared + value. + + Both halves are pinned rather than described: `merge-boot-config.test.ts` parses + the CLI's real boot block against `RestApiConfigSchema` and against the discovery + field's enum, and `standalone-stack.test.ts` parses the block the factory + actually returns. Each pin asserts the refusal of `'none'` alongside the + acceptance of `'auto'`, so it can be seen to say no. The CLI constant is now + typed as `StandaloneStackResult['api']`, so it can no longer drift from the + producer without failing `tsc`. +- 96940b7: feat(storage): report-only inventory for stranded `sys_file` orphans, plus `os storage orphans` (#10950) + + The tombstone repairs in #10171 (update verb) and #10240 (delete verb) are forward-only: + they changed what the next write does and touched no row already written. Every + attachments-scope file orphaned before them still sits at `status='committed'` with + `deleted_at` NULL and no `sys_attachment` join row — and `sys_file`'s declared lifecycle + nominates a sweep candidate only via `ttl { field: 'deleted_at' }` or + `retention { onlyWhen: { status: 'pending' } }`, so such a row matches **neither**. It is + never a candidate, the reap guard is never asked about it, and its bytes are never + reclaimed. The leak is permanent rather than late. + + This ships the measurement half only, per the maintainer's ruling on #10950: + + - `inventoryStrandedFileOrphans()` — a read-only reconciliation pass that walks + attachments-scope committed `sys_file` rows and reports how many are stranded, their + byte magnitude, and why each excluded row was excluded. `formatStrandedOrphanInventory()` + renders it; those two plus the inventory's result types are what + `@objectstack/service-storage` publishes — the shared ownership predicate stays internal, + since nothing outside the package pulls on it. + - `os storage orphans` — the operator-invoked surface, with `--json` for a machine-readable + payload. There is no `--apply` and no write path, deliberately. + + **It writes nothing, tombstones nothing and deletes nothing.** Authorising the destructive + backfill is a separate decision that these numbers exist to inform; a tombstone written + here would start a 30-day clock ending in an irreversible byte delete. + + The ownership question is not reimplemented. `createSysFileReapGuard`'s "is anything still + holding this file?" test — zero `sys_attachment` join rows **and** empty `ref_*` ownership + columns — is extracted as `findFileHolder()` and called by both the guard and the + inventory, so "the same question, never a weaker one" is a property of the code rather + than a claim in a comment. A file with zero join rows that is `ref_*`-owned (ADR-0104 / + #3459) is a live file and is excluded from the count. + + Behaviour of the reap guard is unchanged — the extraction is a pure refactor, and the + guard's existing pins cover it. Both counts are labelled `attachments` scope: files in the + other scopes are governed by the field-reference seam and are reconciled by + `verifyFileReferences`, which skips attachments-scope files, so the two passes partition + the population rather than overlapping. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [99ccbb9] +- Updated dependencies [74cee59] +- Updated dependencies [6747718] +- Updated dependencies [399ecad] +- Updated dependencies [9bc403e] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [983edf1] +- Updated dependencies [5a916c4] +- Updated dependencies [f93df4d] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [178f90c] +- Updated dependencies [56d3c7a] +- Updated dependencies [84de7e3] +- Updated dependencies [7181101] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [887b97d] +- Updated dependencies [7899f57] +- Updated dependencies [6274a1a] +- Updated dependencies [1fa05a6] +- Updated dependencies [22c42c9] +- Updated dependencies [5cb62d8] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [daacc10] +- Updated dependencies [cebef55] +- Updated dependencies [6171331] +- Updated dependencies [c6c7fec] +- Updated dependencies [0010797] +- Updated dependencies [a58eac3] +- Updated dependencies [3d79144] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [d2b2381] +- Updated dependencies [f9ffd01] +- Updated dependencies [376c70f] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [09ae32e] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [9d3c04d] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [12e306a] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [0d7b1f3] +- Updated dependencies [8155855] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [78f65ef] +- Updated dependencies [107bb4b] +- Updated dependencies [1524927] +- Updated dependencies [f4e7ae5] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [68c5dba] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [bbf1167] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [3dafd8c] +- Updated dependencies [66bbb4c] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [365e334] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [20a452e] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e170b0a] +- Updated dependencies [e5ce2ed] +- Updated dependencies [9e72090] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [4ceae8a] +- Updated dependencies [48318f7] +- Updated dependencies [b6d9432] +- Updated dependencies [3b5f036] +- Updated dependencies [311433f] +- Updated dependencies [ece4dad] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [e577445] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [017130a] +- Updated dependencies [466b389] +- Updated dependencies [c5b9ccc] +- Updated dependencies [c68c670] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [a2ec377] +- Updated dependencies [d9cf78e] +- Updated dependencies [1a47a53] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [8649b39] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [ba8420b] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [599515d] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [fe72aa5] +- Updated dependencies [a1c804b] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [6e33394] +- Updated dependencies [911da5f] +- Updated dependencies [7986d97] +- Updated dependencies [1c66fe4] +- Updated dependencies [033a34c] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [4d98d9e] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [18b53ac] +- Updated dependencies [e1d773e] +- Updated dependencies [9a884c6] +- Updated dependencies [1cba33f] +- Updated dependencies [da1126a] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [fc8627e] +- Updated dependencies [050d8d8] +- Updated dependencies [1272f0a] +- Updated dependencies [18b53ac] +- Updated dependencies [3194c91] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [598b7ec] +- Updated dependencies [ffbb7a1] +- Updated dependencies [9981f31] +- Updated dependencies [a41069b] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [91b1342] +- Updated dependencies [88b9d74] +- Updated dependencies [96940b7] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [f213793] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/service-settings@17.3.0 + - @objectstack/service-analytics@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/plugin-approvals@17.3.0 + - @objectstack/verify@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-protocol@17.3.0 + - @objectstack/client@17.3.0 + - @objectstack/console@17.3.0 + - create-objectstack@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/service-datasource@17.3.0 + - @objectstack/lint@17.3.0 + - @objectstack/cloud-connection@17.3.0 + - @objectstack/service-storage@17.3.0 + - @objectstack/service-automation@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/plugin-webhooks@17.3.0 + - @objectstack/plugin-sharing@17.3.0 + - @objectstack/mcp@17.3.0 + - @objectstack/service-messaging@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-audit@17.3.0 + - @objectstack/plugin-reports@17.3.0 + - @objectstack/plugin-email@17.3.0 + - @objectstack/account@17.3.0 + - @objectstack/setup@17.3.0 + - @objectstack/driver-memory@17.3.0 + - @objectstack/driver-mongodb@17.3.0 + - @objectstack/driver-sqlite-wasm@17.3.0 + - @objectstack/driver-turso@17.3.0 + - @objectstack/formula@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/plugin-hono-server@17.3.0 + - @objectstack/service-cache@17.3.0 + - @objectstack/service-job@17.3.0 + - @objectstack/service-package@17.3.0 + - @objectstack/service-queue@17.3.0 + - @objectstack/service-realtime@17.3.0 + - @objectstack/service-sms@17.3.0 + - @objectstack/trigger-api@17.3.0 + - @objectstack/trigger-record-change@17.3.0 + - @objectstack/trigger-schedule@17.3.0 + - @objectstack/plugin-pinyin-search@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 8d0d0d2345..87b48fe337 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.2.0", + "version": "17.3.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 7199a731e9..e9fa0e622b 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,156 @@ # @objectstack/client-react +## 17.3.0 + +### Patch Changes + +- 1fa05a6: fix(client-react): correct two broken TSDoc `@example` blocks the SDK docs shipped verbatim (#10969) + + TSDoc `@example` blocks are preserved into the published `dist/*.d.ts` (confirmed by + building and reading the emitted declarations), so they reach consumers directly in their + editor's hover tooltip — copying one is the intended usage. Two were actively wrong: + + - `useAutoRefresh`'s example read `data.map(...)`. `data` is a `PaginatedResult` (or + `null`), which has no `.map` — copied verbatim, this throws once the query resolves. + Fixed to `data?.records.map(...)`. + - `useMetadata`'s example called `client.meta.getObject(...)`, a method that does not + exist on the client (only `getItem`/`getItems`/`getView` do). Fixed to the real + `client.meta.getItem('object', ...)`, matching `useObject`'s own implementation. + + While in there: every other `@example` on this surface (18 more, across + `client-react`'s `data-hooks.tsx`/`metadata-hooks.tsx`/`realtime-hooks.tsx`/`context.tsx` + and `client`'s `index.ts`) is now genuinely self-contained and copy-paste-able — each + previously omitted the `import` for the hook or type it demonstrated, and three + `realtime-hooks.tsx` examples wrote a literal `useQuery(...)` (three dots) as a prose + placeholder, a syntax error once copied. + + **`@objectstack/spec`: dev tooling only, nothing published changes.** The gate that now + type-checks the surface above (`check:skill-examples`, `packages/spec/scripts/`) lives in + this package but is not part of it — `scripts/` is outside `@objectstack/spec`'s publish + `files` allowlist (confirmed via `check:published-files`), so no consumer-visible surface + moves. Named here only because the fixed-version group requires every package with a + source diff to be covered by a changeset; the actual version bump is a byproduct of the + group moving together, not a claim that spec shipped something new. + + No exported type, function signature, or runtime behaviour changed on any of the three — patch. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [887b97d] +- Updated dependencies [7899f57] +- Updated dependencies [6274a1a] +- Updated dependencies [1fa05a6] +- Updated dependencies [22c42c9] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/client@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index b10d938ef6..af773183e3 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.2.0", + "version": "17.3.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 a4eff4d484..e8b7ad13a4 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,435 @@ # @objectstack/client +## 17.3.0 + +### Minor Changes + +- d3bee87: **SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method. + + `AuthPlugin` has mounted this route on the raw Hono app for as long as the SSO-onboarding flow has existed, but `packages/client/src` built the URL nowhere — measured zero for both `setInitialPassword` and `set-initial-password`, against four sibling auth members returning non-zero on the same corpus, so the absence was an absence and not a broken search. Its only caller was `@object-ui/auth`'s `createAuthClient`, whose three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are all expressed on `ObjectStackClient`, and whose sibling branch in the very same Console password card — `changePassword` — has been ledgered `sdk` throughout. + + The method is shaped exactly like its namespace siblings (`this.getRoute('auth')` + `this.fetch`, `POST` with a JSON body, returning the parsed envelope), because the difference between it and `changePassword` is a **server-side** one and belongs there: better-auth registers `setPassword` with no HTTP path of its own (server-only `auth.api.setPassword`), so ObjectStack wraps it in an authenticated mount that requires a session and refuses with 409 `PASSWORD_ALREADY_SET` when a credential already exists. Callers that already have a password use `changePassword`, which verifies the current one. + + **Nothing about the route's behaviour moves.** Its accept/reject logic, its admit set and its server-side guards are untouched — this is a client binding to an existing mount, not a widening of what the mount allows. + + **Its `AUTH_ROUTE_LEDGER` row lands with it**, because the two halves are one statement and neither is true alone. `plugin-auth` gains `{ route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }` — the ninth mount of the #10534 census, whose disposition was escalated rather than guessed and which the maintainer ruled `sdk` (option C, 2026-08-22) and then ruled should land in one PR (2026-08-23). Without the row, the method's URL matched only the dispatcher's `* /auth/**` prefix family, and `client-url-conformance.test.ts` bounds wildcard-only matches at zero on purpose; with it, the same URL resolves to an enumerated route. The row also brings the `check:auth-mount-ledger` pending-disposition entry down — the exemption that carried this route while the question was open is deleted, which is that ratchet working rather than being relaxed. +- 887b97d: `meta.saveItem` accepts the query-string options bag its route already reads — + `force`, `packageId`, `mode` — on both clients + + The Phase 3a-destructive gate refuses a metadata save with + `409 DESTRUCTIVE_CHANGE` and ends the message `— re-submit with ?force=true to + proceed.` Both REST `PUT` doors read `?force` off the query string and thread + it, so that sentence is true of an HTTP caller. It was **false of a + first-party SDK caller**: `meta.saveItem(type, name, item)` built a bare path + and a body and sent no query string at all, on either declaration. A caller + who did literally what the refusal prescribed got the identical refusal back, + and the only way to act on it was to abandon `@objectstack/client` for raw + `fetch`. + + Three parameters are newly reachable, and they are exactly the three + `PUT /api/v1/meta/:type/:name` reads: + + - **`force?: boolean`** — `?force=true`, the destructive-change opt-in the 409 + message names. Only the opt-IN is spelled on the wire: `false` and + `undefined` both omit the parameter rather than sending `?force=false`. + That is a hazard avoided, not tidiness — the door refuses a *repeated* + `force` because a repeated value arrives as an array and a non-empty array + is truthy, so an opt-OUT that reached the wire twice would switch the guard + ON. + - **`packageId?: string`** — `?package=`, binding the saved row to a + software package (`sys_metadata.package_id`). Named `packageId` to match the + sibling `getItem` / `getItems` options on the same object. + - **`mode?: 'draft' | 'publish'`** — `?mode=draft`, staging the write as a + pending draft. `'publish'` is the default said out loud and deliberately + sends nothing, since the door acts on `mode=draft` alone. + + **Backward compatible.** The bag is optional and an options-less call builds a + byte-identical URL to before — `''`, not a trailing `?`. Existing + three-argument callers are unaffected, and pins measure that rather than + assuming it. + + Both declarations move together — the unscoped `ObjectStackClient.meta` and + the environment-scoped `ScopedProjectClient.meta` — sharing ONE exported + `SaveMetaItemOptions` type and ONE query builder rather than a literal copied + into each. They are the same method on two clients reaching one pair of routes + (the scoped mount is the same route registration replayed under + `/environments/:environmentId`, so it reads the same three parameters), and + every divergence measured between these twins so far has been closed as a + defect. A bag spelled twice is the next one waiting to be introduced. + + The branch was selected by measurement, not preference: the SDK is the real + metadata-write path for both surfaces the ruling named. The CLI's `os meta + register` goes through `client.meta.saveItem` and the CLI has no raw-HTTP + metadata-save path at all; Studio reaches it from 21 production call sites + across `@object-ui/app-shell`, `plugin-designer`, `data-objectstack` and the + console app — including the object and field designers, where dropping a field + and saving is precisely what raises the destructive 409. +- 7899f57: Bind the SDK's erased return types to the `@objectstack/spec` contracts the package already depends on + + **This is a NARROWING of published return types.** 41 methods that resolved to `any` (or to an + envelope carrying an `any[]`) now resolve to the contract type the route actually answers, and 12 + fixed-shape `automation.*` methods gain a constrained generic in place of ``. Nothing + changes at runtime — no request, response, unwrapping or error path is touched — but code that + compiles today against these methods can stop compiling. `any` is assignable to everything and + admits every property read, so the previous declaration accepted assignments, property reads and + parameter forwarding that a precise type refuses. + + What a consumer could stop compiling against, per family: + + - **`automation`** — `get`/`getFlow` are `FlowParsed`; `runs.get`/`getRun` are `ExecutionLog`; + `runs.list`/`listRuns` are `{ runs: ExecutionLog[]; hasMore: boolean }`; `execute` and `resume` + are `AutomationResult`; `getScreen` is `{ runId: string; screen: ScreenSpec }`; `listActions` and + `listConnectors` carry `ActionDescriptor[]` / `ConnectorDescriptor[]` instead of `any[]`. ⚠️ The + biggest practical break is `AutomationResult.screen`, `.runId`, `.status` and `.summary` being + **optional**: a completed run carries no screen, so `result.screen.nodeId` must become + `result.screen?.nodeId`. The six flat aliases and their `ScopedProjectClient` mirrors move + together. ⚠️ `` became `` on those twelve: an explicit type argument + still works when it narrows the platform shape (`getFlow`), + but one naming an unrelated type is now refused — including where TypeScript used to infer it + from the assignment's own annotation. + - **`approvals`** — `recall` / `revise` / `resubmit` are `ApprovalRecallResult` / + `ApprovalSendBackResult` / `ApprovalResubmitResult`; `remind` is + `{ request: ApprovalRequestRow; notified: number }`; `requestInfo` and `comment` are + `{ request: ApprovalRequestRow }`. These join `reassign` and `listActions`, which were already + typed this way beside them. + - **`shares` / `shareLinks`** — `shares.list` is `RecordShare[]`, `shares.grant` is `RecordShare`; + `shares.rules.list` / `save` / `get` are `SharingRuleRow`(`[]`) and `rules.evaluate` is + `SharingRuleEvaluationResult`; `shareLinks.create` / `list` are `ShareLink`(`[]`). + - **`reports`** — `list` / `save` / `get` are `SavedReport`(`[]`), `run` is `ReportRunResult`, + `schedule` / `listSchedules` are `ReportSchedule`(`[]`). + - **`security`** — `describeDelegableScope` is `DelegableScope`; `explain` is `ExplainDecision` (the + `z.input` form `ISecurityService.explain` declares and the route relays verbatim — **not** the + post-parse `ExplainDecisionParsed`, since no parse runs on that path); the three + `suggestedBindings` methods carry their `{ suggestion, … }` / `{ suggestions, synced }` envelopes. + The suggestion ROW stays `Record` by contract, but `bindingCreated` and + `synced.{created,confirmedObserved,pruned}` stop being erased. + - **`email` / `datasources.external`** — `email.send` is `SendEmailResult` (branch on `status`). + ⚠️ The four federation methods are **envelope-wrapped** and the obvious binding is the wrong one: + `listTables` answers `{ tables: RemoteTable[] }`, not `RemoteTable[]`; likewise + `{ draft: ObjectDraft }`, `{ object: ImportObjectResult }`, `{ catalog: ExternalCatalog }`. + `validate` is a bare `SchemaValidationReport`. + - **`ScopedProjectClient.packages.list`** — `{ packages: InstalledPackage[]; total: number }`. + + Four methods deliberately keep `Promise` and say so in their docblocks: `automation.create` / + `automation.update` echo an unvalidated request body, and `search` / `data.clone` answer shapes + declared inline in the implementation rather than in `@objectstack/spec`. Those are missing + *contracts*, not missing annotations, and authoring them belongs to the spec package. The + caller-supplied generics on `data.*` and `actions.*` are unchanged — there the payload really is + the caller's. +- 6274a1a: feat(client): `meta.saveItem` can send the `If-Match` OCC header it already told callers to send (#11713) + + `saveItem`'s own docstring has always named the ADR-0008 optimistic-concurrency + protocol: the resolved `version` is the token, echo it back as the `If-Match` + request header on the next write to the same item, and a concurrent edit is + reported as `409 METADATA_CONFLICT` instead of silently overwriting. Both REST + `PUT` doors read `if-match` and thread it as `parentVersion`, so that sentence + was true of a raw-HTTP caller. It was **false** of a first-party SDK caller: + neither `saveItem` declaration accepted a header, an `ifMatch`, or anything + that became one — so an SDK caller who did exactly what the docstring said had + nowhere to put the token and their concurrent edit overwrote anyway, answered + `200`. Declared, not enforced, with no signal at the call site. + + **What is new:** `ifMatch?: string` joins the `SaveMetaItemOptions` bag that + `#11391` landed, on **both** `saveItem` declarations — the unscoped + `ObjectStackClient.meta` and the environment-scoped + `ScopedProjectClient.meta` — wired to the `If-Match` request header through a + single shared builder, the same way the three query parameters go through one + shared query builder. The twins cannot drift. + + ```ts + const saved = await client.meta.saveItem('object', 'customer', doc); + // …later, guarded against a concurrent edit: + await client.meta.saveItem('object', 'customer', next, { ifMatch: saved.version }); + // a stale token now answers 409 METADATA_CONFLICT instead of overwriting + ``` + + Purely additive and opt-in. Only a non-empty token reaches the wire: + `undefined` and `''` both omit the header entirely — the `init` handed to + `fetch` carries no `headers` key at all — so every existing call is + byte-identical and last-write-wins remains the default, on the wire and on the + door. Unlike the bag's `mode`, `ifMatch` reaches **both** save doors: the + compound-name twin `PUT /meta/:type/:section/:name` reads `if-match` and strips + ETag-style quotes exactly as the single-segment door does. + + Aligned deliberately with the other first-party client: the same member name, + the same header, and the same truthy guard as `MetadataClient.save` in + `@object-ui/data-objectstack` — two first-party clients, one behaviour. +- 22c42c9: fix(client): bind the three verifiable methods of the unannotated return-type erasure population to their spec contracts (#11925) + + **Return-type narrowing on a published SDK.** No runtime change — the value each + method resolves to is byte-identical before and after. Only the DECLARED type + moved, off `any`, which is precisely why a runtime test cannot observe it and + the pins for it are type-level. + + `any` is assignable to everything and admits every property read, so for each + method below a consumer's code could stop compiling where it previously did + not: assigning the result to an unrelated annotation, reading a property the + bound type does not declare, or forwarding the value to a differently-typed + parameter. + + ## What changed, per family + + **`client.packages.list` → `{ packages: InstalledPackage[]; total: number }`** + (was `{ packages: any[]; total: number }`). A consumer stops compiling if it + reads any key off a row that `InstalledPackage` does not declare. Note in + particular `source` (`'database' | 'registry' | 'both'`): the REST surface + spreads it onto each row, the dispatcher surface does not, and it is therefore + deliberately NOT declared — code reading `pkg.source` off this result compiles + today and will not after. `total` and the array envelope are unchanged. + + **`client.packages.update` → `InstalledPackage`** (was `any`). This method + declared no envelope before, so nothing about the shape claim changed; a + consumer stops compiling if it reads an undeclared key off the returned row, or + assigns the result somewhere `InstalledPackage` does not fit. + + **`ScopedProjectClient.packages.get` → `{ package: InstalledPackage }`** (was + `{ package: any }`). The `{ package }` envelope is unchanged; only the member + narrowed. A consumer stops compiling if it reads a key off `.package` that + `InstalledPackage` does not declare — again including `source`, which this + route does send and which stays undeclared for consistency with its already + bound `list` sibling. + + ## What deliberately did NOT change + + The other 36 methods in the measured population keep their erased `any`, each + with a docblock stating why and pointing at the issue that carries it: + `meta.*` history/diagnostics (9) and eight `packages.*` routes have no published + response contract to bind to (#12038); `client.packages.get` has two mounted + surfaces that emit different envelopes and `install`/`enable`/`disable` declare + an envelope no surface emits (#12034); the 15 cloud `projects.*` methods call a + control plane that speaks snake_case while the `@objectstack/spec/cloud` rows are + camelCase, so binding to them would compile and be false (#12036). + + No consumer loses anything by those staying `any` — they are exactly as + permissive as before. +- daae7aa: Declare the `search` and `data.clone` route response contracts, and bind the SDK to them (#11924) + + Two of the four SDK routes #8140 had to leave as deliberate `Promise` holes now have real + contracts. Their shapes were always stable and server-produced — they were declared inline on the + implementation (`@objectstack/metadata-protocol`'s `searchAll` / `cloneData`), reachable from no + spec export — and per the maintainer ruling on #11924 they are now declared in `@objectstack/spec` + exactly as produced, with conformance coverage on both the producer and the mounted route (#3877: + no route-ledger `responseSchema` row is filled without conformance coverage; both rows are filled + as part of this change). + + **`@objectstack/spec` (additive):** + + - `SearchAllResponseSchema` / `SearchAllHitSchema` (+ `SearchAllResponse` / `SearchAllHit` types, + `@objectstack/spec/api`) — the WHOLE body of `GET /api/v1/search`, answered bare: + `{ query, hits, totalObjects, totalHits, truncated }` with hits of + `{ object, id, title, snippet?, record }`. ⚠️ Deliberately distinct from `SearchResult` / + `SearchHit` in `@objectstack/spec/contracts`, which type the per-object `ISearchService.search` + (hits of `score` / `document`) — reaching for that same-named neighbour was the near-miss trap + #8140 left a compile-time guard against, and the guard stands unchanged. + - `CloneDataResponseSchema` (+ `CloneDataResponse`, `@objectstack/spec/api`) — the whole 201 body + of `POST /data/:object/:id/clone`: `{ object, id, sourceId, record }`, `CreateDataResponse`'s + structural sibling plus `sourceId` (`id` is the NEW record's, `sourceId` the copied record's). + No `droppedFields` member — unlike `createData`, the clone producer emits none. + + **`@objectstack/client` (return-type narrowing, same nature as the #8140 batch):** `search` is + now `Promise` and `data.clone` is `clone(…): Promise>` + (a new exported interface mirroring `CloneDataResponseSchema`, beside `CreateDataResult`). Nothing + changes at runtime — no request, response, unwrapping or error path is touched — but code that + compiled against the previous `any` returns (arbitrary property reads, assignments to unrelated + types) can stop compiling; in particular a result assigned to the per-object `SearchResult` is now + refused at compile time, which is the trap the erasure used to hide. + + The `automation.create` / `automation.update` pair is explicitly NOT declared here — it returns to + the decision inbox with a consumer-survey reading per the same ruling. +- 7986d97: Retire compound-name metadata addressing (`/meta/:type/:section/:name`) + + Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. + Stage 1 declared the item-name grammar and refuses every slash-bearing name at + the publish door, so the routes removed here addressed only names that can no + longer be created. + + **BREAKING — three public REST routes stop answering:** + + | stops answering | use instead | + | :-- | :-- | + | `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | + | `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | + | `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | + + Each retired route folded its `:section` and `:name` segments back into one + slash-bearing key (`views/all_leads`) that the protocol layer then treated as a + single opaque string — the section half was never stored, filtered or + enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. + + The `@objectstack/runtime` dispatcher stops folding in the same way: its + `/meta` handler requires exactly two path segments for an item and three for + `…/published`, instead of re-joining every trailing segment. A `/meta` path + that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than + falling through to the adapter's anonymous 404. + + **FROM → TO for callers.** Address every item through the single-segment route + and percent-encode the name: + + ``` + GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads + ``` + + `@objectstack/client` now calls `encodeURIComponent` on every `/meta` item + address, so SDK callers need no change: the SDK already sends the new spelling. + Encoding is a **no-op** for every name the item-name grammar admits (lowercase + snake_case segments, optionally dot-qualified), so the bytes on the wire are + unchanged for every name that can be written today. + + A pre-grammar **residue** row whose stored name contains a slash remains + readable, writable and deletable: `%2F` matches the single-segment pattern and + the parameter is decoded back to the stored spelling before the handler runs. + Nothing that could be stored has become unaddressable. + + Two SDK doc comments that promised "compound names pass through unencoded" + (`meta.getPublished`, `meta.publishItem`) are corrected, and the + `SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored + at the compound door and published live — is closed at the source: there is one + door, and it reads every member of the options bag. + + + +### Patch Changes + +- 1fa05a6: fix(client-react): correct two broken TSDoc `@example` blocks the SDK docs shipped verbatim (#10969) + + TSDoc `@example` blocks are preserved into the published `dist/*.d.ts` (confirmed by + building and reading the emitted declarations), so they reach consumers directly in their + editor's hover tooltip — copying one is the intended usage. Two were actively wrong: + + - `useAutoRefresh`'s example read `data.map(...)`. `data` is a `PaginatedResult` (or + `null`), which has no `.map` — copied verbatim, this throws once the query resolves. + Fixed to `data?.records.map(...)`. + - `useMetadata`'s example called `client.meta.getObject(...)`, a method that does not + exist on the client (only `getItem`/`getItems`/`getView` do). Fixed to the real + `client.meta.getItem('object', ...)`, matching `useObject`'s own implementation. + + While in there: every other `@example` on this surface (18 more, across + `client-react`'s `data-hooks.tsx`/`metadata-hooks.tsx`/`realtime-hooks.tsx`/`context.tsx` + and `client`'s `index.ts`) is now genuinely self-contained and copy-paste-able — each + previously omitted the `import` for the hook or type it demonstrated, and three + `realtime-hooks.tsx` examples wrote a literal `useQuery(...)` (three dots) as a prose + placeholder, a syntax error once copied. + + **`@objectstack/spec`: dev tooling only, nothing published changes.** The gate that now + type-checks the surface above (`check:skill-examples`, `packages/spec/scripts/`) lives in + this package but is not part of it — `scripts/` is outside `@objectstack/spec`'s publish + `files` allowlist (confirmed via `check:published-files`), so no consumer-visible surface + moves. Named here only because the fixed-version group requires every package with a + source diff to be covered by a changeset; the actual version bump is a byproduct of the + group moving together, not a claim that spec shipped something new. + + No exported type, function signature, or runtime behaviour changed on any of the three — patch. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 86b6f93a93..e3136086d3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.2.0", + "version": "17.3.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 b91759172a..a523db2324 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,161 @@ # @objectstack/cloud-connection +## 17.3.0 + +### Minor Changes + +- 4d98d9e: **Security (p0, upstream half):** `GET /api/v1/runtime/config` now carries a `telemetry` block, giving the Console SPA a **post-build off switch** for client error reporting (#10805, upstream half of `cloud#1508`). + + An air-gapped on-premises EE Console was measured sending **14 Sentry envelopes per session** to `sentry.io`, carrying IP and User-Agent PII, with no way for the customer to turn it off. objectui closed the half it owns — a build that never opts in now issues no third-party request at all — and could not close the other: every telemetry knob there is a Vite build-time variable inlined into the bundle as a frozen literal, so a build that **did** opt in (the hosted console, and the identical artifact shipped on-prem) had no switch that editing env vars on the deployed host could reach. The only server-to-SPA channel is this endpoint. + + ```json + { "telemetry": { "allowClientErrorReporting": false } } + ``` + + Operators grant it with `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true`, or hosts with `new RuntimeConfigPlugin({ allowClientErrorReporting: true })`. The switch answers to the repo's usual truthy vocabulary (`1` / `true` / `on` / `yes`); an unrecognised spelling is refused and named at mount time rather than coerced. + + **Denied by default on every posture, not only the air-gapped one.** Deriving "connected therefore allowed" would have left the reported injury class open one deployment over: an internet-connected on-prem box runs the *same build artifact* as the hosted console, so the DSN cannot tell them apart, and its customer has equally never heard of Sentry. A universal opt-in satisfies "air-gap defaults off" strictly, and satisfies it without having to identify the posture correctly — which matters, because a posture predicate that is wrong in the *allow* direction is this card's own defect. + + **A permission, never a source.** The server supplies no DSN and cannot start telemetry for a build that carries none; `true` means only "this deployment does not object to the sink you were compiled with". The composed decision stays `Boolean(buildTimeDsn) && isClientErrorReportingAllowed(payload)`. + + **A runtime that declared its control plane off cannot grant it.** `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the grant and says so in the boot log — the copied-hosted-config-onto-an-air-gapped-box shape. That declaration is the repo's one existing network-posture signal and needs no new knob: the EE image's compose file already defaults `OS_CLOUD_URL` to `off`, so the operator this failed is safe with zero configuration. + + **Absence is denial, and the reading ships with the contract.** A new export, `isClientErrorReportingAllowed(payload)`, is the canonical fail-closed reader: an older runtime's payload, a malformed body, a 404 and a failed fetch (pass `undefined`) all answer `false`. It is exported rather than left to consumers because "absent means do not send" is a claim about *their* code, and a hand-written `?.` chain is one `!== false` away from re-opening the leak on exactly the legacy payloads the guarantee is for. The key is spelled as a permission for the same reason: a negative `disabled` flag would have read falsy — therefore "send" — on every one of those states. + + `isControlPlaneDeclined()` is factored out of `cloud-url.ts` so "what counts as off" has one definition shared by the URL resolution and the telemetry refusal. No behaviour change to `resolveCloudUrl()`. + + The consumer half (reading the key and gating `initSentry`) is objectui's and is filed separately. + +### Patch Changes + +- 12e306a: The canonical-expression-envelope detector for raw-literal `Page` exports gets a shared home in `@objectstack/lint` (#11480). New public API beside `walkPageComponents`: `auditPageExpressionEnvelopes(page, label)` runs the three parse doors (`PageSchema` / `PageComponentSchema` / `ComponentPropsMap`) over one authored page and reports bare-expression findings plus every door's precondition failures; `renderBareExpressionFindings(findings)` renders the actionable red; types `BareExpressionFinding`, `PageEnvelopeAudit`, `EnvelopeAuditDoor`. The detector previously lived package-local to `@objectstack/platform-objects`' gate, which could not reach raw-literal pages shipped by other packages. `@objectstack/cloud-connection`'s two shipped pages are now covered by the same gate, and `MarketplaceInstalledPage` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery sees it. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c68c670] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [9981f31] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index f7cceac7ec..48355b6325 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.2.0", + "version": "17.3.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 (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 aebd0a14aa..987d794281 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/connector-mcp +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index e5a3337bca..0aa6fada47 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.2.0", + "version": "17.3.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 101185464f..a87b4607f0 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/connector-openapi +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 1a6ad04609..2fb927e1a7 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.2.0", + "version": "17.3.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 64c592d12d..3504dad087 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/connector-rest +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 4c5153f3f6..579e448dfc 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.2.0", + "version": "17.3.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 e24c980b48..8adb1ced91 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/connector-slack +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 909f052546..476fa3b7eb 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.2.0", + "version": "17.3.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 1bc3434c55..ce5a25bb41 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,110 @@ # @objectstack/console +## 17.3.0 + +### Patch Changes + +- daacc10: npm `description` no longer names the fabricated `@objectstack/framework` + + `@objectstack/console`'s manifest `description` is what npm renders on the + package page, so it was the most-read of the three sites that named a package + nobody can install. The earlier pass corrected the other two — this package's + README and `packages/cli/src/utils/console.ts` — but left the manifest alone + because a published manifest was outside that change's declared file surface. + This is the third and last live site. + + The correction is the same one the README took: the mechanism is real and only + the name was wrong. There is no umbrella `@objectstack/framework` package (404 + on the public registry, and fabricated — nothing in this tree presents it as + enterprise or private, it is presented as the *default public* install). What + actually pins the two together is that `@objectstack/cli` declares + `@objectstack/console` as a dependency and both ship at one version from the + Changesets `fixed` group. + + ```diff + -Prebuilt Console SPA pinned to this @objectstack/framework release. Source of truth: … + +Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: … + ``` + + "framework release" survives as the common noun the README already uses; what + is dropped is the `@objectstack/` scope that turned it into a package name. The + `@object-ui/console` source-of-truth half is unchanged — it was accurate. + + Text only: no code, no exports, no behaviour change. It carries a changeset + rather than the publish-nothing exemption because the manifest `description` + ships inside the npm tarball and is a published artifact. +- cebef55: Correct the published `@objectstack/console` README's account of how the CLI + resolves the Console SPA (#11262). `README.md` is in this package's `files` + array, so it is the page npm renders. + + The README claimed `resolveConsolePath()` "prefers `@objectstack/console` and + falls back to `@object-ui/console` when present — so cloud's Docker overlay + (which `cp -r`s its build over `node_modules/@object-ui/console`) keeps + working." Both halves are false against + `packages/cli/src/utils/console.ts`: + + - There is no `node_modules` fallback to the `@object-ui/console` npm + package. Strategy 1 (`require.resolve('@objectstack/console/package.json')` + from the app and from the CLI itself) and strategy 2 (direct + `/node_modules/@objectstack/console`) each require the resolved + `package.json` to be named `@objectstack/console`. The legacy package is + never consulted; the function's own header says so. + - `@object-ui/console` survives in exactly one branch — strategy 3, the + sibling-repo dev fallback, which matches the `name` field of a checked-out + `../objectui/apps/console` **source** tree. That is a source-checkout + probe, not a package resolution, and the corrected text keeps the two + apart. + - The trailing consequence was the load-bearing half: cloud and objectos + Docker images overlay into `@objectstack/console`'s `dist/`, so the README + was telling an operator their overlay works by a mechanism that no longer + exists. Replaced with the real overlay target. + + Documentation only — no runtime, type or export change. The removal of the + npm-package fallback was deliberate and is not restored here. +- a2ec377: Stop naming a package nobody can install: `@objectstack/framework` is not a real + package, and the multi-org remedy now says it is not publicly obtainable + + Four `@objectstack/` names appear in this repo's published docs and runtime text + without this repo building any of them. Measured against the public npm registry + (unauthenticated `GET https://registry.npmjs.org/@objectstack%2F`, with + `@objectstack/spec` and `@objectstack/cli` as positive controls so a 404 is a + fact about the name and not about access), they are **not one population but + three**: + + - `@objectstack/framework` — **404, and fabricated.** Unlike the others, nothing + in this tree describes it as enterprise, cloud, or private; it is presented as + the *default public* install. There is no umbrella package and there never was. + - `@objectstack/security-enterprise` (404) and `@objectstack/organizations` + (404) — **real, and deliberately not public.** This tree calls them + "closed-source" and "cloud-private" in a dozen places, and + `PLATFORM_CAPABILITY_PROVIDERS` declares `security-enterprise` with + `edition: 'enterprise'`. Their 404 is the caveat npm's API carries for any + private package, not evidence of fabrication. + - `@objectstack/service-tenant` — **published, at 4.1.0**, exactly as + `platform-object-names.ts` describes it. Untouched. + + What changes: + + - **`@objectstack/console`'s README** no longer opens with + `pnpm add @objectstack/framework`. The mechanism it described is real, just + misnamed: `@objectstack/cli` declares `@objectstack/console` as a dependency + and both ship at one version from the Changesets `fixed` group, so any app + that installs the CLI — every `npx create-objectstack` scaffold does — already + gets a version-matched Console. The instruction is corrected rather than + deleted, so the reader is left with something they can run. + - **`serve`'s multi-org fail-fast** kept telling an operator to add + `@objectstack/organizations` to their app without saying the runtime ships + only with an enterprise/cloud subscription. That is the un-followable "add it + to your dependencies" that framework#3366 exists to make legible. The remedy + now states it, so an operator without a licence can see that the two bullets + below it are their actual path. The declared-but-unresolvable branch is + unchanged — that operator does have the package. + + No behaviour changes: boot outcomes, exit codes and the posture wall are + untouched, and `@objectstack/security-enterprise`'s install hint is deliberately + left alone — it already names its edition boundary, and the test pinning it is + strengthened to assert that it keeps doing so. + ## 17.2.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index b2e521d650..f0d7d6a926 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.2.0", + "version": "17.3.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 430bc639f5..47a4cb468b 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,587 @@ # @objectstack/core +## 17.3.0 + +### Minor Changes + +- 4635f3e: fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049) + + + + **BREAKING** accept-set narrowing + export removal, landing after the v17.0.0 + cut (the lockstep launch-window convention ships it as `minor`; the + prescription is registered under protocol major 18 — + `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry + `hot-reload-inert-state-strategies-retired` — where `os migrate meta` users + will look). + + This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825 + ruling deliberately kept. That ruling retired the authorable lifecycle-config + container and kept `HotReloadConfigSchema` as a host-driven library parameter + type; this change measures the kept vocabulary's own remainder and finds the + same defect in it. The keep itself stands — `HotReloadConfigSchema`, + `PluginStateSnapshotSchema` and the health vocabularies still export, and + `HotReloadManager` / `PluginHealthMonitor` are untouched. + + The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both + wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said + "memory fallback" — and announced the substitution at DEBUG level only. A host + that asked for durable or cluster-replicated state got process-local memory + and no error: state that does not survive the restart it was configured to + survive. `distributedConfig` had ZERO readers anywhere, so an author could + name a Redis endpoint, a TTL and a replication factor and nothing ever opened + a connection. + + FROM → TO: + + - `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime + behaviour, because `'disk'` already stored to memory. It is the spelling + that was false, not the behaviour. + - `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or + `'none'` to disable state preservation outright. + - `distributedConfig: { … }` → *(removed)* — delete the key. It left with the + `'distributed'` value its own doc comment called it "required" for. + - `DistributedStateConfigSchema` / `DistributedStateConfig` / + `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of + that one key. + + One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete + any `distributedConfig` — you were already getting in-memory state. There is + no in-tree replacement for durable or distributed plugin state; persist it in + the host, which owns the process lifetime these strategies pretended to + outlive. Real disk or distributed persistence returns only via the ENFORCE + route of ADR-0049 — the implementation first, the declaration with it. + + The retirement kit: + + - **enum-value narrowing** (`['memory','disk','distributed','none']` → + `['memory','none']`): invisible to all four ratchets by construction (the + def still emits), so the prescription hangs on the enum's own `error` map + dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'` + precedent. A value that was never legal still gets zod's own enum message, + so a typo is not told it "was removed". + - **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable + surface: no metadata-type binding, stack collection or manifest embed ever + carried it, and nothing in the tree parses `HotReloadConfigSchema` outside + its own unit test, so there is no authored document to rewrite and nobody + who could receive a parse-time tombstone): `kernel/DistributedStateConfig` + in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as + a def removal must — `api-surface` −3, `authorable-surface` −8, + `json-schema.manifest` −1. + - **runtime doors** in `@objectstack/core`, because route 3 leaves no + parse-time prescription: `HotReloadManager.registerPlugin` now refuses an + unhonoured `stateStrategy` and a leftover `distributedConfig` with an + ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the + prescription. Refused BEFORE the `enabled` check, so a disabled config + cannot smuggle the false declaration through. TypeScript hosts never reach + it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a + compile error at the call site. + - **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the + #11825 survivor list, so this reverses one line of that ruling on new + evidence — #11825 measured the container's six groups, never this key's own + readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts` + moves in the same commit with the reasoning recorded beside it, and asserts + the surrounding keep is intact. + - zero in-tree consumers passed `'disk'` or `'distributed'` (measured at + cdbd9204b6 with a firing positive control; every live caller passes + `'memory'` or `'none'`), so no in-repo source changes ride along. +- ee3595c: fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry + `hot-reload-watch-placeholder-retired` — where `os migrate meta` users will + look). Graded `minor` rather than `major` for the same reason #12340 was one + day earlier, in this same module. + + ADR-0049 applied one symbol over from #12340, in the same file and on the same + per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and + `PluginStateSnapshotSchema` still export, and `HotReloadManager` / + `PluginHealthMonitor` are untouched apart from the two doors below. + + `HotReloadManager.startWatching` contained **no watcher**. Its whole body was a + guard plus `logger.info('File watching started', { patterns })`, above an + in-source note saying real watching "would require chokidar or similar". Where + #12340's inert fallback at least announced itself at DEBUG, this claimed + success at **INFO**: an operator who set `enabled: true` with `watchPatterns` + and read that line had been told the opposite of the truth. `watchHandles` was + only ever read, deleted, iterated and cleared and **never set**, so + `stopWatching`'s cleanup branch and the teardown loop over its keys were + structurally unreachable rather than merely untaken. `watchPatterns` therefore + had no reader that acted on it — its only two uses were log lines. + + FROM → TO: + + - `watchPatterns: ['src/**/*.ts']` → *(removed)* — delete the key. Declare your + globs wherever your own watcher reads them. + - `manager.startWatching(name)` → `manager.scheduleReload(name, reloadFn)`, + called from your own watcher's change handler. That is the debounced + integration point this class does implement, and it is unchanged. + + One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own + file watcher instead of `startWatching` — nothing was ever watched, so nothing + that used to happen stops happening. File watching is the host's job in this + host-driven library; `chokidar` is already a dependency of + `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — + never of `@objectstack/core` — so a host has a working model to copy. + + The retirement kit: + + - **key tombstone**, and the build is what chose it: the plain deletion was + tried first and `gen:schema` gate (a) refused it, because + `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a + silent strip (#3733, ADR-0104) — the very defect being retired, one layer + down. #12340 could take route 3 because what left there was a whole *def*; a + key leaving a *surviving* def has no such exit. So `watchPatterns` is + `retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and + `kernel/HotReloadConfig:watchPatterns` is registered by exact key in + `RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves + `authorable-surface` only — the def still emits, so `api-surface` and + `json-schema.manifest` do not. + - **no D2 conversion**, deliberately: the chain walks a normalized stack, and + `HotReloadConfig` is not an authorable surface — no metadata-type binding, + stack collection or manifest embed ever carried it — so a conversion would be + a transform with no seam that ever runs. For the same reason the prescription + carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling + in this module does not. + - **runtime doors** in `@objectstack/core`, because nothing in the tree parses + `HotReloadConfigSchema` outside its own unit test, so the tombstone alone + reaches nobody: `startWatching` now throws an ADR-0112 envelope + (`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and + `registerPlugin` refuses a leftover `watchPatterns` the same way — before the + `enabled` check, so a disabled config cannot smuggle the false declaration + through. `startWatching` is kept as a throwing door rather than deleted so + that caller meets a prescription instead of a bare `TypeError`. + - **dead code removed with a firing positive control**: `watchHandles` and both + of its unreachable readers are gone. The zero was pinned first — + `reloadTimers.set` resolves a real writer in the same file and the same scan, + while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the + half that always did something (it cancels a pending debounced reload), and + `shutdown` is unchanged in effect: the loop it lost iterated `watchHandles` + and therefore ran zero times. + - **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a + removal: no runtime composes `HotReloadManager`, so enforcing would build for + a caller that does not exist; and a scan of every planning doc returned zero + mentions of hot-reload file watching against 145 control hits in the same + files, so there is no roadmap for `experimental` to point at. +- af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + + Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling + (2026-08-26, verbatim and untranslated: 「同意」). + + ## The registration follows the declaration (#12359) + + `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but + the only thing that REGISTERED it was the automation service's manifest — + because flows were the ledger's first and, until packaged actions landed, only + consumer. Packaged actions are a second consumer with a different owner: their + consult and write path live on the ObjectQL engine, present in every + composition that can execute an action. + + So a deployment with actions and no automation service had no ledger table, and + the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — + correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be + reported as one) and permanently. Measured on a real boot; it is now this + change's positive test, measured on the same boot: + + ``` + POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED + ``` + + `PlatformObjectsPlugin` registers it now, so every composition carrying + platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, + `skill`, `position`) inherits it. **MOVE, not add** — the automation service no + longer names the object. That was not a style choice: a second code package + claiming one object throws `Object "…" is already owned by package "…"` + (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot + failure rather than a duplicate. + + **Upgrade is a no-op for existing data, and that is measured rather than + asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` + step 4 routes an object by its owning package's `defaultDatasource` — so the + registrar carries the table's datasource with it: + + ``` + owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' + owner com.objectstack.platform-objects (none) -> undefined (global default driver) + ``` + + The ledger table already exists in live databases, so on any deployment + carrying a `cloud` datasource that difference would leave the rows in one + database and read another — every disabled artifact silently re-arming. The + ledger therefore rides its own manifest from the same plugin, carrying the + automation manifest's `scope` / `namespace` / `defaultDatasource` triple + verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, + `sys_secret`) deliberately do not get it and keep riding the project database. + + ## One implementation of the §4 row contract (#12350) + + ADR-0126 §4 declares one activation ledger; it had two independent + implementations of that one row contract — `ObjectStoreFlowActivationStore` + (service-automation) and `ObjectStoreActionActivationStore` (objectql). They + agreed because the second was written from the first, and nothing structurally + held them together; §8 pre-charts `tool`, `skill` and `position`, and a third + and fourth copy is where the org-row skip and the `0`-is-false read get lost + quietly, in the direction (an artifact re-arming) nothing else measures. + + Neither consumer could import the other, so the contract now lives once in + `@objectstack/core` — the package both already depend on — as + `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside + `InMemoryMetadataActivationStore`, `MetadataActivationRow`, + `MetadataActivationStore`, `MetadataActivationStoreEngine` and + `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own + one-argument constructor and its own docs, and fixes the discriminator. + + **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / + `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and + `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / + `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` + / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same + shapes. Row semantics are byte-equivalent: install-level rows only + (`organization_id` never written), org-carrying rows skipped on read and + ignored when deciding insert-vs-update, a driver `0` read as false, + read-then-write rather than a blind upsert, and no `delete` in the engine slice + because re-enabling rewrites the row. + + Both existing pin suites stay green **unchanged**, which is what makes them the + proof the consolidation lost nothing — verified by ablation: removing the + org-row skip from the one shared implementation turns both of them red on their + own org-skip assertion, so both really reach it. +- 7131f12: **Security:** the "is this user id a platform admin?" question is now asked in exactly one place, and the two copies that answered it differently are gone (#10348, #10949). + + ADR-0068 D2 defines platform standing as one thing — an unscoped `admin_full_access` grant, held now. `core/security/resolve-authz-context.ts` is the declared authority for authorization derivation and its header states that every entry point must resolve through it and never re-read the grant tables itself. `plugin-auth`'s `auth-manager.ts` did exactly that twice: once inside the `customSession` callback, and once in the predicate that authorizes `/sso/register` and, through the impersonation oracle, `/admin/impersonate-user`. Both copies are deleted. Both callers — and the session payload — now ask `hasPlatformAdminStanding(engine, userId)`, a projection of `resolveUserAuthzGrants` exported from `@objectstack/core`, so a platform-admin verdict is derived in one place for the whole platform. + + **What that changes, and it is a tightening on all three counts.** The deleted copies applied neither the ADR-0091 validity window nor the ADR-0049 `active` check, and resolved `admin_full_access` by matching a name over a page of the permission-set catalogue. The authority applies both checks before any derivation and resolves the set by id. So: + + - an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload; + - a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too; + - an environment holding **more permission sets than a single catalogue page** can no longer lose the `admin_full_access` row and demote every platform admin at once. + + **One behaviour widens, and it was ruled deliberately** (maintainer, 2026-08-24). The `customSession` copy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin — the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected: the two reads reached identical rows there already. + + **The org boundary is unchanged and now pinned at both gates.** An org owner, an org admin, a `TENANT_ADMIN`-posture principal and an org-scoped `admin_full_access` grant are all refused — the `PLATFORM_ADMIN` rung derives from the unscoped capability grant alone. The predicate takes an engine and a user id and nothing else: it deliberately does not accept the resolver's caller-supplied seeds, so no part of a request can supply part of its own verdict. + + Population queries are a different kind and are untouched: `ensure-default-organization.ts` asks *which* user is the platform admin, which a per-user predicate cannot express. +- b72db01: fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescriptions are registered + under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 + semantic entry `plugin-auto-restart-never-reinitialised` — where + `os migrate meta` users will look). Graded `minor` rather than `major` for the + same reason #12340 and #12428 were, the day before, in this same module. + + ## What was measured + + `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped + there. The comment above the call read *"Call destroy and init to restart"*, + and `init` appeared in `health-monitor.ts` **only inside that comment**. So a + plugin whose health checks crossed `failureThreshold` with `autoRestart: true` + got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`, + and periodic health checks that carried on running against the destroyed + instance. The default check when no `checkMethod` resolves is + `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes + indefinitely — so the **terminal** report on a torn-down, never-re-initialised + plugin was `healthy`. + + Reproduced at `ee3595cefd` before anything was changed, with + `successThreshold: 3`: + + ``` + round 1 (failing): status=failed destroyed=0 alive=true + after backoff: status=recovering destroyed=1 alive=false + recovery round 1: status=recovering destroyed=1 alive=false + recovery round 2: status=recovering destroyed=1 alive=false + recovery round 3: status=healthy destroyed=1 alive=false + ``` + + #11955 made that report *more* convincing rather than less: reaching `healthy` + now costs `successThreshold` consecutive passing rounds, so a destroyed plugin + has to earn a declared number of passes before it is misreported. + `restartAttempts` was incremented as though a restart had occurred, and + `maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin + that was never brought back up. + + ## Why REMOVE and not the other two ADR-0049 states + + **ENFORCE** would have to build the restart, and the class cannot host one. + `Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call + sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`, + `kernel.ts:607`), both over the full plugin list, with a context that is + `private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain + one, so a host-provided re-init hook would have had nothing to call. (Positive + control for that scan: the same pass resolves five real non-test + `plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a + per-plugin re-init API for a caller that does not exist — no runtime constructs + `PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision + names as the wrong default at this milestone, where the shippable liability is + the false promise and not the missing feature. + + **EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR + corpus returned **zero** mentions of plugin auto-restart, against 118 control + hits for "health" and 13 for "hot reload" in the same corpus. + + `maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than + as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and + *"Backoff strategy for restart delays"* have nothing left to be the vocabulary + **of** — the test that took `distributedConfig` out with the `stateStrategy` + value it was documented as requiring (#12340). + + ## What changes for a host + + All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not + `.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a + milder form of the defect being retired. A TypeScript host gets a `tsc` error + (the keys are typed `never`); a parse raises the prescription; and + `PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of + them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown + before any state is stored so a refused config leaves no half-registered plugin + behind. + + `PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that + crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and + left running; acting on that is the host's job in this host-driven library + (#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` + and restart at the level that owns the plugin's lifetime. + + Everything else in the monitor is unchanged: registration, periodic checks, the + `timeout` race and its refd-timer guard (#4875), both failure routes sharing the + counters (#11852), and `successThreshold` binding from every status that records + a failure (#11955). `recovering` is now written only by the success branch — + the one writer that ever meant it. + +### Patch Changes + +- 983edf1: fix(core): `autoRestart` now fires for a health check that throws or times out, not only for one that returns a failure (#11852) + + `PluginHealthMonitor.performHealthCheck` reaches its failure handling by two + disjoint routes, and only one of them could ever restart the plugin. + + A check that **returned** a failure (`false` or `{ status: 'unhealthy' }`) + incremented `failureCounters`, cleared `successCounters`, and — once + `failureThreshold` consecutive failures accumulated — consulted `autoRestart` + and restarted the plugin. A check that **threw** took a separate `catch` block + that incremented `failureCounters` and stopped there: it never cleared + `successCounters` and never read `autoRestart`. Because `raceCheckTimeout` + rejects rather than resolving, every `timeout` overrun lands in that `catch`, + so a plugin that hung was marked `failed` and never restarted no matter how + many rounds passed or what `autoRestart` said. The severer of the two failure + modes was the one that could not trigger recovery. + + Both routes now funnel into one `recordFailedRound` step that owns the + counters, the `failureThreshold` comparison and the `autoRestart` decision, so + a thrown or timed-out check is restart-eligible on exactly the same terms as a + returned failure. + + The per-route *status* label is deliberately unchanged: a throw is still the + separate `failed` status applied immediately with no threshold, as + `content/docs/protocol/kernel/lifecycle.mdx` documents. Only the counters and + the restart decision are shared — those are what `failureThreshold` and + `autoRestart` declare, and neither names a route. +- f658793: Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须 + schema-free」) for `@objectstack/core`'s plural→singular store-key fold. + + `@objectstack/spec`: the `defineStack()` manifest-collection vocabulary + (`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`, + `singularToPlural`) moved to a schema-free module and is now ALSO exported + from the sanctioned schema-free entry `@objectstack/spec/meta-spelling` + (widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared` + keeps the same four symbols as re-exports — no consumer-visible removal. The + manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts + (#8424). + + `@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves + from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so + browser consumers of `@objectstack/core` (every `@objectstack/client` bundle) + no longer link the zod schema closure through the store-key fold. +- a17da05: fix(core): only a backend fault populates `resolveLocalizationContext`'s failure memo (#11877) + + `resolveLocalizationContext` memoizes an outcome for 30s whenever the read + "failed" (#10221 — so a repeatedly-failing `sys_setting` query does not re-run, + and the driver does not re-log it, on every request). The write condition was + wider than the cache's own docblock: six legs set the flag and only **one** of + them is the backend fault the docblock describes (the direct `ql.find` throw). + The other five are the **settings service refusing** — a thrown `getMany`, each + of the three older per-key `get`s, and the whole-block "service unavailable" + handler. + + Those five legs are reachable inside the settings engine's **bind window** + (`SettingsService.getMany` refuses all-or-nothing for a `localization` + namespace whose manifest is not yet registered), so: + + - A caller that deliberately re-reads **after** the bind — the #11580 stdio + repair re-resolves at `kernel:bootstrapped` for exactly this reason — was + answered from the memo taken **inside** the window for up to 30s. The + correction silently did not happen, with nothing in the output saying so. + - A settings refusal standing alongside a perfectly **successful** direct read + memoized that successful value — the staleness the docblock forbids outright + and that `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to catch. + + The memo is now written only for the direct-read fault. **#10221's protection + is unchanged for the legs it was built for**: its environment (table not + migrated yet) still memoizes, because the direct read throws there whether or + not a settings refusal stands in front of it — pinned in both directions. And + nothing is lost on the narrowed legs: those refusals throw out of an in-memory + registry check *before* any query and *before* any log line, so memoizing them + suppressed neither. + + No signature, export or accepted-input change — the flag is internal to the + module. +- f7b25c5: fix(core): `successThreshold` now binds from every status that records a failure, so a declared count above 2 stops being unreachable (#11955) + + `PluginHealthMonitor` consulted `successThreshold` only while a plugin's status + was `unhealthy` or `degraded`. The first success in a recovery wrote + `recovering` — a status that gate did not name — so the **second** success took + the outer `else` and went straight to `healthy` without the counter being read + at all. `failed` was in neither set either, so a plugin whose check threw + recovered on its **first** success. + + The declared value was therefore capped in practice: + + | Status when the successes start | Consecutive successes actually required | + | :--- | :--- | + | `unhealthy` / `degraded` | 2, whatever `successThreshold` said | + | `failed` / `recovering` | 1, whatever `successThreshold` said | + + A declared `successThreshold: 5` was indistinguishable from `2`. The default is + `1`, which is exactly the value at which the defect is invisible — every + declared value above it was the one that misbehaved. + + The counter is now consulted on the way out of every status that records an + observed failure — `degraded`, `unhealthy`, `failed` and `recovering` — so + `successThreshold: N` requires N consecutive successes from each of them, as + its declaration says ("Consecutive successes needed to mark healthy"). The gate + is a map that is exhaustive over `PluginHealthStatus`, so a status added to the + spec fails to compile until it is placed on one side or the other; that is what + `recovering` slipped through before. + + `healthy` and `unknown` still promote on the first success, deliberately: the + count is declared as a **recovery** criterion ("Number of consecutive successes + to recover from unhealthy state") and neither of those records a failure to + recover from — `unknown` is the status `registerPlugin` writes before any check + has run. + + **Behaviour change, only for configs that declare `successThreshold` above 1.** + At the default `1` every route is byte-for-byte what it was: one success has + always been enough and still is. A plugin declaring a higher count now takes + the number of consecutive successes it asked for before it is reported + `healthy`, including after a `failed` round and after an `autoRestart`. + + This also makes #11852's `successCounters` reset load-bearing. That fix cleared + the counter on the thrown failure route, and could not be pinned: the counter's + only read site was unreachable with a stale non-zero value, so any test would + have passed for the wrong reason. With `failed` gated on the counter, a throw + that interrupts a recovery now demonstrably starts the count over. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 40e77c21a7..0e3f4a2a45 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.2.0", + "version": "17.3.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 39605c0caf..c095dffbcf 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,41 @@ # create-objectstack +## 17.3.0 + +### Patch Changes + +- c6c7fec: fix(create-objectstack): stop reporting a failed pnpm probe as a deliberate npm choice (#11616) + + `detectPackageManager()` was `try { execSync('pnpm --version') } catch { return + 'npm' }`, so every failure mode collapsed into one answer. `npm install` in the + scaffolder's output meant either *this machine has no pnpm* or *the probe + threw*, and nothing — no log line, no message — could tell the two apart. + + That second case is reachable on an ordinary developer machine, not just in + theory: `pnpm --version` resolves through Corepack and therefore depends on the + directory it runs in. Measured on one machine, one binary, two directories — + `10.31.0` inside a repo that pins `packageManager`, `10.33.0` outside it, where + Corepack has to resolve, and may have to fetch, a version nothing pinned. A + user who has pnpm installed but is on a slow or offline network was silently + told to run npm. + + The probe now reports why as well as what: + + - `probe: 'ok'` — pnpm answered, so pnpm is used (unchanged, silent). + - `probe: 'absent'` — no pnpm on PATH at all, so npm is a real choice + (unchanged, silent). + - `probe: 'failed'` — pnpm **is** on PATH and the probe still threw. npm is + used exactly as before, and the run now says so, naming the underlying + failure: `pnpm is installed but \`pnpm --version\` failed (); using + npm as a fallback.` + + **Which package manager a run uses is unchanged in all three cases** — it is + still pnpm if and only if the probe succeeded. The PATH lookup that separates + `absent` from `failed` runs only after the decision is already made and feeds + the message alone, so a miss there can change a warning's wording and never the + tool's behaviour. The only output that moves is one warning in a case that was + previously silent and wrong. + ## 17.2.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 5d1d60eca2..bb6cdb946e 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.2.0", + "version": "17.3.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 202a3653bc..3b320530bc 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,125 @@ # @objectstack/driver-memory +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 88d91bbaf0..d514e66946 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.2.0", + "version": "17.3.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 1049e71da8..3809437bc9 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,125 @@ # @objectstack/driver-mongodb +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index 7c6bc7c37b..8f0e375d18 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.2.0", + "version": "17.3.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 e627f35b9c..595d86aa72 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,996 @@ # @objectstack/driver-sql +## 17.3.0 + +### Minor Changes + +- 0010797: Cross-schema foreign keys are now qualified instead of shipping an unusable bare name (#11377). + + `IntrospectedForeignKey` (driver-sql) gains an optional `referencedSchema`, present when — and + only when — the referenced parent table lives outside the introspecting session's resolution + scope (Postgres: the parent's schema is not on `current_schemas(false)`; MySQL: the parent's + database differs from `DATABASE()`; SQLite never sets it — no schemas, and a foreign key cannot + cross an ATTACHed database). `referencedTable` stays a bare name always — the qualification is a + separate key, never a conditional spelling. + + `convertIntrospectedSchemaToObjects` (objectql) reads the new key: a foreign key whose target + carries `referencedSchema` is loudly skipped and flagged through the new `options.logger` + (default `console`) instead of being wired to the bare name — which either resolved to nothing + or to a same-named table in the current schema, silently. The column is kept as a plain field so + its data stays visible. Foreign keys with in-scope targets keep producing identical lookup + fields. +- 09f9361: Report a multi-value field left on a stale `varchar`/`text` column, instead of + letting it silently corrupt every array written to it + + A field that gains `multiple: true` materialises as a `json` column on a fresh + database, but `initObjects` is additive-only: on a database created while the + field was single-value, nothing is missing, so nothing is added and the old + `varchar`/`text` column is kept forever. The write path stringifies the array + for a json field on every non-SQLite dialect; the read path relies on the + driver's column-type-based decoding, which a stale textual column defeats. The + array goes in as the literal `["id1","id2"]` and comes back as a **string** — + so a hook copying the value into a child record's single-lookup column writes + that whole string as one id. User-filed production report, repaired by hand on a + live database. + + Until now the schema-drift detector said **nothing** about it. Measured on the + pre-fix tree against live Postgres 16.13 and MySQL 8.0.46: after the metadata + change and a reboot, `detectManagedDrift()` returned `[]` and the boot logged + zero `[schema-drift]` lines, while the very next write stored + `["user_A","user_B"]` into a `character varying(255)` column and read it back + with `typeof === 'string'`. The action vocabulary had no "the base type is + wrong" entry at all — only `relax`/`tighten_not_null`, `widen`/`narrow_varchar`, + `drop_column`, `drop_column_default` and the index ops. + + The divergence is now **detected and reported**, naming the table, the column, + the declared type, the physical type and the exact statement an operator runs by + hand — dialect-correct, and executed against both live servers by the suite + rather than merely printed. ObjectStack does **not** change the column: an + `ALTER TABLE … TYPE json USING …` over existing rows with an index drop and + rebuild is a destructive migration over shipped data, and whether the platform + should perform it is a separate, open decision. The new `manual_column_type_change` + op deliberately has no reconciler arm; `applyMigrationEntries` reports it as + skipped, which is the intended contract while that decision is open. + + Reported at severity `error` and category **`needs_confirm`**, and the category + is load-bearing rather than cosmetic. Every database this finding describes is + already serving — that is the premise of the report — and the artifact-pinned + boot gate refuses a boot for `category === 'destructive'` and nothing else + (`severity` it never reads). Measured both ways: a `destructive` entry returns + `ok=false` from that gate, this entry returns `ok=true`. Spelling it + `destructive` would have turned every affected deployment into a crash-loop on + its next restart — the report of the corruption becoming the outage. + + SQLite is deliberately excluded, and the exclusion is a measurement rather than a + scoping convenience: the same stale column reads back as a real `['x','y']` + array there, because SQLite's read path `JSON.parse`s regardless of what the + column calls itself. There is no corruption to report, and reporting it anyway + would put a permanent `error` finding on every long-lived SQLite development + database. A stale `integer`/`timestamp` column is excluded for the mirror-image + reason — the server already refuses that write loudly, so there is no silence to + break. + + Also fixed, same defect class: a multi-value field that *also* declared + `maxLength` used to produce `narrow_varchar` at severity `error`, category + **destructive** on both enforcing dialects — a finding that refuses the + artifact-pinned boot and invites `os migrate apply --allow-destructive` to + rewrite the column to `varchar(50)`, the exact opposite of the repair it needs. + `createColumn` returns at its `multiple` branch before `maxLength` is ever read, + so the emitter never asks for that width; the differ no longer does either. The + single-value width branch is untouched and pinned as untouched. +- d0e3a88: **Fix:** a text-family field that a declared index keys on is emitted as `varchar(maxLength)` instead of an unbounded `TEXT`, so the index MySQL previously refused can actually be created (#11374). + + `createColumn` mapped the whole text family (`text` / `textarea` / `html` / `markdown`) to an unbounded `TEXT`, ignoring the field's own declared `maxLength`. MySQL refuses a `TEXT`/`BLOB` column in a key without a prefix length, and the two halves of schema-sync fail *separately*: the `CREATE TABLE` succeeds, then `ALTER TABLE … ADD [UNIQUE] INDEX` fails with `ER_BLOB_KEY_WITHOUT_LENGTH`. The table therefore lands on disk **without the constraint it declared**, and the object stays registered-but-broken. Measured on a live MySQL 8.0.46: **36 of the 44 platform objects** failed schema-sync this way, so a stack whose `default` datasource is MySQL could not stand up its own schema — the dev-admin seed never landed and first sign-in returned `401 INVALID_EMAIL_OR_PASSWORD`. Honouring the declared bound takes that to **12**. + + **The bound is the field's own `maxLength` — nothing is invented.** `schema-drift.ts` already treated `varchar(field.maxLength)` as the expected physical shape of a bounded field (its `widen_varchar` / `narrow_varchar` ops say so in as many words); this is the emitter finally agreeing with the differ. On MySQL that removes a permanent destructive drift finding: `columnInfo()` reports `maxLength: 65535` for a `TEXT` column, so every bounded text field already reported `narrow_varchar` ("metadata caps at 32 chars but the column allows 65535") against a column the driver itself had created. + + **Scope, both halves load-bearing.** The bound is emitted only for a column some declared index **keys on** — a non-indexed `Field.text({ maxLength: 65000 })` stays `TEXT`, because `varchar(65000)` on utf8mb4 is 260000 bytes and would blow MySQL's 65535-byte row limit, turning a working table into an un-creatable one. And only where the bound is **usable as a key part**: `maxLength` absent, or wider than 768 characters (3072 index bytes ÷ 4 bytes per utf8mb4 character — measured: `varchar(768)` takes a unique index, `varchar(769)` is refused with `ER_TOO_LONG_KEY`), leaves the column `TEXT` and the index refused with a message naming the field and the declaration that fixes it. + + **⚠️ Graded `minor`, not `patch`: this changes declared behaviour on newly created tables.** A keyed bounded text column now enforces its declared length where the dialect enforces `varchar` (Postgres and MySQL), so a write longer than `maxLength` that previously landed in an unbounded `TEXT` is now refused — under `STRICT_TRANS_TABLES`, with `ER_DATA_TOO_LONG`. That is the declaration becoming enforced rather than a new restriction, and it is exactly what makes the column indexable, but it is a behaviour change and is named here as one. **Existing tables are unaffected**: schema-sync is additive and never rewrites a column that is already present. + + **A prefix index is deliberately NOT substituted for an unkeyable column.** For an ordinary index that would be a transparent access-path choice, but for a `UNIQUE` one it silently replaces the declared constraint with a stricter one — uniqueness of the *prefix*. Measured on MySQL 8.0.46 with `UNIQUE KEY (token(191))` and two distinct 200+ character tokens sharing their first 191 characters: the second insert was rejected with `ER_DUP_ENTRY` **even though the tokens differ**. On `sys_session.token` that is a valid sign-in refused as a duplicate. The refusal an operator can read is strictly better than a constraint that quietly means something else. +- 64505a5: fix(driver-sql): stamp `updated_at` at the audit column's own precision on MySQL, so an updated row stops reading as modified BEFORE it was created (#11224) + + `createAuditTimestampColumn` builds the audit columns on MySQL as `DATETIME(3)` + defaulted with `now(3)`, and its docblock says why in as many words + ("`CURRENT_TIMESTAMP` has to carry matching precision for a `DATETIME(3)` + default", #3942). `updatedAtStamp()` — the value every UPDATE door writes into + that same column — was a bare `knex.fn.now()`, which compiles to an unqualified + `CURRENT_TIMESTAMP` that MySQL truncates to whole seconds. So the column was + created at millisecond precision on purpose and then written at second precision. + + Measured on live MySQL 8.0.46, against the exact schema the driver produces: + + ``` + created_at updated_at delta + before CURRENT_TIMESTAMP 2026-08-23 10:22:36.799 2026-08-23 10:22:36.000 -799 ms + after CURRENT_TIMESTAMP(3) 2026-08-23 10:22:36.799 2026-08-23 10:22:36.802 +3 ms + ``` + + Nothing errors. Three silent consequences, in ascending order of damage: + + 1. **"Last modified" precedes "created".** Any consumer comparing the two — an + audit answer, a "modified since creation?" badge, a data-quality check — + reads a row that WAS modified as if it were not. + 2. **A delta / incremental sync SKIPS the row.** A cursor held at millisecond + precision (`updated_at > cursor`) misses every row whose stamp was truncated + back below it. Measured: all six rows in the new suite's §2 were invisible to + their own cursor immediately after being updated. This is the same + silent-wrong-answer family as #11067 / #11176 / #11223, reached by a fourth + mechanism. + 3. **Two updates in the same second are indistinguishable**, so an + `order by updated_at` over them is unstable exactly where it matters most. + + The fix is the expression #11176 had already derived and measured for the UPSERT + door: `now(3)` on MySQL, unchanged elsewhere. Every UPDATE door reads one helper + (`update`, `updateMany`, `rotatedUpdateById`), so all three move together. + + **Postgres and SQLite emit byte-identical SQL to before, and that is a + measurement rather than an assumption.** Postgres' `CURRENT_TIMESTAMP` is + `transaction_timestamp()` at microsecond precision against a `timestamptz` + column; SQLite's stamp is a JS ISO-8601 string that already carries millis. + Neither has anything to truncate. The new suite runs every cell on SQLite AND on + live Postgres AND on live MySQL, and its §5 pins which expression each dialect + gets — so a future "just add `(3)` everywhere" cannot satisfy the ordering + assertions while changing the SQL the other two dialects emit. In the baseline + run against the unfixed driver, the SQLite and Postgres cells were green (7/7 + each) and only the MySQL cell was red (6 of 7). + + **BREAKING**, narrowly, and the reason this is not a patch: the `protected` + `upsertUpdatedAtStamp()` that shipped in 17.2.0 with #11176 is **removed**. It + existed only to hold the precision-matched form for the upsert door without + changing the SQL every `update()` emits — a split that card made deliberately + because it had not measured the UPDATE door. This one measured it, so the pair + collapses back into the single `updatedAtStamp()`, which now carries the matched + precision for both doors. A subclass of `SqlDriver` that OVERRODE + `upsertUpdatedAtStamp()` would otherwise have kept compiling while silently + ceasing to be called, which is precisely the failure mode a release note has to + name out loud. Such a subclass should override `updatedAtStamp()` instead; the + two in-repo subclasses (`SqliteWasmDriver`, `TursoDriver`) override neither and + are unaffected. Nothing else was removed or renamed, no authored metadata + changes, and no public API moves — under this repo's launch-window convention + (breaking changes ship as `minor` while the stack versions in lockstep) `minor` + is the honest slot. + + Stored data is not rewritten. Rows updated before this change keep their + truncated `updated_at`; the ordering invariant holds from the next write onward. + + +- 107bb4b: driver-sql (MySQL): carry an over-long UNIQUE index on a hash-shadow column + + On utf8mb4 InnoDB a key part holds at most 3072 bytes (768 characters), so a + full-value UNIQUE index over a longer column is inexpressible — an OAuth access + token that is a multi-KB JWT cannot be made keyable by any declared bound. + Measured on live MySQL 8.0.46, 7 of 44 exported platform objects failed + `syncSchema` outright and landed registered with their declared uniqueness + absent (Postgres 16.13: 0 of 44). + + Such a UNIQUE index is now carried by a driver-owned `__hash` column — a + `STORED GENERATED` `VARBINARY(32)` holding the full, untruncated SHA-256 of the + key values — with the unique index on that column. Uniqueness is still enforced + over the whole value: distinct values sharing a long prefix are both accepted + (the property that ruled out prefix-unique indexes), NULLs stay distinct, and a + composite tuple containing NULL conflicts with nothing. + + The shadow is created only *after* the server refuses the direct index, so the + dialect divergence is selected by the error code rather than by a dialect check: + Postgres and SQLite are byte-identical to before. Non-unique indexes are + deliberately left refused — an index over a digest accelerates no lookup the + planner can reach. +- 0e5bea6: New operator-run command `os migrate multi-value-columns`: migrates a stale `varchar`/`text` column to `json` where the field declares `multiple: true` — the `manual_column_type_change` drift `os migrate apply` reports and deliberately never reconciles for you (#11535, ruled C on #11700). Flags: `--apply` (default off), `--yes`/`-y`, `--force`, `--table ` (repeatable), `--database-url`, `--json`. **Dry-run contract: without `--apply` the command executes nothing at all** — it prints the exact statements and the database they would run against, opens no seam and issues no probe, and a run is verified to have left the column type and every row unchanged. `--apply` runs `@objectstack/driver-sql`'s own `manualJsonConversionSql` — newly re-exported from that package's index for this consumer, its only other change — i.e. the statement the drift finding itself prints (Postgres: one `ALTER … USING (CASE …)` with `json_build_array`; MySQL: the two row-shaping `UPDATE`s then `ALTER … MODIFY … json`), refuses to execute anything the finding does not contain verbatim, re-runs detection afterwards and exits non-zero if the finding has not cleared. SQLite is excluded — the stale column round-trips a real array there, so the finding is never raised. Rows corrupted before the column is migrated are out of scope, and the command is never invoked automatically: nothing on the boot path reaches it. +- c05b40b: Stop reading every PostgreSQL `Field.date` one day early on a process east of UTC + + On PostgreSQL a `Field.date` came back **one calendar day early** whenever the + Node process ran east of UTC — an app container on `TZ=Asia/Shanghai` served + `"apply_date": "2026-08-23"` for a row `psql` reads as `2026-08-24`. The stored + value was always right; the read corrupted it, so the wrong day was already in + the REST payload before anything rendered it. Worse than a display bug: an + `afterUpdate` hook copying a date into a child record persisted the shifted + value, writing the wrong day back into the database. + + `node-postgres` materialises OID 1082 (`date`) as a JS `Date` at **local** + midnight, and `SqlDriver#toDateOnly` reads a `Date` with **UTC** components. + East of UTC, local midnight is the previous day in UTC. Measured on PostgreSQL + 16, one stored row `2026-08-24`, only the process `TZ` changed: + + | process `TZ` | `pg` materialised | driver returned | + |---|---|---| + | `UTC` | `2026-08-24T00:00:00.000Z` | `2026-08-24` | + | `America/New_York` | `2026-08-24T04:00:00.000Z` | `2026-08-24` | + | `Asia/Shanghai` | `2026-08-23T16:00:00.000Z` | **`2026-08-23`** | + + Fixed at the parser rather than the reader: the driver now registers a + connection-scoped type parser so `date` (OID 1082) and `date[]` (1182) arrive + as their `YYYY-MM-DD` wire text and never become a `Date` at all — the same + shape SQLite has always had, and the same shape MySQL already had via the + existing UTC connection pin. `timestamptz` is untouched: an instant is what a + `Date` is for, and `Field.datetime` depends on it. The parser is registered on + the connections this driver opens, never through the process-wide + `pg.types.setTypeParser`, so a host application's own `pg` clients keep stock + behaviour. + + Reading local components in `toDateOnly` instead was measured and rejected: + that helper is shared by the read, write and filter paths, and a caller's + `new Date('2026-08-24')` is UTC midnight — local components would report it as + `2026-08-23` west of UTC, i.e. the identical one-day error moved onto the write + and filter paths. `toDateOnly` now documents the UTC clock as its contract. + + **If you worked around this, you can undo the workaround.** Running the app + process with `TZ=UTC` is no longer a prerequisite for correct dates, and any + app-side "+1 day" compensation on a PostgreSQL date read must be removed — with + this release the driver returns the stored day, so a compensating shift now + overshoots. Rows that were *written* through the old skew (a hook that copied a + date it had just read) still hold the wrong day and need a data fix; nothing + here rewrites stored data. + + One behaviour change beyond the corrected day: on PostgreSQL a raw read + (`driver.execute(...)`, or knex used directly on this driver's connection) now + yields a `string` for a `date` column where it previously yielded a `Date`. + Values leaving `find()` / `findOne()` / `aggregate()` / `distinct()` were + already normalised to `YYYY-MM-DD` strings and keep that type — only the day + they name changes. + + Pinned by a process-zone matrix (`UTC`, `Asia/Shanghai`, `America/New_York`, + `Asia/Kolkata`) that asserts it contains an east-of-UTC cell before it believes + itself: the existing live-Postgres CI job runs at `TZ=America/New_York`, which + is west of UTC, where the pre-fix read names the right day — which is why this + was green in CI for as long as it was broken in production. +- a11c1a5: `signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: + + - **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. + - **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). + - **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. + + Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. +- dfebfc8: feat(driver-sql,spec): one emission-identity source — `redshift`/`cockroachdb` DDL is refused by name, `pgnative` joins the Postgres family (#11991, landing the #11756 ruling) + + **BREAKING** accept-set narrowing on `SqlDriver`'s DDL path, shipped as `minor` + under the repo's launch-window convention for breaking changes — and a widening + in the same edit, so read both directions. + + Maintainer ruling, 2026-08-25 (#11756, verbatim 「同意」 on 「C,但 pgnative + 归入 Postgres 家族」). Three knex clients speak the PostgreSQL wire protocol + without being the PostgreSQL this driver emits DDL for, and the driver had no + opinion about any of them — it simply let knex compile whatever it compiles. + Measured on `origin/main` before the change, one `CREATE TABLE` per client: + + ``` + pg / pgnative / cockroachdb "body" text primary key inline + redshift "body" varchar(max) primary key in a separate ALTER TABLE + ``` + + So on Redshift the pre-ruling behaviour was not a failure — it was a table of a + different shape, built quietly, with the deployment finding out when it wrote + data into it. + + **Refused (narrowing).** A `redshift` or `cockroachdb` datasource that reaches + schema DDL — `initObjects` / `syncSchema`, `dropTable`, `rotateShards`, + `reconcileManagedSchema` — now gets an immediate + `UnsupportedDialectEmissionError`: code `SQL_DIALECT_EMISSION_UNSUPPORTED` + (newly registered under `@objectstack/driver-sql` in `ERROR_CODE_LEDGER`), + HTTP status `501`, and a message naming the client, every client the driver + DOES emit for, and the supported way to keep the database — manage its schema + out-of-band and boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`. It throws + before any statement is issued, so nothing is half-built. Connection, the + connect bound and the #11389 calendar-day parser are untouched: the boundary is + DDL only, drawn where behaviour was actually verified. + + **Recognised (widening).** `pgnative` is now a member of the Postgres emission + family — knex resolves it to the same `postgresql` dialect and the same query + compiler as `pg`, differing only in which npm binding carries the bytes. It was + previously in neither the emission set nor the wire table, so a `date` column + got a bare `CURRENT_TIMESTAMP` default (the server's calendar day, the exact + #11550 defect) and no calendar-day parser. It now behaves identically to `pg` + and carries the #11389 pin. + + **One source of truth.** The pair `cockroachdb, redshift` used to be + hand-written into the connect-timeout table and again into the wire table. It + is now declared once, as `POSTGRES_WIRE_ONLY_CLIENTS`, and both tables extend + the emission sets through it — as does the refusal, which reads the same set. + Adding a future pg-wire client is one edit, and the three answers cannot drift + apart. `mariadb` is explicitly out of the ruling's scope and keeps its third + state: neither recognised nor refused. + + +- 4045b95: fix(driver-sql): make the SQLite `Field.json` codec injective — one encoding across all three dialects (#12380) + + **BREAKING** storage-format change for `Field.json` columns on SQLite (and the + SQLite-backed `driver-turso` / `driver-sqlite-wasm`, which inherit this codec), + shipped as `minor` under the repo's launch-window convention for breaking + changes. Postgres and MySQL are **untouched** — this makes SQLite match what + they have always done. + + `formatInput` now `JSON.stringify`s every `Field.json` value on every dialect, + and `formatOutput` parses it back. That **deletes a dialect branch rather than + adding one**. + + ## What was wrong + + Measured 2026-08-26 through the driver boundary on live SQLite, live Postgres + 16.13 and live MySQL 8.0.46, with each stored cell read back through a separate + raw catalog query: **Postgres and MySQL were 17/17 faithful; SQLite was 13/17 + type-changed.** Three independent mechanisms, only two of them reversible: + + 1. **Read-side.** `formatOutput` `JSON.parse`s every string in a json column, so + a stored string whose *content* is valid JSON came back type-changed — + `'true'` → boolean, `'null'` → null, `'[]'` → array, `'{"a":1}'` → object. + 2. **Write-side.** The column is declared type `json`, which contains none of + `INT`/`CHAR`/`CLOB`/`TEXT`/`BLOB`/`REAL`/`FLOA`/`DOUB`, so SQLite's affinity + rules fall through to **NUMERIC** and a bound number-like string was converted + to INTEGER/REAL *before storage*: `'123'`, `' 123 '`, `'0123'`, `'1e5'`, + `'1.0'`, `'-0'` were destroyed on disk. ⛔ Not reversible. + 3. **Native booleans.** `true` was stored as INTEGER 1 and read back as the + number `1` — `formatOutput`'s `booleanFields` pass is keyed to declared + `Field.boolean` *columns*, not to booleans inside a json payload. + + The contract decides which dialect is right, not strictness: `json`'s stored + contract is `z.unknown()` because *"openness is now an explicit decision, not an + accident of nobody checking"* (`packages/spec/src/data/field-value.zod.ts`). An + explicitly-open contract admits both `123` and `'123'` as legal values of one + field, so no driver may collapse them onto one representation. + + The live consumer is `sys_setting.value`, which is `Field.json`, and the settings + service persists verbatim and reads back with no re-coercion by declared type — + so the driver's answer is what the caller gets, on the dialect tenant + environments actually run. + + ## What changes on disk, and what does not + + The DDL is unchanged — the column is still declared `json`, so NUMERIC affinity + is still in force. The encoded form defeats it because a string's encoding + carries its quotes (`'123'` → `"123"`, which is not a numeric literal). Pinned + live rather than reasoned. + + For **new** writes the on-disk delta is exactly two classes: + + - **strings** are now quoted JSON text; + - **booleans** are now TEXT `true`/`false` instead of INTEGER `1`/`0`. + + Objects, arrays, `null` and **numbers** are byte-identical to before (`123` bound + as a number and `"123"` bound as text both land as INTEGER `123`). + + ⚠️ An out-of-band reader of a SQLite file — anything reading the table with its + own SQL rather than through this driver — now sees quoted JSON text where it saw + a bare value. + + ## The migration, and the limits of what it can recover + + `backfillCanonicalJsonEncoding` runs on `syncSchema`/`initObjects` for existing + tables, the same posture and shape as the `backfillCanonicalDatetimes` and + `backfillCanonicalTimes` storage-format migrations beside it: one `UPDATE` per + column, failures logged and swallowed, correctness never contingent on it having + run. It converts the **one on-disk class the pre-fix encoding left unambiguous** — + a TEXT cell that is not valid JSON, which nothing but a stored plain string could + have produced — into its quoted form. Idempotent by construction: the `WHERE` is + the exact complement of the `SET`'s output, so a converted row cannot match again + and re-running costs one scan and zero writes. + + ⛔ **It does not guess, because the rest cannot be guessed**, and two classes are + therefore left exactly as they are: + + - **INTEGER/REAL cells.** A number, a boolean, and a number-like string eaten by + NUMERIC affinity are the *same bytes* on disk — `123` the number and `'123'` + the string are one INTEGER `123`. No migration can know which was written. + - **TEXT cells that already parse.** A stored object `{"a":1}` and a stored + *string* `'{"a":1}'` were byte-identical before this change. Re-quoting them + would turn every legacy object and array into a string — corrupting the common + case to guess at the rare one. + + ⇒ Those rows read after this change exactly as they read before it. **The class + stops growing; it is not retroactively repaired.** Maintainer ruling 2026-08-26, + with that cost accepted explicitly. + + The migration changes **no read**: a legacy plain string reads back as that + string before it runs (via `formatOutput`'s parse fallback, kept for exactly this + reason and now documented as the pre-#12380 read-side repair) and after it runs. + It is a canonicalisation that makes the on-disk format uniform and injective + going forward, not a repair of something that reads wrong today. + + ## What upgraders may notice + + Values that were being **corrupted** now read back correctly. Code that adapted + to the corruption is what changes underneath: a boolean `Field.json` value that + read back as `1` now reads back as `true`, and a string whose content is valid + JSON now reads back as that string instead of the structure it looked like. + Filters are unaffected — every scalar comparison operator on a json column is + already refused by the driver (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), so no + predicate could have been keyed to the old stored text. + + +- c49afd0: driver-sql: a string field's declared `maxLength` now shapes the column it gets + + `createColumn` mapped the string family — `string` / `email` / `url` / `phone` / + `password` — with a bare `table.string(name)`, so every column took knex's + default width of 255 and the field's own `maxLength` was never read. A field + declaring a wider bound got a narrower column, and on a dialect that enforces + `varchar` length the write was refused: measured through the driver's own + `initObjects` on MySQL 8.0.46 and Postgres 16, a 300-character value written to + a `maxLength: 1024` column came back `ER_DATA_TOO_LONG` and `22001 value too + long for type character varying(255)` respectively. `schema-drift.ts` has always + treated `varchar(field.maxLength)` as the expected physical shape, so every such + column also reported permanent drift against a table the driver had just + created. + + **This changes emitted DDL for existing declarations.** A field declaring + `maxLength` now gets `varchar(maxLength)` in both directions — wider *and* + narrower than 255. Only newly created columns are affected: `createColumn` runs + on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, never on a column that already + holds rows, so nothing is truncated and no existing column is rewritten. + Narrowing a populated column remains what it was — the `narrow_varchar` drift + op, category `destructive`, behind `os migrate apply --allow-destructive`. + + A declared bound above 16383 characters (MySQL's utf8mb4 `varchar` ceiling) + makes the column `TEXT` rather than clamping it, since a clamp would reinstate + the same defect. Fields declaring no `maxLength`, or a malformed one, keep + `varchar(255)` exactly as before. `lookup` / `user`, `autonumber`, and the + catch-all branch are deliberately unchanged — none of them stores the value the + declared bound describes. + + Two matching corrections in `schema-drift.ts`, so the differ and the emitter + agree on which declarations count: a `maxLength` that is not a positive integer + is no longer read as a bound (`maxLength: 0` planned a destructive `varchar(0)` + ALTER), and a MySQL `TEXT` column is no longer diffed as a `varchar` 65535 wide + — MySQL reports `character_maximum_length` 65535 for `TEXT` where Postgres + reports NULL, so on MySQL alone every bounded unkeyed text column had been + reporting a permanent destructive `narrow_varchar` against itself. + +### Patch Changes + +- ef52884: `aggregate()` now answers an unresolvable column with the same refusal class as `find()` and `count()` instead of the generic `DATABASE_ERROR`/500 terminal — the #8790 refusal reaching the third read door (#11541). The dialect-named column is attributed to the clause the caller's own query names it in: a `groupBy` field or an aggregation `field` refuses with `INVALID_FIELD`/400 naming the column and the clause (the same code the protocol ingress gives this condition, #4254); a column named by neither clause is the WHERE, which answers #8790's `INVALID_FILTER`/400 refusal verbatim; a dialect wording that yields no column name keeps the #11455 terminal envelope unchanged, because no attribution is supportable there (#8931). Drivers extending `SqlDriver` (`driver-turso`'s embedded face, `driver-sqlite-wasm`) inherit the same answers. +- 178f90c: Boolean aggregands now answer the ruled #11249 contract on every SQL dialect. On Postgres, `sum`/`avg`/`min`/`max` over a declared `boolean` field are lowered with a cast (`avg(cast("flag" as int))`) instead of reaching the server as `avg("flag")` — which PostgreSQL refuses with SQLSTATE `42883`, so those aggregations previously failed with `DATABASE_ERROR`/500. On every dialect, `min`/`max` results over a declared boolean are now presented as JSON booleans (`false`/`true`) at the driver boundary — previously MySQL (`tinyint(1)` storage) answered `0`/`1`. `sum`/`avg` answer arithmetic (`3` / `0.5` over a 3-true/3-false column); `count`/`count_distinct` are unchanged, and `min`/`max` over an empty window still answer `null`. +- 84de7e3: fix(driver-sql): name the storage a declaration on a builtin column name loses, instead of discarding it in silence (#12015) + + `initObjects` emits `id`, `created_at` and `updated_at` itself and then skips any + declared field colliding with one — `if (builtinColumns.has(name)) continue;`, with + no warning, no throw and no record anywhere that the author's declaration had been + dropped. Measured on live PostgreSQL 16.13: an object declaring + `id: { type: 'text' }` boots green and gets `id varchar(255)` — `table.string('id')`, + not TEXT. Measured here on SQLite: the same substitution, and a declared + `maxLength: 12` on that field binds nothing. The driver is right to own its primary + key and audit stamps; the defect was that it disagreed with the author in silence — + the declared-≠-enforced shape that bites hardest on AI-authored metadata, where the + mismatch surfaces much later as data behaving oddly. + + Every DDL path that drops such a declaration now says so, naming the field, the + object, the attributes that were lost and what the platform's column actually is: + + - **create** — `while creating table "…"`, said before the CREATE runs, so the + author hears it even when the CREATE goes on to fail for an unrelated reason; + - **ADD COLUMN diff** — `while syncing existing table "…"`; this path drops the + declaration for a different reason (the builtin is already in the table, so the + diff never proposes it), and it is the path a stock upgrade takes; + - **rotation shard** — `while syncing shard "…"`, covering both the shard-create and + shard-column-sync branches. + + A warning on one path with silence on the others just moves the trap, so each path + carries its own call and its own pin: a regression to a silent `continue` on one path + fails by name rather than being absorbed by a sibling. + + **Only the STORAGE half is reported, because only the storage half is lost.** A + declaration on a builtin column name still carries `label` (and the locales generated + from it), `readonly`, `searchable` and the ADR-0113 write contract in `required` — all + honoured on the platform's column exactly as on any other. So the diagnostic fires + only when the declaration asks for storage the platform's own column does not deliver + (a differing `type`, a `maxLength`, `unique`, `defaultValue`, `storage.notNull`, a + `multiple` shape…) and stays silent when it does not: `created_at: { type: 'datetime', + defaultValue: 'NOW()' }` describes precisely what lands, and says nothing. + `id: { type: 'number' }` — an author expecting a numeric key — still fires, as does + `id: { type: 'text' }`. The storage/presentation split is one table + (`builtin-column-collision.ts`) pinned against `FieldSchema.shape`, so a field key + added later is classified deliberately instead of defaulting into silence. + + **Grade: `patch`, and deliberately.** Nothing about the accept set moves — every + object that booted before still boots, the DDL emitted is byte-identical, no public + type or metadata key changes, and the only observable difference is a line in the log + for storage that was already being discarded. The platform still owns `id` / + `created_at` / `updated_at`: this changes what the driver **says**, never what it + **does**. +- f9ffd01: `SqlDriver` now recognises knex's own **canonical** client spellings, so `client: 'postgres'` and `client: 'sqlite'` no longer silently lose every dialect-specific behaviour (#11550). `SqlDriverConfig` is `Knex.Config & {…}`, so every client name knex accepts was already declared valid, while `isPostgres` / `isSqlite` enforced two literals each — and `postgres` is the canonical name of the dialect whose registered aliases are `pg` and `postgresql`, `sqlite` likewise for `sqlite3`. Nothing failed on the unrecognised spellings; the driver just emitted the wrong SQL. The sharpest case: `nowColumnDefault` fell through to a bare `CURRENT_TIMESTAMP` default on a `DATE` column, which resolves the calendar day in the **server's** timezone — the exact defect ("a UTC-12 server records YESTERDAY") that method's Postgres branch exists to remove. The three getters, the connect-timeout table, the pg wire-protocol set and the MySQL UTC session pin now derive from one identity source per dialect family instead of four hand-written lists that had already drifted apart. What an already-recognised spelling (`pg`, `postgresql`, `sqlite3`, `better-sqlite3`, `mysql`, `mysql2`) resolves to is unchanged, and `redshift` / `cockroachdb` keep pg **wire** recognition without gaining SQL-**emission** identity — that remains an open support-scope decision (#11756). +- c804f0c: The stale multi-value column warning now names `os migrate multi-value-columns`, + instead of telling operators ObjectStack will never fix the column + + The finding that reports a multi-value field left on a stale `varchar`/`text` + column opened its remedy with **"ObjectStack will NOT change this column for + you. Migrate it by hand"** and then printed raw SQL. That was true when it was + written and became false the moment `os migrate multi-value-columns` shipped: + there is now an operator-run command that does exactly this, with a dry run as + the default, a confirmation prompt, and a post-run re-detection that exits + non-zero if the finding has not cleared. Operators were being sent to hand-write + DDL on a production table while the safer route sat one command away, unnamed. + + The message now leads with the command and keeps the hand-run statement after it + for anyone without the CLI. Both surfaces an operator meets this on pick the + change up, because both print `message` verbatim: the boot warning + (`[schema-drift] …` on every restart) and `os migrate plan`. + + What has **not** changed is what the finding gates. It stays `severity: 'error'`, + `category: 'needs_confirm'` — the artifact boot gate refuses a boot on + `category === 'destructive'` and on nothing else, and every database this finding + describes is already serving, so making the report louder must never be the thing + that stops one from starting. No load-time or write-time refusal was added; the + platform still never migrates the column on its own, per the ruling that it warns + and ships an explicit operator-run migration rather than altering a customer's + production table unattended. + + The dialect-specific statement stays embedded **verbatim**, which is a contract + rather than formatting: a `ManagedDriftEntry` carries no dialect, so the CLI + command recovers one by testing which dialect's statement the message contains. + That coupling is now pinned from the emitting side as well as the consuming one. +- 9d3c04d: fix(driver-sql): `aggregate()` joins the enveloped read exits — a dialect error it + cannot attribute now leaves as `DATABASE_ERROR` / 500 instead of raw (#11455) + + `SqlDriver.aggregate()` executed its statement **bare**. Every dialect error the + backend raised left the driver as the backend's own error object: a `code` from + the backend's vocabulary, **no `status`** at all, and a message opening with the + compiled statement. `find()` and `count()` have carried the terminal ADR-0112 + envelope since #8931; this third read door was simply never given it. + + Measured on live PostgreSQL 16.13. The driver maps a `boolean` field to a real PG + `boolean` column and `SQL_AGGREGATE_FUNCTIONS` lowers the arithmetic aggregates to + a bare function name with no cast, so an ordinary analytics shape — a rate measure + over a flag column — reached the server as `avg("flag")`: + + ``` + sum(flag) => THREW code=42883 status=undefined + msg=select sum("flag") as "n" from "…" - function sum(boolean) does not exist + ``` + + A raw `42883` is on no list `@objectstack/rest` reads, so with `status` undefined + a caller-shaped mistake was logged as an **unhandled server fault**, and the + statement's shape travelled to the caller with it. + + `aggregate()` now composes the same `backendStatementFaultError` its two siblings + do: `DATABASE_ERROR` / 500, asserting exactly one thing — *the backend would not + run this statement* — with the dialect's own diagnostic written to the **server + log** rather than the caller's message, and the original error kept as a + non-enumerable `cause` so `isMissingTableError` and every other cause-following + predicate stay truthful. + + **No new error code.** ADR-0112 D3/D4 closed the `StandardErrorCode` vocabulary, + and D2's 2026-08-18 amendment retired three members on the reasoning that an + unreachable-but-declared code teaches a branch that can never fire. The code here + is the catalogued member the sibling read exits already answer with. + + **This is the envelope half only, and it decides no contract.** Whether the + platform should *answer a number* for an arithmetic aggregate over a boolean (by + casting in the lowering) or *refuse* is #11152's question, and #11249's for + `min`/`max`. Nothing here pre-empts it: the envelope is raised from the **exit**, + not from recognising `42883` or any wording, so it holds whichever way that card + is ruled — and the three dialects' arithmetic answers are deliberately left + unpinned (measured 2026-08-24: SQLite and MySQL's `tinyint(1)` both answer, + Postgres refuses). + + Unchanged, and pinned as controls: the precise refusals this door already + composed — an undeclared function (`INVALID_QUERY` / 400, #5907), a + `count_distinct` with no `field` (`INVALID_QUERY` / 400, #6409), a + per-aggregation `filter` (`NOT_IMPLEMENTED` / 501, #10576) — are all raised while + the statement is *built*, upstream of the guarded execution, so none can be buried + under the generic envelope. The accept set does not move: every condition that now + takes the envelope failed before this change and fails after it. +- fcd0efc: fix(driver-sql): scope the Postgres `introspectForeignKeys` catalog read to the session's own schemas (#11201) + + The Postgres arm queried `information_schema.table_constraints` with + `tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ?` and **no `table_schema` + predicate at all**. Those views span every schema the session has privilege on, + independently of `search_path`, so a table name that exists in more than one schema had + all of their foreign keys merged into a single answer — including foreign keys from + schemas the session can never reach unqualified. + + That is a wrong answer rather than a missing one, and it is consumed as fact: + `introspectSchema` hangs the result on the table it just listed, and from there it reaches + federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift + comparison. A phantom foreign key makes a drafted federated object reference a table it + does not reference. + + The fix is the pin the rest of the family already carries — + `AND tc.table_schema = ANY (current_schemas(false))` — spelled and placed exactly as + `introspectUniqueConstraints` spells it, which in turn follows `introspectSchema`'s own + table listing. `introspectForeignKeys` was the last unscoped introspection arm; the two + `pg_index`-based arms (`introspectIndexes`, `introspectPrimaryKeys`) reach the same scoping + from the other side by resolving the name to an OID through `regclass`. No interface shape + and no accepted input changes: a same-named table in another schema simply stops + contributing foreign keys it never should have contributed. + + Measured on a live PostgreSQL 16.13. The regression pin + (`sql-driver-11201-introspect-fk-schema-scope.test.ts`) builds the collision the repo's own + live-PG isolation (#9350, one schema per test file in one database) already makes routine: + two same-named tables in two schemas, each with a different foreign key. It first asserts + the pre-fix predicate really sees both constraints — so the interesting assertion, an + absence, cannot go green on a fixture that never collided — then requires the arm and + `introspectSchema` to return only the current schema's. Reverse-verified: with the + predicate reverted the pin fails with the neighbour's foreign key present in the answer. + + The MySQL arm of the same method was checked and is not affected: it already pins + `TABLE_SCHEMA = DATABASE()`. SQLite has no schemas. +- dd4113e: fix(driver-sql): order the MySQL `introspectForeignKeys` read by the key ordinal (#11379) + + `SqlDriver.introspectForeignKeys`' MySQL arm read `information_schema.KEY_COLUMN_USAGE` + with no `ORDER BY`. `ORDINAL_POSITION` is the key ordinal and was selected by neither the + projection nor an order clause, so the row order of a composite foreign key's columns was + whatever the query plan happened to yield. + + That order is load-bearing. `IntrospectedForeignKey` is a flat per-column record with no + ordinal field, so a composite key is expressed as **ordered sibling rows** — `(x, y) + references p (a, b)` is `x -> p.a` then `y -> p.b`, and there is nothing for a consumer to + recover the position from if the rows arrive permuted. The Postgres arm pins this with + `ORDER BY … k.ord`; the MySQL arm was leaving it to the optimizer. + + This is a determinism fix rather than the repair of a wrong answer, and the measurement is + what distinguishes the two. On MySQL 8.0.46, a foreign key declared out of column sequence + — `foreign key (second_col, first_col) references ooo_parent (pa, pb)` — came back in key + order through this predicate with no `ORDER BY` at all. But on the same server, in the + same session, over the same view, the sibling `introspectPrimaryKeys` predicate + (`CONSTRAINT_NAME = 'PRIMARY'`) returned an out-of-sequence primary key in **column** + order — `carrier_code` at ordinal 2 ahead of `shipment_id` at ordinal 1. `KEY_COLUMN_USAGE` + therefore does not preserve the ordinal for free on this server: which of the two orders + you get is decided by the `WHERE` clause, and nothing declared that. The foreign-key + predicate was on the lucky side of a choice nobody made. + + Consumers that read composite foreign keys through `introspectSchema` — federated-object + codegen, the persisted `external_catalog` (ADR-0015), and schema-drift comparison — now get + the declared key order from MySQL by construction rather than by plan choice. +- 992161b: fix(driver-sql): `introspectUniqueConstraints` reports single-column uniqueness on all three dialects (#11202) + + `SqlDriver.introspectUniqueConstraints` returns a flat `string[]` that + `introspectSchema` folds into a per-column `isUnique` flag, and the three dialect arms + disagreed about what that list meant. SQLite pushed a column only when the unique index + had exactly one column; the Postgres and MySQL arms returned **every member of every + composite constraint**. So for `UNIQUE (a, b)` the same table read through Postgres + claimed `a` alone is unique *and* `b` alone is unique — a claim the constraint does not + make — while through SQLite it claimed neither. + + The divergence was latent rather than active until recently: the Postgres arm's query + selected `c.column_name` with no alias `c` in scope, and the bare `catch {}` the method + carried until #11161 turned every execution into `[]`. Live Postgres had therefore never + once reported a unique constraint through this method. Repairing that query is what put + three dialects into conflict on live systems for the first time. + + Per maintainer ruling 2026-08-23 (option A→B), the flag is now narrowed to + **single-column uniqueness only**: a column is reported iff some unique constraint covers + that column and nothing else. A composite constraint's members are deliberately absent — + a per-column boolean is structurally unable to say "a and b are unique *together*", so + setting it on both members asserts something different and false. Representing composite + constraints is option B and waits for real demand; until it exists, an absent flag on a + composite member means "not single-column unique", never "no constraint". + + All three arms now normalise their rows to a `UniqueConstraintMember` and decide through + one predicate, so a fourth dialect cannot quietly acquire a fourth meaning. The Postgres + arm additionally selects `constraint_schema` and keys constraint identity on + `(schema, name)`: its answer spans `current_schemas(false)` and Postgres auto-names a + unique constraint after the table and column, so two same-named tables in two schemas + produce two different constraints under one name — keyed on the name alone they would + fuse into an apparent two-member constraint and drop a genuinely single-column unique + (the #11201 defect class, one method over). + + Two smaller corrections ride the same rewrite, both in the SQLite arm's handling of + `PRAGMA index_info` rows: an expression-index term (`… ON t (lower(a))`) reports + `name: null`, which the arm used to push into a `string[]` as a literal `null` — it is + now discarded, while still counting toward the index's width so `(d, lower(e))` cannot + read as single-column; and the returned columns are de-duplicated, so a column carrying + both a `UNIQUE` clause and a hand-made unique index is named once. + + No interface shape and no accepted input changes, and `isUnique` is only ever *set* to + `true`, so a column that stops being flagged carries `undefined` exactly as an + unconstrained column always has. The one in-tree consumer is + `introspectedSchemaToObjects` in `@objectstack/objectql`, which turns the flag into a + drafted field's `unique: true` — it is the direct beneficiary: composite members no + longer draft fields declaring a single-column uniqueness the database never enforced. + + Verified on embedded SQLite, including the consumer-visible `introspectSchema` fold; the + live Postgres and MySQL cells are declared through the shared dialect matrix and run in + the `Temporal Conformance (live PG + MySQL)` job. The narrowing predicate is pinned + directly against each dialect's real row shape, so the Postgres and MySQL decision is + measurable without a provisioned server. Reverse-verified by ablation: with the width + filter removed, 9 of the new pins fail — the Postgres and MySQL row-shape cases, the + end-to-end SQLite cell, and the `isUnique` fold. +- d395692: Withdraw the never-honored `IntrospectedTable.indexes` promise and widen two + introspection declarations to the measured emitted types (#11122, maintainer + ruling 2026-08-23, option B — 「其他同意你的意见」). + + The spec's introspection contract (`schema-diff-service.ts`) declared + `indexes: IntrospectedIndex[]` as REQUIRED, yet no producer has ever emitted + it — a consumer typed against the promise read `undefined` with no compiler + complaint. It also declared `defaultValue?: string` while the in-tree SQL + driver passes `knex.columnInfo().defaultValue` through raw (measured on live + SQLite: `null` for a column with no default, dialect-quoted strings such as + `'abc'` otherwise; other producers report native values such as `true`). + + - `IntrospectedTable.indexes` is now **optional**, and absence is meaningful: + an absent key means the producer did not read indexes; an empty array is a + positive claim the table HAS none. Producers that did not look must omit + the key rather than emit `[]`. Wiring the index read into + `introspectSchema()` is explicitly NOT part of this change. + - `IntrospectedColumn.defaultValue` is now `unknown` — consumers narrow + before use instead of trusting a string promise no producer kept. + - The SQL layer's extra `maxLength` fact (driver-sql / objectql + `IntrospectedColumn`, driver-sql `PhysicalColumn`) widens from `number` to + `number | string` — SQLite reports the string `"255"` where other dialects + report a number. + + With the spec now telling the truth, the deliberate `Omit` workarounds in + `@objectstack/driver-sql` and `@objectstack/objectql` (which carved + `defaultValue` and `indexes` out of the spec types to keep the divergence + visible) are retired: both packages' introspection types now extend the spec + contract directly. + + Consumers that read `table.indexes` must guard for absence (none exist + in-tree — the requirement was never honored, so today's readers would have + crashed on `undefined` anyway); consumers of `defaultValue` must narrow from + `unknown` before string operations. +- e40a28c: fix(driver-sql): a declared `Field.boolean` answers JSON booleans on MySQL's row-read doors (#11782) + + `formatOutput`'s boolean read coercion — and its per-column mirror + `readPresentationKind`, which `distinct()` and the aggregate group-key / + `min`/`max` tracking consume — was gated `isSqlite`-only. On MySQL the storage + is `tinyint(1)` and mysql2 hands back a JS number, so a declared boolean + answered `1`/`0` through `find()`, `distinct()` and aggregate group keys while + SQLite and Postgres answered `true`/`false` — and, after #11635 presented + aggregate `min`/`max` on every dialect, `max(flag) === true` and + `row.flag === 1` disagreed on the same column over the same MySQL connection. + + Measured on live MySQL 8.0.46 before the fix: `find().flag` → `1` (`typeof + number`), `distinct('flag')` → `[0, 1]`, aggregate group keys → `1`/`0`. The + boolean presentation now runs on the two dialects whose stored boolean is a + number (SQLite `INTEGER` 0/1, MySQL `tinyint(1)`); Postgres stores a real + `boolean` node-pg already parses, so it deliberately stays outside the gate and + its answers are byte-identical. A `NULL` boolean stays `null` on every door + (absence is not `false`), and declared `number`/`string` columns are untouched. +- 7e83932: MySQL's row-size refusal now names the declarations that caused it (#11565). MySQL charges every bounded column's DECLARED byte width against a per-row budget, independently of the per-column `varchar` ceiling — measured on 8.0.46 through this driver, 15 fields at `maxLength: 1024` create and 16 are refused — and its own error names no column and no declaration, about a table its author described entirely in metadata. Schema sync now translates `ER_TOO_BIG_ROWSIZE` at both the `CREATE TABLE` and `ALTER TABLE ADD COLUMN` sites into the same failure re-worded: every varchar column the object produces, widest first, with its emitted width and its byte cost at the schema's real bytes-per-character (read from the server, not assumed), plus the fields that reach the budget while declaring nothing — `lookup`, `user`, `auto_number` and the option types all take `varchar(255)`. InnoDB's separate per-page limit answers with the same code and is reported with the number the server quoted rather than 65535. Deliberately a translator and not a pre-flight: it speaks only after the server has refused, so it cannot refuse an object MySQL would have accepted. Nothing is refused that was accepted before, and no other dialect is touched. +- 80f1dcd: **Fix:** `introspectForeignKeys`' Postgres arm no longer drops a cross-schema foreign key, nor returns a composite one as a cartesian product (#11324). + + The arm joined three `information_schema` views, and the correlations were wrong in two independent ways. Both were measured on live PostgreSQL 16.13, against the query as it stood after #11201, so neither was caused by nor repaired by that change. + + **A foreign key whose target lived in another schema vanished.** The join carried `ccu.table_schema = tc.table_schema`, which demands parent and child sit in the same schema. For a FOREIGN KEY constraint, `constraint_column_usage` describes the *referenced* side — that is exactly why the projection aliases it `referenced_table` — so its `table_schema` is the **parent's**, not the constraint's. A cross-schema reference therefore contributed **zero rows**, and the table reported having no foreign keys at all. That is the #7332 failure mode through a different door and it has no `onFailure` to consult, because nothing failed: `[]` does not read downstream as "I could not see it", it reads as *this table has no foreign keys*, and federated-object codegen, the persisted `external_catalog` (ADR-0015) and schema-drift comparison all act on it. Cross-schema references are the normal shape for the federated remotes ADR-0015 points this driver at. + + **A composite foreign key came back as the cartesian product of its columns.** The `kcu` ↔ `ccu` join carried no ordinal correlation at all, so an N-column key yielded N x N rows pairing every child column with every parent column. Measured, a 2-column key `(x, y) references p (a, b)` returned **four** records — `x -> a`, `x -> b`, `y -> a`, `y -> b` — where the answer is `x -> a`, `y -> b`. Because `IntrospectedForeignKey` is a flat per-column record, the two phantom pairs are indistinguishable from the real ones to every consumer: a wrong-shaped answer that type-checks. + + **The whole query moves to `pg_constraint` rather than the join predicate being patched.** `constraint_column_usage` exposes no ordinal column at all — measured, its seven columns are the catalog/schema/name triples for the table and the constraint plus `column_name` — so the composite half has nothing to correlate on inside `information_schema`. The conservative half-fix was tried and measured: correlating `ccu` on `tc.constraint_schema`, the spelling `introspectUniqueConstraints` already carries, repairs the cross-schema case and leaves the composite case at four rows. `pg_constraint` carries both facts on one row — `conkey` and `confkey` are parallel `smallint[]`s in key order — so unnesting them *together* pairs child column with parent column by construction, and `unnest(...) WITH ORDINALITY` keeps the key position the old join threw away. That is the shape `introspectPrimaryKeys` already uses for `indkey` (#11101 / #11162), and dropping to the catalog matches what that arm and `introspectIndexes` already do. + + **No interface change.** `IntrospectedForeignKey` keeps its flat per-column shape and gains no ordinal field. A composite key is expressed as **ordered sibling rows** — contiguous, in declared key order, each pairing its own child column with its own parent column — which `ORDER BY con.conname, con.oid, k.ord` now pins and the type's docblock now states. Measured on a key declared out of column sequence, `foreign key (second_col, first_col)`, the result is key order rather than column order. An ordinal field was considered and rejected: it would let a wrong `ORDER BY` keep shipping wrong rows that merely *describe* their wrongness, where the pairing is a fact the query itself has to get right. + + Schema scoping is unchanged in meaning: `ns.nspname = ANY (current_schemas(false))` is #11201's `tc.table_schema = ANY (…)` expressed over the catalog, so a same-named table in a schema `search_path` never reaches still contributes nothing. An unknown table name still yields an empty list rather than a throw, so the #7332 `onFailure` contract is untouched. +- 6757eb2: A `redshift` datasource now gets the 10s dialect connect-timeout bound instead of silently degrading to the 15s pool backstop (#11784). `SqlDriver` answers three separate questions about a knex `client` name from three separate tables, and `redshift` was a member of the wire-protocol one (`POSTGRES_WIRE_CLIENTS`, which #11389 put it in so it gets the calendar-day parser pin) while absent from `DIALECT_CONNECT_TIMEOUT`. It reaches the server through the `pg` driver — knex's `Client_Redshift` literally `extends Client_PG` — so it has `connectionTimeoutMillis` and would have obeyed it; it just never received it, and `withConnectBound` skipped the injection. Nothing errored and nothing was logged: the bound was simply 50% looser than the method's own docblock declares ("the effective bound" at 10s, with `pool.createTimeoutMillis` a "strictly looser backstop, reached only by a dialect that has no connect-timeout knob (SQLite) or ignores the one we set"). A `redshift` host is neither of those. The practical consequence is the framework#3769 failure shape — an endpoint that accepts the TCP connection and never completes the handshake makes every query WAIT rather than fail, and the wait was bounded 5s later than declared, with knex's inaccurate "the pool is probably full" wording instead of pg's `timeout expired`. A host that sets its own `connectionTimeoutMillis` or `pool.createTimeoutMillis` is still left alone. `redshift` gains **no** SQL-emission identity from this: the connect-timeout knob is a property of the npm driver doing the connecting, not of which DDL dialect gets compiled, so this is independent of the open support-scope decision (#11756). +- 1c66fe4: fix(driver-sql): retire the lookup FOREIGN KEY branch gated on the rejected alias `reference_to`, and refuse the key instead of honouring it (#11567) + + `SqlDriver.createColumn` emitted `table.foreign(name).references('id')` for a + relationship field carrying `reference_to`. `reference` is the only relationship + spelling `@objectstack/spec` declares — `reference_to` is a **rejected alias**, + answered by `FieldSchema` with `unrecognized_keys` and *"Did you mean + `reference_to` → `reference`?"* — so that branch could not fire for any + spec-conformant lookup, and never had. + + **This is not a behaviour change for any authored deployment.** Measured across + all 44 exported platform objects on live PostgreSQL 16.13 and MySQL 8.0.46 + before the change: **0** FOREIGN KEY constraints. `reference_to` has zero + non-test assignments repo-wide; the branch was reachable only by metadata that + went around Zod through raw `registerObject` (which deliberately skips it). + + What changes is that the driver no longer disagrees with the spec in silence. A + field still carrying `reference_to` at DDL time now throws + `VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same + words `FieldSchema` uses, rather than quietly changing the physical schema. One + key, one answer, on both doors. + + Fix, if you have such metadata — the same rename the schema has always asked for: + + | Wrote | Write instead | + |---|---| + | `{ type: 'lookup', reference_to: 'account' }` | `{ type: 'lookup', reference: 'account' }` | + + Referential integrity is unchanged and remains the **engine's**, applied via + `deleteBehavior` (the `409 DELETE_RESTRICTED`) — which is what + `content/docs/protocol/objectql/types.mdx` has documented since 2026-07-30. + + **Not graded as declared-breaking, deliberately.** ADR-0087's ledger reaches + upgraders about *authorable metadata* that must be rewritten. `reference_to` is + not authorable: the spec refuses it at the authoring door today and did before + this change, so no conformant object definition behaves differently and no + migration is owed to any deployment `objectstack migrate meta` can see. The + prescription above exists for metadata that bypassed validation, not for a + surface this repo ever published as writable. +- b826390: A `richtext` field now takes an unbounded TEXT column instead of knex's `varchar(255)`, so an ordinary rich-text body over 255 characters can be written (#11794). `createColumn`'s text-family case listed `text` / `textarea` / `html` / `markdown`; `richtext` — the third member of the spec's own "Rich Content" grouping in `field.zod.ts` — was in neither that case nor `JSON_COLUMN_TYPES`, so it fell through to the catch-all's `table.string(name)`. Measured at 1000 characters on live MySQL 8.0.46 and Postgres 16: before this change the write was refused by the server (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, `22001 value too long for type character varying(255)`) while the same body in a `markdown` field on the same table was accepted; after it, the column reads back as `text` from `information_schema` on both and the value round-trips byte-identically. `code` moves with it for the same reason. + + Membership is now decided by a stated, measured test instead of the hand-maintained case list that let one member of a three-member spec group diverge in the first place: a type may take an unbounded TEXT column exactly when the **write seam** enforces its declared `maxLength`, which is the invariant `schema-drift.ts` already rests on ("A TEXT column refuses nothing a `maxLength` allows … the bound is enforced at the write seam"). objectql's record-validator applies its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` / `password` / `markdown` / `html` / `richtext` / `code` and to nothing else, so both moved types keep a field-named ADR-0112 refusal for an over-declared value and the physical surface is restored to the declared contract rather than widened past it. The set of types that take an unbounded column when unkeyed is pinned as a whole, so the next addition has to be stated on purpose. + + `signature` and `qrcode` are **not** moved, deliberately and against the first reading of this defect. Their stored value is the author's own and routinely far past 255 characters (a data-URI PNG), so `varchar(255)` refuses ordinary values for them too — but the record-validator has no `max_length` branch for either, so an unbounded column would accept values a declared `maxLength` forbids: over-accepting in place of under-accepting, which is a physical surface wider than the contract. They stay bounded until the write seam can bound them, and the live-dialect suite asserts that refusal out loud rather than leaving it undocumented. + + The #11374 keyed-and-bounded rule applies to the two new members unchanged: a keyed, bounded `richtext` / `code` column is still emitted as `varchar(maxLength)` so a declared index can key it on MySQL, and a keyed but unbounded one still gets the named `explainUnkeyableTextColumn` refusal rather than a silently weaker constraint. Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty. +- 5dd3bc9: **Fix:** on SQLite the builtin `created_at`/`updated_at` audit columns now take the same canonical ISO-8601 `DEFAULT` a declared `Field.datetime` NOW() column in the same table already gets (#11321). + + `createAuditTimestampColumn`'s non-MySQL branch was `table.timestamp(name).defaultTo(this.knex.fn.now())`. On SQLite `knex.fn.now()` compiles to an unqualified `CURRENT_TIMESTAMP`, which renders a zone-**naive**, space-separated, second-precision `'YYYY-MM-DD HH:MM:SS'`. A declared `defaultValue: 'NOW()'` field in the **same table** already got `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))` from `nowColumnDefault`, so one table carried two spellings of one conceptual value: + + ``` + created_at "2026-08-23 14:54:17" <- builtin audit (naive) + when "2026-08-23T14:54:17.796Z" <- declared field (canonical) + ``` + + That naive spelling is the one `updatedAtStamp()`'s own docblock condemns: `Date.parse` reads a zone-less string as LOCAL time, silently shifting the instant by the host offset on a non-UTC runtime. It is also the pre-canonical storage form `backfillCanonicalDatetimes` exists to converge — reached here by a path writing it *today*, not by legacy data. + + The SQLite branch is now routed through `nowColumnDefault('datetime')` — the existing single source for "what does NOW() mean in DDL on this dialect" — rather than restating the expression, so the two cannot drift apart again. **Postgres and MySQL are untouched**: `knex.fn.now()` on Postgres is a real zone-aware `TIMESTAMP` that never had the ambiguity, and MySQL keeps the `now(3)` precision match from #11224. + + `rebuildSqliteTablePatched` — the whole-table rebuild SQLite drift reconciliation uses — re-emitted the audit default itself as `knex.fn.now()`. That method is SQLite-only, so leaving it would have silently **reverted** a canonically-created table the moment any unrelated drift (a relaxed NOT NULL, an orphaned column) triggered a rebuild. Fixed in the same change: a rebuild hands back the column `initObjects` would have built. + + **Graded `patch`, not `minor`, on a measurement rather than a judgement.** The change alters emitted DDL, so the question that decides the grade is what it does to databases that already exist: + + - **Existing tables are not altered.** Both call sites are `CREATE TABLE` only; `initObjects`' `alterTable` branch adds declared fields and never the audit columns. A table already on disk keeps `default CURRENT_TIMESTAMP`. + - **They do not start reporting drift.** Measured on live in-memory SQLite through the real `detectManagedDrift` entry point against a table carrying the old default: **zero** entries. Two independent guards — `BUILTIN_COLUMNS` skips `created_at`/`updated_at` in both of `diffManagedTable`'s loops, and the only `default_mismatch` producer is the #4560 runtime-token check, for which `isAppResolvedDefaultToken('NOW()')` is pinned `false`. The measurement carries a positive control: in the same call on the same table, drift reports `unmapped_column` **and** `default_mismatch` for a `current_user` column, so the default-reading dimension is demonstrably live and still says nothing about the audit columns. + - **Rows already written naive keep reading correctly.** `formatOutput`'s `repairNaiveUtcAuditTimestamp` folds them to canonical on read — the same disposition `nowColumnDefault` already documents for declared fields. + + So no deployment changes behaviour on upgrade; only newly-created tables get the corrected default. + + The population this actually repairs is wider than "writes that bypass the driver". `stampInsertTimestamps` fills both columns app-side, but it gates on `tablesWithTimestamps`, which only DDL-running paths populate. On the documented `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` posture, `registerObjectMetadata` (the DDL-free registration door) deliberately does not touch that set — so the set is empty, the stamp returns early, and **the driver's own `create()` door reaches the column DEFAULT**. Measured, one table, one row per boot posture: `created_at "2026-08-23T14:54:17.791Z"` on a normal boot versus `"2026-08-23 14:54:17"` on a `skipSchemaSync` boot, with the declared NOW() sibling canonical in both — because its canonical shape lives in the column DEFAULT rather than in an app-side stamp. That asymmetry is the argument for fixing this in DDL, and it is now closed. +- 7adcd07: `introspectUniqueConstraints` no longer reports a PRIMARY KEY column as unique on SQLite, so all three dialects now answer the same question (#11654). The SQLite arm read `PRAGMA index_list` keyed only on `unique === 1`, and SQLite materialises a non-INTEGER primary key as a unique auto-index — so a `varchar` key was reported while the Postgres and MySQL arms, which filter on `CONSTRAINT_TYPE = 'UNIQUE'`, never see a primary key at all. It also disagreed with itself: an `INTEGER PRIMARY KEY` is a rowid alias with no auto-index, so the same logical schema produced a different `isUnique` flag depending only on the declared type of its key. The arm now skips `origin: 'pk'` index rows, which closes both gaps at once (`WITHOUT ROWID` keys included). + + This continues #11202's convention: `isUnique` means a *declared single-column UNIQUE constraint*. Nothing is lost — primary-key membership is still reported losslessly through `IntrospectedTable.primaryKeys` and `IntrospectedColumn.primaryKey`. The filter is on the index's `origin`, not on whether the column is in the key, so a key column that separately carries its own unique index stays flagged. + + Consumer-visible effect: `introspectedSchemaToObjects` in `@objectstack/objectql` turns this flag into a drafted field's `unique: true`, so a federated-object draft (ADR-0015) taken from a SQLite table no longer gains a redundant `unique: true` on its key column that the same table drafted through Postgres or MySQL never had. Drivers extending `SqlDriver` (`driver-turso`, `driver-sqlite-wasm`) inherit the change. +- f5a7f9c: **Fix:** on SQLite, `applyMigrationEntries` no longer reports an op as **applied** just because a table rebuild ran (#11722). + + `SqlDriver.applyMigrationEntries` splits by dialect, and the two arms disagreed about what `applied` means. The in-place arm (Postgres / MySQL) asks per entry and believes the answer — `applyDriftOpInPlace` returns `false` for an op its dialect cannot perform, and the entry goes to `skipped`. The SQLite arm did not ask at all: it called `rebuildSqliteTablePatched(table, ents)` and then `applied.push(...ents)`, every entry, unconditionally. But that rebuild honours exactly four op types — `relax_not_null`, `tighten_not_null`, `drop_column`, `drop_column_default` — and silently ignores everything else; its own docblock already said so for the varchar ops. An ignored op was still reported applied. + + **The failure mode is a false green, not an error.** Nothing throws and nothing is skipped, so every consumer announces work that never happened: `reconcileAndWarnDrift` logs `auto-reconciled on

    `, and the artifact boot gate prints `↪ migrated `. The finding is still physically present, so the next boot detects it again, reports drift again, and "migrates" it again — a loop with no failing signal anywhere in it. + + **What changes.** `rebuildSqliteTablePatched` now returns the entries it actually acted on, built in the same pass that fills the four column sets it already partitioned into — deliberately not a second list of op types to keep in sync, so the returned set cannot drift from the work done. The caller reports those as `applied` and routes the remainder to `skipped`, logging it in the **same sentence** the in-place arm uses for an op its dialect cannot do (` on
    . is unsupported on dialect 'sqlite' — skipped`), so one greppable line covers all three dialects. `@objectstack/driver-sqlite-wasm` and `@objectstack/driver-turso` extend `SqlDriver` without overriding either method, so both inherit the correction. + + **What deliberately does not change.** No op does anything different — this moves only what is *reported*. In particular the rebuild still runs for the whole table even when it honours nothing: it re-materializes every kept column's default (#11321, #4560) and the full declared index set from metadata (#3696), so it is not a no-op, and suppressing it would change what the reconciler DOES rather than what it says it did. `applied`/`skipped` remains a reported partition consumed by log lines and CLI counts; it is not an accept/reject door, and no public surface widens. + + **Latent when found, and fixed anyway.** The gap was unreachable at the time of the fix, held closed from two independent directions neither of which knew it was holding it: `enforcesVarcharLength` excludes SQLite, so the differ never emits `widen_varchar`/`narrow_varchar` there, and `multiValueColumnTypeIsLoadBearing` excludes SQLite for an unrelated measured reason, so #11535's `manual_column_type_change` is never emitted there either. The next column op that is not SQLite-rebuildable would have opened it silently. `manual_column_type_change`'s own docblock states that `applyMigrationEntries` reports it "skipped, never applied" — measured on Postgres and MySQL; that sentence is now also true on SQLite, and the docblock says so. + + Pinned by `packages/drivers/driver-sql/src/sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts`, which constructs the reachability rather than waiting for it — it substitutes only the differ's dialect guard, handing entries straight to the public `applyMigrationEntries` seam that `os migrate apply` and the artifact boot gate both call, with a real driver, dialect and database throughout. All five cases fail on the pre-fix tree, including the consumer-level one that catches `auto-reconciled` being logged for an op that never happened. +- f24c90d: fix(driver-sql): route `updateMany()`'s payload through `formatInput` / `applyWriteColumnMap` (#11223) + + `updateMany()` was the only write door in `sql-driver.ts` that passed the caller's `data` + straight to `builder.update(data)`. Every other one — `create`, `update`, `bulkCreate`, + `upsert`, `rotatedUpdateById` — applies `applyWriteColumnMap(object, formatInput(object, data))` + first, and the WHERE side of the very same bulk statement was already being translated by + `applyFilters`. Measured on SQLite, live PostgreSQL 16.13 and live MySQL 8.0.46: + + - **`json` and `Field.multiple` values were refused.** Nothing stringified the structured + value for the bind, so each dialect refused it in its own voice: `22P02 invalid input + syntax for type json` on Postgres, `SQLite3 can only bind numbers, strings, bigints, + buffers, and null` on SQLite, and on MySQL the array expanded into the SET list itself + (``set `tags` = 'y', 'z'``) — a syntax error rather than a bind error. `update()` wrote + the identical values correctly in the same run. + - **A federated `external.columnMap` object's bulk update named a column that does not + exist.** The WHERE was mapped and the SET was not, in one statement: + ``update `legacy_p` set `name` = 'Bulk' where `full_name` = 'Renamed'`` → `no such + column: name`. The door was unusable on every remapped external object. + - **Temporal values were stored verbatim**, silently. On SQLite a zone-naive + `'2026-05-06 07:08:09'` landed as-is — the pre-#3912 storage form + `needsLegacyDatetimeRepair` exists to repair on read, written into a column + `canonicalDatetimeFields` had already certified as canonical and therefore stopped + repairing. Measured end to end: a range filter over that calendar day returned only the + `update()`-written row, with the bulk-written row on disk carrying the right day and + invisible to the query. On live Postgres the same literal was resolved in the **server's** + timezone rather than UTC — `2026-05-06 07:08:09` stored as `2026-05-05T23:08:09.000Z`, a + silent 8-hour instant shift on an `Asia/Shanghai` server. `Field.date` and `Field.time` + were affected the same way: stored verbatim on SQLite, refused outright on the live + dialects. + + The literal `'NOW()'` token now resolves on this door as it does on every other one; it + previously stored the four-character string `"NOW()"` into a datetime column on SQLite and + was refused by MySQL. + + #11176's `updated_at` stamping is unchanged in effect — the stamping decision now reads the + formatted payload, matching `update()` and `rotatedUpdateById`, and the stamp is still + applied afterwards as the literal post-map column name. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/observability@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index a3adffd24d..b6c89c50ff 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.2.0", + "version": "17.3.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 9e82cd6c4a..56dcf7e976 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,149 @@ # @objectstack/driver-sqlite-wasm +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [178f90c] +- Updated dependencies [84de7e3] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [f9ffd01] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [9d3c04d] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [107bb4b] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [1c66fe4] +- Updated dependencies [033a34c] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index 81d9a6b193..a2890926f1 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.2.0", + "version": "17.3.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 ab4029a234..69b5a664f8 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,149 @@ # @objectstack/driver-turso +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [178f90c] +- Updated dependencies [84de7e3] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [f9ffd01] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [9d3c04d] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [107bb4b] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [1c66fe4] +- Updated dependencies [033a34c] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 353a2902a9..e504ea9c57 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.2.0", + "version": "17.3.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 582e8cd44b..630566640b 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,114 @@ # @objectstack/formula +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 418d169cb0..b51f0efeb9 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.2.0", + "version": "17.3.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 8b847f2169..deb8ce669f 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,313 @@ # @objectstack/lint +## 17.3.0 + +### Minor Changes + +- 09ae32e: Judge `visibleWhen` / `readonlyWhen` / `requiredWhen` examples in the docs corpus + as CEL — where the enclosing structure says which layer they are about + + `{/* os:check */}` blocks are type-checked by `tsc --noEmit`, and every CEL + string is the same type as every other CEL string, so + `visibleWhen: "record.status != 'closed' && user.hasRole('admin')"` type-checked + perfectly. `hasRole` is a CEL function that exists nowhere — it is in no stdlib + registry and on no contract — so the predicate faults at runtime, and a + field-level `visibleWhen` fault is fail-**open**: `resolveFieldRuleState` + evaluates visibility with `fallback: true`, so the element the author wrote the + predicate to hide is shown to everyone who copies the page. That is not a + hypothetical shape — a shipped doc taught it (#11034 fixed the instance). + + `check:doc-formula-expressions` gains this as a third scan surface rather than a + second gate, because two gates with opinions about one contract is the thing + Prime Directive #12 exists to prevent. The verdict is imported whole: syntax, the + unknown-function catch and the bare-reference rule come from + `@objectstack/formula`'s `validateExpression`, and the closed-root rule comes + from `fieldRuleRootIssue` — the same two the metadata walk applies to the same + slot, in the same order, in the same words. + + **The layer is decided first, and a layer that cannot be decided is skipped and + printed.** `visibleWhen` is one key spelling several unrelated contracts, and the + binding root really does differ: an object field binds `record` + `previous` + (+ `parent`), a per-option predicate binds `record` plus the host predicate scope + including `current_user`, a page component binds the user roots and `app`, and a + flow-screen field **flattens its own field names to top level**. A gate keyed on + the key alone would have gone red on + `content/docs/automation/flows.mdx`'s correct `visibleWhen: + 'createOpportunity == true'` and on `content/docs/ui/pages.mdx`'s correct + `'sales_manager' in current_user.positions` — and a gate whose reds are wrong is + worse than no gate, because it teaches people to add ignores. + + So admission is structural and schema-backed, never keyed on the key: a + `Field.*({ … })` factory call, or a raw field definition carrying `type:` inside + an object-literal `fields:` **map**. The map-versus-array test is the load-bearing + half and it is read off the schemas — `ObjectSchema.fields` is + `z.record(name, FieldSchema)` while `FormFieldSchema` and `ScreenFieldConfigSchema` + are both `z.array(…)`, so a `fields:` map is the object-field layer and nothing + else, and a `fields:` array is exactly the case that cannot be told apart. + + **The skip list is printed and counted on every run, including green ones.** A + gate that skips in silence is the same false-green one level up, so the summary + names every skipped site and why. Measured on the corpus as it stands: 23 + text-level `*When:` occurrences, of which 13 are admitted and judged, 7 are + listed as skipped, and 3 are the ADR quoting `field.zod.ts`'s schema + (`visibleWhen: ExpressionInputSchema.optional()`) rather than authoring a + predicate. Three of those seven were invisible to an AST-only walk — a bare + `visibleWhen: "…"` line at statement position is a labelled statement, not a + property — so a text-level tripwire reconciles the two counts and any site the + parser never surfaced is listed rather than dropped. + + `@objectstack/lint` newly exports `fieldRuleRootIssue` and + `FIELD_RULE_BOUND_ROOTS`. The field-rule root decision was a closure inside + `validateStackExpressions` — correct while it had one caller, and exactly how a + second caller comes to own a dialect of a rule instead of the rule. Behaviour is + unchanged: the metadata walk now calls the extracted function and its 2271 tests + pass untouched. +- 12e306a: The canonical-expression-envelope detector for raw-literal `Page` exports gets a shared home in `@objectstack/lint` (#11480). New public API beside `walkPageComponents`: `auditPageExpressionEnvelopes(page, label)` runs the three parse doors (`PageSchema` / `PageComponentSchema` / `ComponentPropsMap`) over one authored page and reports bare-expression findings plus every door's precondition failures; `renderBareExpressionFindings(findings)` renders the actionable red; types `BareExpressionFinding`, `PageEnvelopeAudit`, `EnvelopeAuditDoor`. The detector previously lived package-local to `@objectstack/platform-objects`' gate, which could not reach raw-literal pages shipped by other packages. `@objectstack/cloud-connection`'s two shipped pages are now covered by the same gate, and `MarketplaceInstalledPage` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery sees it. +- 5383fa6: React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). ``'s canonical bindings are now the spec ListView schema's own props: `data={{ provider: 'object', object: '…' }}` for the object binding (objectui#2890 A6) and `type` for the visualization kind. `objectName` and `viewType` remain published and accepted as deprecated aliases for the whole deprecation window — nothing is removed in this release — with the deprecation visible at authoring time: `[DEPRECATED → …]` markers in the generated react-blocks contract, and a new `react-prop-deprecated` lint warning (never an error) on every use of a deprecated spelling. The lint accepts either spelling as satisfying ``'s required binding and resolves field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are. `` / `` `objectName` are unchanged: the form's spec counterpart is explicitly not 1:1 (objectui#2890 Scope B), and the chart has no metadata-tier object binding to converge on (charts bind through a dashboard `dataset` there — see chart.zod.ts guidance). Removal of the deprecated aliases is a later card after the deprecation window. + +### Patch Changes + +- 7345308: fix(lint): drop the `element:form` entry from `COMPONENT_FIELD_SPECS` (#9249) + + The whole `element:form` element retired at element grain (ADR-0049 — no + renderer ever shipped for it; the #9220 shape one element over), so every + `ElementFormProps` key is a `retiredKey()` tombstone and no spec-conformant + page carries `fields` on it. The field-binding rule's job (resolve a field + NAME against the object) is not the question a retired key raises: an authored + key is already reported by name with the element-retirement prescription — + which names the live replacement, the object-bound `object-form` block — + through the #5068 props gate, and the binding entry would only add a second + finding about a key that no longer exists — the #5775/#6629 residue class the + package's own `component-field-specs-liveness` gate refuses. +- 68c5dba: fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624) + + `os lint` computes i18n coverage and runs the authoring-rule registry in a + single pass over the same stack, and for the `flows` translation group the two + halves pointed opposite ways: + + | the author does | which rule fires | what it says | + |---|---|---| + | omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X | + | adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it | + + Measured on one stack, one run: omitting produced **4** `i18n/missing-flow` + findings and 0 liveness findings; authoring produced 0 demands and **2** + `liveness-planned-property` findings ("sets `flows` but this translation + property is planned"). There is no per-rule suppression in `os lint`, only + `--skip-i18n`, which silences the entire `i18n/missing-*` family — so the + author's only escape cost them every other coverage signal. Under + `--i18n-strict` the demand side is an **error**, so a project could be forced + to author keys it is then warned for. + + ⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner + reads the group, so a translated wizard string is stored and never shown — the + failure mode `validationMessages` was removed in 17.0.0 for. The premature half + is the demand. + + **The fix.** `collectExpectedEntries` — the single definition of what is + translatable at all, shared by the coverage gate and the `os i18n extract` + skeleton — now leaves out any translation group the liveness ledger warns + authors for authoring. It reads that set from `@objectstack/lint`'s new + `authorWarnedProperties(type)`, which returns the very warn-map + `lintLivenessProperties` iterates, so the demand side and the warn side cannot + drift into disagreeing about the same keys again. + + Two properties fall out of reading the ledger rather than switching on `flows` + by name: the bucket **turns itself back on** the day an objectui screen-flow + runner lands and the row flips to `live` (no flag, no follow-up edit), and any + future group that acquires an `authorWarn` is covered on the day it is marked + rather than re-opening this collision one group at a time. Today `flows` is the + only such group — pinned as an equality so a second one goes red instead of + shipping. + + No other bucket changes: `objects`, `apps`, `pages`, `dashboards`, + `globalActions` and `metadataForms` are all `live` and are reported exactly as + before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` + + `authorWarn: true`. +- 365e334: **Fix:** `lintLivenessProperties` walks `stack.translations` as the locale-keyed bundle it is, so the `translation` liveness ledger finally reaches the author (#11288). + + `stack.translations` is `z.array(TranslationBundleSchema)` — each item is a `TranslationBundle`, i.e. `z.record(LocaleSchema, TranslationDataSchema)`, whose top-level keys are locale codes. The lint registered `{ type: 'translation', key: 'translations' }` in `TYPE_COLLECTIONS` and then walked those items flat, the way every other collection there is walked: `checkItem` read `bundle['flows']` for the ledger's one `authorWarn` row. A bundle has no `flows` key at any depth reachable that way — the groups live one level down, under each locale — so every warned lookup missed and the whole `translation` ledger was silent for file-authored bundles, the only way apps author translations today. + + That is the failure mode the comment above `TYPE_COLLECTIONS` names ("a newly governed type needs its collection registered or its ledger warns nobody"), reached from the other side: the collection *was* registered, and the shape underneath it was the mismatch. Registering a collection is only half the contract — the walk has to match the collection's shape — so the row is now a tombstone comment saying exactly that, and `translation` joins `object`/`field` as a bespoke walk: for each bundle, each locale entry's `TranslationData` is checked, with the finding subject naming the bundle index and the locale (`translation bundle #0 · locale 'zh-CN'`). + + Measured on a real app before the fix, as a guarded ablation: injecting a `flows:` section into a locale bundle and re-running `objectstack lint --json` produced **zero** delta — 91 issues before and after, 0 liveness findings naming `flows`. The author who reached for a `planned` translation group got silence, which is strictly worse than the ledger being absent, because the ledger's stated contract is that `authorWarn` is what tells them. + + Advisory-only as before: the finding is a warning, and `os lint` exits on errors, never on warnings. + + The regression test is pinned on the **bundle** shape, and a `TranslationItem`-shaped anti-fixture is pinned alongside it. That shape — `locale` plus the groups at the top level — is the runtime metadata door, and it *warned on the broken walk*, so a fixture written that way would have been green from the day the bug shipped and pinned nothing. Runtime-authored `translation` items are reached by this lint through no door at all: no stack collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does not run at the runtime publish gate either. The two doors share the group vocabulary, not the container; only the file-authored one is lintable, and now it is linted. +- 20a452e: `lintLivenessProperties` now honours its own docblock contract ("Advisory only + — returns findings, never throws") when a collection item is `null` or + otherwise not an object. The object walk, the field walk nested under it, and + the flat `TYPE_COLLECTIONS` loop that covers every other governed type (flow, + action, agent, tool, …) each read `item.name`/`item.object` straight off every + element with no record guard, throwing `TypeError: Cannot read properties of + null (reading 'name')` on a malformed item instead of skipping it — reachable + via the exported `stack: AnyRec` signature on an unparsed or hand-built stack. + The translation bundle walk already guarded its two levels (#11383); this + closes the same hole on the three walks that did not (#11385). +- ba8420b: `lintLivenessProperties` no longer tells authors a `planned` property is `dead` + + `describe()` in `lint-liveness-properties.ts` only knew two verdicts + (`experimental`, everything else → `dead`), while the liveness ledger ships a + third: `status: 'planned'` (declared, and a consumer is being built against + it — contract-first, the opposite of `dead`). Every `planned` row fell through + into the `dead` branch, so the finding's own **message** told the author to + remove metadata the platform had asked them to write, while the same finding's + **hint** (when the row carried one) said the opposite one sentence later. Three + shipped rows hit this: `field.relatedListFilter`, `object.externalSharingModel`, + `translation.flows`. + + `describe()` now has a third branch: `status === 'planned'` gets its own rule + id (`liveness-planned-property`, mirroring `liveness-dead-property` / + `liveness-experimental-property`'s advisory-only posture — nothing downstream + keys off these ids today) and its own message/default hint ("keep it — a + consumer is being built against this property", never "Remove it"). + + The ledger's `status` field is a documented vocabulary, not a Zod-enforced + enum — nothing rejects a ledger entry with an unrecognised status. `describe()` + previously graded any such entry `dead` silently; it now throws, naming the + offending status, so a ledger-authoring mistake (a typo, or a new status added + without teaching this file about it) fails loudly at test time instead of + mislabelling a finding. +- f213793: `validate-translation-references` now checks the `flows` group — an authored key naming a + flow, screen node or screen field that does not exist warns instead of resolving to nothing + + The rule walked `objects`, `globalActions`, `apps` and `dashboards`; an unrecognised + top-level namespace is skipped and never reported, and `flows` was one of them. So a + bundle keyed to `flows..screens..fields.` parsed, shipped, and + silently resolved to nothing — the wizard rendering its source-locale string while every + other label on the screen was translated, which is the exact failure this rule exists for, + one namespace over. + + All three levels are exact-match identifiers with an enumerable universe, so the leg + mirrors the `dashboards` → `widgets` leg one level further: flow → `Flow.name`, screen → + `FlowNode.id` on `type: 'screen'` nodes, field → `ScreenFieldConfig.name`. Findings are + `warning`, like every other finding in this rule (ADR-0072 D1 — an orphan key is inert, + not broken), and each names the declared universe it resolved against. + + Two shape facts the collector respects, both measured against the schemas rather than + assumed — either one read the obvious way would have made the leg a false-positive + generator: + + - **Screen nodes nest.** A screen inside an ADR-0031 region (`loop.config.body`, + `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) is a real screen the + runner pauses on, so the universe is collected through `walkFlowNodes` rather than the + flat `flow.nodes`. + - **`ScreenConfigSchema` has two mutually exclusive shapes.** An object-form screen + (`config.objectName`) renders that object's own create/edit form and declares no + `config.fields`; its input labels resolve through `objects..fields.*`, so a + field key there is reported with that redirect rather than a bare "not declared". + + A key naming a node that exists but is not a `screen` is diagnosed as the wrong node type, + not as a missing node. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [34f60b7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/sdui-parser@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index e8392e7408..41e70e6f33 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.2.0", + "version": "17.3.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 881eab9ac1..45ec9825ed 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,162 @@ # @objectstack/plugin-mcp-server +## 17.3.0 + +### Patch Changes + +- 4ceae8a: MCP stdio transport now serves the workspace's CONFIGURED timezone/locale + instead of the manifest defaults + + The stdio transport resolved its localization inside `MCPServerPlugin.start()`. + `SettingsServicePlugin` registers its service in `init()` but binds its data + engine from a `kernel:ready` hook registered in its own `start()`, and every + plugin's `start()` body runs strictly before the first `kernel:ready` handler — + so that read was inside the settings bind window under **every** composition + order. Being ordered after the settings plugin did not help, and the + `optionalDependencies` edge that repairs the neighbouring ordering defects would + not have moved it either. + + In that window the read does not fail: the empty in-memory fallback plus the + manifest defaults answer with `source: 'default'`, so `resolveLocalizationContext` + returned `UTC` / `en-US` and reported success, never reaching its direct + `sys_setting` fallback. The value is then held for the life of the transport by + design, so a long-lived stdio MCP server served every call with `UTC` / `en-US` + on a workspace whose persisted `localization` settings said otherwise, and never + self-corrected. + + The resolution now happens from a `kernel:bootstrapped` hook — the earliest phase + strictly after the bind, and the one `SettingsService.reportPreBindRead` names as + the remedy — memoized so it stays one resolution for the life of the transport + rather than a per-call settings read. A host that never fires the boot hooks + resolves it lazily at first use instead, so nothing can deadlock on a hook that + never arrives. + + **Behaviour change on a declared setting**: a deployment that has configured + `localization.timezone` / `localization.locale` / `localization.currency` will + see those values take effect on the stdio MCP surface, where it previously + always received the platform defaults. Formula evaluation (`ctx.timezone`) and + message localization on that surface change accordingly. +- 48318f7: The stdio MCP transport no longer freezes a pre-bind localization when a data call races the boot. #11580 moved the localization read onto a `kernel:bootstrapped` hook, but its lazy entry point memoized whatever the FIRST read produced — and the transport goes live inside `MCPServerPlugin.start()`, before the remaining plugins' `start()` bodies and before every `kernel:ready` handler, which is where `SettingsServicePlugin` binds its data engine. A client fast enough to send a data call in that stretch resolved localization pre-bind, received `UTC` / `en-US` from the manifest defaults, and kept them for the life of the process — the narrow race turning into the permanent wrong value #11580 was about. + + The memo is now armed by the bind rather than by the first read: a resolution taken while the settings bind window is still open is answered but not kept, so a call arriving after the bind (even before `kernel:bootstrapped`) already sees the configured value, and the first resolution taken once the window has closed is the one that lives for the life of the transport. #7279's steady state is unchanged — one resolution, never a per-call settings read — because the `kernel:bootstrapped` hook takes that resolution the moment the window closes. A host that never fires the boot hooks (a bare kernel, a test harness) still answers rather than deadlocking, which is why the read is not made to wait for the bind. +- 8649b39: `CONNECT_AGENT_PAGE` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery can see it. It reaches the kernel through `CONNECT_AGENT_UI_BUNDLE.pages`, but was authored as a bare `export const CONNECT_AGENT_PAGE = { … }` with per-field `as const` — the same shape `MarketplaceInstalledPage` shipped in before #11574, and invisible to the export-shape scan the canonical-envelope gates (#11255, #11480) discover their population with. A new repo-wide gate, `check:page-declaration-shape`, now closes the class: every identifier in a bundle's `pages:` array must be declared `export const X: Page =` or through `definePage()` (#11576). +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 6b3c3788e4..6a6175b7e0 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.2.0", + "version": "17.3.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 07d9f5f3ce..4eabd9416f 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,173 @@ # @objectstack/metadata-core +## 17.3.0 + +### Minor Changes + +- 0fd4899: feat(metadata-core,objectql): publish `assertEngineFindOnePredicate` — the read-side member of the engine-double contract family (#11957) + + `ObjectQL.findOne` applies `limit: 1`, so a query naming no particular record + would return an ARBITRARY row. `requireFindOnePredicate` (#4419) REFUSES that + call. Every in-memory test double in the repo instead read an absent filter as + "match everything" and answered happily, so a production call site that violates + #4419 read as *working* under every unit suite and only failed on a real engine. + + That is measured, not hypothetical. `AuthManager.isBootstrapCreation` probed the + bootstrap population with `findOne({ where: [] })` inside a `try/catch`; on a + real engine that throws, the `catch` read the refusal as "users exist", and the + declared first-run bypass became permanently inert on real deployments — while a + 641-line unit matrix over the double stayed green, including a case named + "bootstrap: the very first signup is admitted" (#11767). + + New public API, mirroring the two write-side dispatch predicates + (`assertEngineDeleteDispatch`, `assertEngineUpdateDispatch`) exactly — the + implementation lives in `@objectstack/metadata-core` so that packages + `@objectstack/objectql` itself depends on can reach it, and `@objectstack/objectql` + re-exports every symbol: + + - `assertEngineFindOnePredicate(object, query)` — the line a fake engine's + `findOne` opens with; throws the engine's own message, object name included. + - `resolveEngineFindOnePredicate(object, query)` — the same decision without the + throw, for a double that wants to classify. + - `engineFindOnePredicateRefusalMessage(object)` — the refusal text, so an + assertion pins the producer's wording rather than a paraphrase. + - `ENGINE_FINDONE_PREDICATE_CASES` — the shared conformance case-set, driven + against the REAL engine by + `packages/objectql/src/engine-findone-predicate.test.ts`, so the predicate + cannot drift from `engine.ts` unnoticed. + + Nothing is removed and no existing behaviour changes: the engine's own guard is + untouched, and this publishes the decision it already makes so a double can + import it instead of re-deriving it. +- 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. + + - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. + - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. + - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. + - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. + + The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. +- 5d16379: **BREAKING (accept-set tightening)**: a by-id `update` whose bound truthy scalar payload `data.id` stands beside a DECLARED but non-scalar `options.where.id` — `{ $in: [...] }`, an array, `null` — is now refused loudly (`UPDATE_ID_MISMATCH`, HTTP 400) instead of silently binding the payload row and discarding both the id predicate and any declared `multi: true` (#11230). + + `update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a', 'b'] } }, multi: true })` used to write exactly one row — `rec_1` — with no diagnostic: the payload id outranked `where` and `multi` alike (#5748), so the declared row SET and the declared bulk intent were both dropped, and `rec_1` need not even have been a member of the set. This was the LAST silent member of the dropped-declaration family (#5748 payload operator-objects, #11009 extra `where` keys, #11142 unequal scalar `where.id`); closing it reverses the remaining half of the #5748-pinned verdict `a SCALAR data.id still outranks where and multi`, which the maintainer ruling on #11230 (2026-08-23) authorizes. + + What changes, per call shape (`resolveEngineUpdateDispatch`, so every pinned test double inherits the same verdict): + + - A truthy scalar `data.id` beside a **non-scalar** `where.id` — an operator object, an array, `null`, or an explicitly-`undefined` `id` key — now **throws** `UPDATE_ID_MISMATCH` with `status: 400`, naming the payload id and the KIND of predicate the caller wrote. `multi: true` does not rescue the call (the payload id outranks `multi` per #5748, so the contradiction stands). Previously the write landed on the payload row with both declarations silently ignored. + - Boundaries that do **not** move: a **falsy** scalar `where.id` (`0`, `''`) is a scalar and keeps its #11142 verdict (by-id); a `where` that declares **no** `id` key at all (`{}`, or no `where`) is untouched; and with **no** scalar payload id the ladder is exactly as #5748 left it (`multi` when declared, otherwise `reject`) — the refusal lives only on the payload-sourced by-id arm. + - The refusal shares the #11142 error code deliberately — one ADR-0112 ledger member for one defect class, two messages. No new code is registered. + + A caller hitting the new refusal declared a row address and a row-set predicate in one call and meant one of them; each fix is a one-line edit at the call site: drop `id` from the payload to write EVERY row the predicate selects (`update(object, fields, { where: { id: { $in: [...] } }, multi: true })`), or drop `where.id` to write the single row the payload names (`update(object, { id, ...fields })`). The refusal text names both. Measured before shipping: **no in-repo call site constructs the pair** — every production `where.id` predicate (the outbox sweeps) carries a payload with no `id` — so the in-repo blast radius is nil; an external SDK caller can still write it, and today that silently drops both declarations. + + + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index bfa5047705..d25c7218ce 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.2.0", + "version": "17.3.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 d00aed3c85..31180c10fc 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,14 @@ # @objectstack/metadata-fs +## 17.3.0 + +### Patch Changes + +- Updated dependencies [0fd4899] +- Updated dependencies [1272f0a] +- Updated dependencies [5d16379] + - @objectstack/metadata-core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 2ef7c0d2f1..8f34728c09 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.2.0", + "version": "17.3.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 cdd7182314..502c293f1b 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,346 @@ # @objectstack/metadata-protocol +## 17.3.0 + +### Minor Changes + +- 311433f: feat(spec,metadata-protocol): declare the metadata item-name grammar and refuse it loudly at the publish door (#12194, #12176 stage 1) + + **BREAKING** accept-set narrowing at the metadata write door, shipped as + `minor` under the repo's launch-window convention for breaking changes. + + Metadata item names — the `name` half of the `type`/`name` pair that keys + `sys_metadata` and the `/api/v1/meta` URL space — were entirely unconstrained: + the empty string, `//`, `'Views/All Leads'` and slash-compound spellings + (`views/all_leads`) were all accepted and stored, and a slash in the name + bypassed the unrecognised-metadata-type refusal entirely (`type=fieldz + name='a/b'` was accepted and stored while `type=fieldz name='a'` was 400). + Maintainer ruling 2026-08-25 (#12176): item names must not contain `/`. + + The grammar is now **declared in spec** (`MetadataItemNameSchema` / + `METADATA_ITEM_NAME_PATTERN`, `@objectstack/spec/shared`): lowercase + snake_case segments, optionally dot-qualified — the family + `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` — sourced from the existing + `ViewItemNameSchema` dotted declaration (same segment source, one grammar; the + view-item identity keeps requiring its dot). And it is **enforced at the + publish door** (`saveMetaItem` and `publishMetaItem` in + `@objectstack/metadata-protocol`): an off-grammar name is refused + `400 INVALID_REQUEST` with the grammar and the dotted prescription in the + message, and nothing is persisted. The slash bypass of + `refuseUnmintableMetaType` closes as a consequence. + + **What an author writes instead.** A flat snake_case name (`crm_lead`) and a + dotted qualified name (`crm_lead.pipeline`) both work exactly as before. A + name that spelled a sub-resource with a slash (`views/all_leads`) is + re-authored with a dot qualifier (`crm_lead.pipeline` — the qualified identity + whose prefix recovers the owner) or flattened with an underscore + (`views_all_leads`); containment is expressed by structure, never by a + separator inside the identity string. A translation item conventionally named + after its locale is named in snake_case (`zh_cn`) with the BCP-47 spelling in + its required `locale` field (`"zh-CN"`), which has been the item's real + identity key all along. + + Reads and `deleteMetaItem` deliberately stay open, so any pre-grammar residue + row remains listable and clearable. The in-repo stored corpus was measured at + **zero** slash-bearing item names (#12176 census, re-asserted at land time); + out-of-repo stored slash rows, if any exist, are reported by their deployment's + migrate run rather than rewritten silently. + + +- 8d237b4: Seeds can now address an ActivityPointer (#11339, ADR-0052 §5): a `text` field may declare `referenceVia: ''`, marking it as the id half of a polymorphic pointer pair whose target object the sibling column names per row (`sys_activity.record_id` via `object_name`, `source_id` via `source_object` — both now declared). The seed loader resolves such pointers as natural keys against the object each row names — the same externalId probes, in-memory map and pass-2 deferral static lookup references use — so a packaged app's seed can ship timeline rows that actually attach to their records, and the shipped console filter `{ object_name, record_id }` matches them. + + The accept/reject contract changes with it, deliberately: an unresolvable pointer on a DECLARED pair is now a loud, counted failure (`success: false`, `error`-level log, record dropped when no pass 2 can heal it) instead of the old silent verbatim store — a row that rendered on no timeline and matched no filter. Undeclared text columns are untouched: only `referenceVia` opts a pair in. Authoring contradictions are refused at parse time (`referenceVia` is text-only and mutually exclusive with `reference`) and at `ObjectSchema.create` (the sibling must be a declared field). Internal-id-shaped values still pass through verbatim, so seeds wiring real ids keep working. + +### Patch Changes + +- f93df4d: fix(metadata-protocol): the seed-tenancy backfill stops reporting a duplicate-minting hazard on a zero-organization first boot (#12395) + + The `#8686` split diagnostic guarded on `organizationIds.length !== 1`, which folded + two opposite conditions into one loud warning. With **several** organizations the + owner of an untenanted row is genuinely underdetermined and the warning is right. + With **none** there is no second partition at all: every object runs exactly one + `__global__` counter, so the line's claim that the named objects "run two autonumber + counters and can mint the same `unique` identifier twice" was false precisely when a + fresh install read it. (The `organizationLastValue: 0` it reported alongside is the + split probe's `LEFT JOIN` finding no second row, not a second counter at zero.) + + Zero organizations is now its own state — `no-organization-yet`, named after and + matching the 0 / 1 / several line `objectql`'s `resolveSystemWriteOrganization` + already draws — logged at `info` rather than `warn`. It is not silenced: the split + is still reported, because the observation is real even though the hazard is not. + It self-heals at the first sign-up, when the `sys_organization`-insert handoff runs + the same repair against a settled database. + + Two things this deliberately does not change. An organization probe that **failed** + still takes the loud path and now says so — an unreadable probe returns the same + empty array as a genuine zero, and reading it as "no organizations yet" is the + confusion `objectql` fixed in `#9261`. And the repair threshold is untouched: data + is still modified on exactly `organizationIds.length === 1` and nothing else. + + The affected-object list is also now described as what it is — a snapshot taken when + the probe ran. The probe runs at `kernel:ready`, which a boot can reach while an + over-budget inline seed is still writing in the background, so a first boot can name + fewer objects than the settled database holds. +- d2b2381: **Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095). + + `saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one. + + The maintainer ruled a **split**, and the two halves are not the same fix: + + - **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON. + - **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first. + + For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes. + + The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them. +- 376c70f: fix(metadata-protocol): `getDiscovery()` serves a derived `version`, not the hardcoded `'1.0'` literal (#11235) + + `ObjectStackProtocolImplementation.getDiscovery()` filled `DiscoverySchema`'s "System + Identity" `version` field with the constant `'1.0'`. The other `DiscoverySchema` producer + — `HttpDispatcher.getDiscoveryInfo()` in `@objectstack/runtime` — filled the *same* field + with its own constant `'1.0.0'` until #10993 derived it. Two producers of one field + disagreeing with each other is what proves neither literal was ever a contract value: if + `version` were a contract, two producers would not each invent their own constant; if it + is not, it should not be hardcoded. That argument needs no opinion about what `version` + "should" be. + + It now resolves the same way its sibling does: an injected `OS_RUNTIME_VERSION` build + stamp, falling back to this package's own installed version, and `'unknown'` only if both + are unavailable — honest about not knowing rather than a plausible-looking constant. One + stamp, one meaning: a deployment that sets `OS_RUNTIME_VERSION` now gets the same answer + from both discovery producers and from `GET /health`, so the two can no longer drift. + + The resolver is a package-local ~10-line copy of `packages/runtime/src/runtime-version.ts` + rather than a shared import: `@objectstack/runtime` depends on + `@objectstack/metadata-protocol`, not the reverse, so importing it would invert the + dependency direction, and hoisting a helper into `@objectstack/types`/`@objectstack/core` + would widen two packages' published surface for two call sites (declined at #11235 + triage). `tsup.config.ts` gains `shims: true` for the same reason + `packages/runtime/tsup.config.ts` carries it — esbuild empties `import.meta` in a CJS + bundle, so without the shim `require('@objectstack/metadata-protocol')` would have fallen + through to `'unknown'` on every consumer. + + No schema shape changed, no field was added, and no export was widened — only where one + field's value comes from. Patch, matching the sibling fix. +- 78f65ef: Global search (`searchAll`, the producer behind `GET /api/v1/search`) no longer invents an empty registry. The registry read was `registry?.getAllObjects?.() ?? []`, so a host whose registry does not implement `getAllObjects` — a structural omission that never throws — produced a successful "nothing matched" response with `objectsScanned: 0`. A registry that cannot enumerate its objects is never truthfully "no objects" (ADR-0110 D3): both halves of the swallow are removed and the omission now surfaces as the read's own failure, mapped by the REST door's standard error path. Unchanged neighbours: a registry that enumerates and truthfully answers "no objects" still yields the successful empty response, and a blank query still short-circuits before the registry is consulted. +- ece4dad: Pin the declaration emitter's module specifier for `FormFieldInput` to `@objectstack/spec/ui` (#11350). When #11350 made the three ui/automation input types nameable from `@objectstack/spec`'s root entry, tsc's declaration emitter for this package switched its synthesized reference for `FormFieldInput` from the `/ui` slice to the root entry — both portable, but the root specifier pulls spec's entire root module graph into every downstream TypeScript program that reads this package's declarations (measured: +190k types, +805k instantiations, roughly +560MB on one real program). A local type-only import binding keeps the emitted reference on the narrow `/ui` entry. Type-only and erased at runtime: every emitted JS file is byte-identical; the package's public export surface is unchanged. +- 9a884c6: Seed pass 2 now writes a deferred reference back through the internal id captured at insert time, so a keyless dataset (`mode: 'insert'`, no `externalId` — the honest authoring for an engine-owned object with no natural key) heals an out-of-order reference exactly like a keyed one. Previously pass 2 re-resolved the source row through its `externalId`, so a keyless (or empty-keyed) row's resolved reference was dropped loudly and the load reported `success: false`; the same load now succeeds with the reference healed. The loud drop remains for the one case where it is true — the source row's pass-1 write failed, so there is nothing to write back onto. Unchanged and now measured against the real engine: a deferred column that is `required: true` is still rejected at pass-1 insert (the deferral deletes the column), so such datasets must still seed their target first. +- 1cba33f: fix(metadata-protocol): warn at load time when a seed defers a `required` column, before the engine rejects the row (#11674) + + The seed loader defers an unresolvable reference to pass 2 by **deleting the + column** from the pass-1 row. On a `required: true` column that turns the insert + into one the write contract rejects (ADR-0113), so the row never lands and pass + 2 has no row to back-fill — measured against the real `ObjectQL` engine in + `packages/objectql/src/engine-seed-required-deferral.test.ts`. It is the second, + independent road to the same loud failure that the pass-2 internal-id write-back + could not clear, and it is why a `required` id half stays **order-dependent** + even though a declared pointer pair contributes no static ordering edge. + + Nothing about that failure was quiet — it is a write error naming the column, a + dropped-deferral error, and `success: false`. What was missing is **when** the + author learns: only after the engine rejected the row, from a driver-level + message that does not mention seeding order. The loader now says it first. + + - **Load-time warning, scoped to the required subset.** When a dataset defers a + reference on a column the write contract requires on insert, the loader logs + one `warn` naming the object, the field, the target object that is not seeded + yet, what deferring did to the row, and the fix — order the target dataset + first. Emitted before the row reaches the engine, once per dataset and field + rather than once per row. + - **⛔ The accept set is unchanged.** Nothing is counted, nothing reaches + `result.errors`, `success` is untouched, and every existing loud failure and + its tests are preserved verbatim. Only the log gains a line. + - **The predicate mirrors the write contract instead of approximating it** + (`required && !readonly && !system`, and only on rows headed for an INSERT). + Two configurations were measured on the real engine where a deferral on a + `required` column is accepted today — an upsert replay taking the UPDATE arm + (an omitted column is not a cleared one) and a `readonly` required column + (required-validation skips it on insert) — and the warning correctly stays + silent on both. They are pinned as controls. + + The constraint is also now documented at the four pointer-pair declaration + sites: `sys_approval_request`, `sys_record_share` and `sys_share_link` must seed + the target dataset first; `sys_audit_log` (optional id half) is genuinely + order-independent and says why it differs. +- da1126a: fix(metadata-protocol): the #8686 tenancy backfill writes the merged autonumber high-water mark before it deletes anything (#12394) + + The seed/API tenancy handoff destroyed the counter it was supposed to move. It ran two + independent statements — an `UPDATE` of the organization-scoped `_objectstack_sequences` + row, then an unconditional `DELETE` of the `'__global__'` one — and on a **fresh install** + there is no organization-scoped row yet, because no API create has happened. The `UPDATE` + matched nothing, which is a success on every dialect; the `DELETE` ran regardless; the + counter table was left empty. `SqlDriver.getNextSequenceValue` then re-entered the + `if (!existing)` bootstrap its own docstring reserves for first allocation, re-derived the + counter from `MAX(data)`, and **re-issued a business identifier that had already been + handed out** — measured on 17.1.0: `ACC-000009` on two different records. + + The zero-row case is the *normal* first-boot shape, not an edge case: it is precisely the + shape `buildSplitProbeSql`'s `LEFT JOIN` was widened to catch, so the repair fired on + exactly the installs where its merge loop body never executed. + + The handoff is now one ordered decision per scope: + + 1. **write** the merged mark — `INSERT` when the organization-scoped row is absent, + `UPDATE` when it exists; + 2. **read it back** — "the statement did not throw" was never evidence a row was written, + and an `UPDATE` matching zero rows is exactly the defect above; + 3. **then** retire the `'__global__'` row, addressed by its own stored `key_hash`, so a + retirement can only ever hit the row whose mark was just merged. + + A throw at any step leaves the `'__global__'` row in place — which is the state the next + boot's split probe detects and retries — so a failed repair now loses nothing. + + Per **scope**, because a `{YYYYMMDD}` / `{field}` / per-parent format runs one counter row + per rendered prefix. The old merge was scope-blind in both directions: it could raise every + scope's counter to one merged value, and it deleted every scope's `'__global__'` row. + + The merge rule itself is unchanged and is the 2026-08-15 ruling's: the greater of the two + **counters**, never the data max. That rule is the whole point — a counter is allowed to + sit ahead of its rows (a rolled-back insert burns a number, by design), and that gap is + exactly what the old handoff threw away. + + Graded **patch**: a defect repair inside an existing migration. It adds no export to + `@objectstack/metadata-protocol`'s public index — the new SQL builders are module-scoped + for their own unit tests, matching the index's own recorded rule that an export added so a + test can import a value is the shape to catch before it ships. + + No change to the allocator. Reaching `if (!existing)` is not evidence of lost state — a new + tenant, a new day and a new `{field}` group each reach it legitimately, and a destroyed + counter leaves no row behind to tell the two apart — so a guard there would fire on the hot + path and still not detect this. The repair belongs where the state was destroyed. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [09ae32e] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [12e306a] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [f4e7ae5] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [68c5dba] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [365e334] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [20a452e] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [ba8420b] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [f213793] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/lint@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 205e489483..32277f2774 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.2.0", + "version": "17.3.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 94f9199d0b..e111a5ec72 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,225 @@ # @objectstack/metadata +## 17.3.0 + +### Minor Changes + +- f4e7ae5: fix(security): gate the metadata HMR door on an explicit development posture (#12140) + + **BREAKING** surface narrowing — `GET`/`POST /api/v1/dev/metadata-events` are no + longer mounted unless `NODE_ENV` is exactly `development`. Shipped as `minor` + under the repo's launch-window convention for breaking changes. + + `MetadataPlugin` mounted both HMR routes whenever a raw-app-capable HTTP server + was present, with no authentication and no environment condition of its own. The + dev-only posture the path (`/api/v1/dev/…`) advertises lived only in prose — + "production deployments simply won't have a CLI POSTing to this endpoint" — which + is a claim about who is on the network, not a gate that stops them, and the same + structural shape #9391 closed for the `datasource-admin` family: both mounts take + the host's framework-native app handle, which is outside REST's `enforceAuth` + seam by construction. + + The distributions were enumerated rather than assumed, because "a dev-only + surface lacking a gate that says so" and "an unauthenticated door on a real + deployment" want different repairs. The official container image runs `os start` + under `NODE_ENV=production`; that boot reaches `createStandaloneStack`, which + composes `MetadataPlugin` unconditionally (only `artifactWatch` was + NODE_ENV-gated) onto a kernel that registers the Hono server whenever it serves. + So a production-shaped boot did mount both routes and did answer them — `POST` + re-reading the compiled artifact from disk and broadcasting a reload frame to + every connected client, unauthenticated. + + `registerMetadataHmrRoutes` now refuses at its first statement, ahead of every + side effect it performs, and returns `null` so "nothing was mounted" is a fact + the compiler forces its caller to handle. Unset `NODE_ENV` is closed, per the + maintainer's 2026-08-06 ruling that an absent value reads as `production`; `test` + and unrecognised spellings (`staging`, `preview`, `qa`) are closed too — a gate + must not treat a spelling nobody recognises as a key. + + Who is affected, in both directions: + + - `os dev` is unchanged. It spawns `os serve --dev`, which sets + `NODE_ENV='development'` before any plugin starts, so the watch-recompile loop + still gets its `200` and Studio still gets its reload frames. + - A deployment that was reaching this endpoint on a production-posture boot now + gets its host app's `404`. That door was never advertised, never in the SDK + (`@objectstack/client` builds no such URL) and is a build-tool loopback; if you + need a supported production reload trigger, that is a product decision, not + this endpoint. + - The server-side artifact-file watcher is untouched on every boot shape that had + it: the reload still happens, only the broadcast to (now absent) SSE clients is + skipped. + + + +### Patch Changes + +- e7f56d6: Make the settings engine facade and the metadata database loader bind the row + they resolved, not a row the payload names + + Two ingresses resolved an authoritative row id and then folded it into the + write payload with the **losing** spread order — `{ id, ...data }` — so a + caller-supplied `data.id` spread over the id the ingress had just resolved and + silently retargeted the write: + + - `wrapEngineAsSettingsEngine`'s by-id `update` branch + (`@objectstack/service-settings`), whose id comes from the caller's + `where.id`. + - `DatabaseLoader._update` (`@objectstack/metadata`), whose id arrives as a + separate parameter every caller resolves first (`existing.id`, from the read + immediately above). + + Both now spell it `{ ...data, id }` — the operation's id **after** the spread, + so it wins. That is the convention the repo's other two ingresses already + document: `rest-server.ts`'s batch update arm ("the operation's id AFTER the + spread, so it wins") and `protocol.updateData`'s #6479 fix + (`{ ...request.data, id: request.id }`). + + **No wrong write is known to have been reachable.** Both sites' current callers + build fresh field literals and never put an `id` inside `data`, so this is + hardening a fragile pattern rather than repairing a measured defect. What makes + it worth the three characters is that neither site can be caught downstream: + both pass **no `where`** to the engine, so the payload is the only id the engine + ever sees, and the engine's conflicting-id refusal (`UPDATE_ID_MISMATCH`, 400) + needs two disagreeing declarations before it can fire. The fold is the entire + trust boundary at both sites, and it is one refactor — a caller handing back a + row copy, and rows carry `id` — from the #6479 shape. + + Both are pinned with a payload whose `id` names a **different** row than the + one the ingress resolved, asserting the resolved row is still the row bound. A + pin exercising a payload without an `id` would have passed against both + spellings. The doubles answer "which row does this bind?" with the producer's + own `assertEngineUpdateDispatch`, so they cannot be kinder about it than a + running server. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/metadata-fs@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index b433431d38..cb313c0932 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.2.0", + "version": "17.3.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 ccc644d750..2ac143656b 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,670 @@ # @objectstack/objectql +## 17.3.0 + +### Minor Changes + +- 0010797: Cross-schema foreign keys are now qualified instead of shipping an unusable bare name (#11377). + + `IntrospectedForeignKey` (driver-sql) gains an optional `referencedSchema`, present when — and + only when — the referenced parent table lives outside the introspecting session's resolution + scope (Postgres: the parent's schema is not on `current_schemas(false)`; MySQL: the parent's + database differs from `DATABASE()`; SQLite never sets it — no schemas, and a foreign key cannot + cross an ATTACHed database). `referencedTable` stays a bare name always — the qualification is a + separate key, never a conditional spelling. + + `convertIntrospectedSchemaToObjects` (objectql) reads the new key: a foreign key whose target + carries `referencedSchema` is loudly skipped and flagged through the new `options.logger` + (default `console`) instead of being wired to the bare name — which either resolved to nothing + or to a same-named table in the current schema, silently. The column is kept as a plain field so + its data stays visible. Foreign keys with in-scope targets keep producing identical lookup + fields. +- 0fd4899: feat(metadata-core,objectql): publish `assertEngineFindOnePredicate` — the read-side member of the engine-double contract family (#11957) + + `ObjectQL.findOne` applies `limit: 1`, so a query naming no particular record + would return an ARBITRARY row. `requireFindOnePredicate` (#4419) REFUSES that + call. Every in-memory test double in the repo instead read an absent filter as + "match everything" and answered happily, so a production call site that violates + #4419 read as *working* under every unit suite and only failed on a real engine. + + That is measured, not hypothetical. `AuthManager.isBootstrapCreation` probed the + bootstrap population with `findOne({ where: [] })` inside a `try/catch`; on a + real engine that throws, the `catch` read the refusal as "users exist", and the + declared first-run bypass became permanently inert on real deployments — while a + 641-line unit matrix over the double stayed green, including a case named + "bootstrap: the very first signup is admitted" (#11767). + + New public API, mirroring the two write-side dispatch predicates + (`assertEngineDeleteDispatch`, `assertEngineUpdateDispatch`) exactly — the + implementation lives in `@objectstack/metadata-core` so that packages + `@objectstack/objectql` itself depends on can reach it, and `@objectstack/objectql` + re-exports every symbol: + + - `assertEngineFindOnePredicate(object, query)` — the line a fake engine's + `findOne` opens with; throws the engine's own message, object name included. + - `resolveEngineFindOnePredicate(object, query)` — the same decision without the + throw, for a double that wants to classify. + - `engineFindOnePredicateRefusalMessage(object)` — the refusal text, so an + assertion pins the producer's wording rather than a paraphrase. + - `ENGINE_FINDONE_PREDICATE_CASES` — the shared conformance case-set, driven + against the REAL engine by + `packages/objectql/src/engine-findone-predicate.test.ts`, so the predicate + cannot drift from `engine.ts` unnoticed. + + Nothing is removed and no existing behaviour changes: the engine's own guard is + untouched, and this publishes the decision it already makes so a double can + import it instead of re-deriving it. +- c3c72a4: Record file-field hydration now answers the same question about a `sys_file` tombstone that the download path answers (#11427). `#10246` stopped `GET /api/v1/storage/files/:id` treating a tombstone (`status: 'deleted'` + `deleted_at`) as the last word — it asks the reap guard's own `findFileHolder` and serves the row for as long as something still holds it — but the record read kept the older `status === 'committed'` rule. One `sys_file` row therefore answered `200` at the download endpoint and a bare id inside a record payload, which UI and export render as "this record has no attachment". + + The population is narrow and unchanged in every other respect: `claimFile` already un-tombstones a field file synchronously when a record re-points at it, and attachments-scope files are never reached by field hydration, so what this closes is the residual the reap guard's sweep-time re-verification names — hook races, direct-driver writes, and future trash restore. A tombstone nothing holds still hydrates as a bare id, and a `pending` upload is untouched. + + The predicate is not re-derived in the engine. `ObjectQL` gains `registerHeldFileResolver` (type `HeldFileResolver`), which the storage plugin fills with `findHeldFiles` — the batched form of `findFileHolder`, asking the same union of `sys_attachment` join rows and the `ref_*` ownership columns. Batched because hydration runs over many rows per read: a read with no tombstone costs nothing, and the residual case costs one extra query for the whole read rather than one per file. Engines with no storage plugin keep tombstones un-hydrated exactly as before. +- 8542bd4: **Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). + + A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. + + What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. + + ```js + // this record — nameField, formula or stored column alike + await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); + // a related record, through the lookup column that holds its id + const account = await ctx.title('account_id'); + ``` + + **Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. + + **Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. + + **It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. + + Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. +- 2af5eac: fix(objectql,runtime): `delete ctx.input.x` in a hook actually removes the field (#12277) + + A hook that stripped a field from its input with `delete` did nothing, on BOTH + execution paths, while an assignment made two lines above it on the same object + in the same call landed normally. Nothing raised, and nothing in the platform + reported it. + + Graded `minor` rather than `patch` deliberately: it moves data that reaches + downstream consumers. Any shipped hook that already contains + `delete ctx.input.` has been a no-op until now and starts taking effect + on upgrade — which is the point, and is also exactly why it must not arrive as + a silent patch. No API is removed and no accept set narrows. + + ### The two mechanisms, which were unrelated and produced one outcome + + **In-process (`installFlatInput`, `packages/objectql/src/hook-wrappers.ts`).** + The flat-record `Proxy` a declarative hook receives over the engine's + `{ data, options, id? }` wrapper trapped `get` / `set` / `has` / `ownKeys` / + `getOwnPropertyDescriptor` — but not `deleteProperty`. The delete therefore fell + through to `Reflect.deleteProperty` on the WRAPPER, one level above the record, + removing a key that was never there and returning `true`. `set` was trapped and + wrote into `data`, which is what the engine persists; hence assignment survived + and deletion evaporated. + + **Sandboxed (`applyMutationsToInput`, + `packages/runtime/src/sandbox/body-runner.ts`).** A QuickJS body's mutations + were written home with `Object.assign(target, result.mutatedInput)`. + `Object.assign` copies own enumerable properties and **has no way to represent a + removal**: a key the VM deleted is simply not in the snapshot, and the host's + key stayed. Deletions are now diffed against the entry snapshot and applied + separately. + + Both are fixed in one change on purpose. Closing either alone would make the + same authored `delete` behave differently depending on whether the hook body + runs in-process or in the sandbox — a worse contract than the symmetric silence + it replaced. + + ### What an author could see, before and after + + The sandboxed path is the one with no tell at all. Measured on the pre-fix code, + one hook call, host row alongside: + + ``` + delete ctx.input.internal_notes -> true + 'internal_notes' in ctx.input -> false <- the VM agrees + Object.keys(ctx.input) -> ['subject'] <- ...and so does this + host ctx.input after write-back -> { subject: 'HELP', + internal_notes: 'STAFF-ONLY' } + ``` + + The in-process path was less deceptive than reported, and the correction is + worth having in writing: only `delete`'s own return value lied there. `'k' in + input`, `input.k` and `Object.keys(input)` all went on honestly reporting the key + as present, so an author who checked with anything other than the return value + would have seen the no-op. + + ### `Object.defineProperty(ctx.input, …)` was the same gap, and nobody reported it + + Found while enumerating the trap set, fixed in the same stroke because it is the + strictly worse shape: it defined on the wrapper, and the `get` trap's + fall-through then read the value straight back — so `input.k` CONFIRMED a write + that never reached `data`, while `Object.keys(input)` denied it and the record + never received it. It now routes into `data` like `set` and `deleteProperty` do. + One inherited JS invariant follows: a proxy may not report success for an + explicitly `configurable: false` descriptor its target does not carry, so + `Object.defineProperty(input, 'x', { value: 1, configurable: false })` now throws + a `TypeError` where it used to define, silently and uselessly, on the wrapper. + Omitting `configurable` — the common spelling, and the one spread and + `Object.assign` produce — is unaffected. + + ### The direction the sandbox write-back deliberately does not overreach in + + Absence from the exit snapshot is the only evidence a deletion leaves, and on its + own it is ambiguous: a key whose host value is `undefined` (or a function, or a + symbol) never survived `JSON.stringify` INTO the VM either, so it is missing from + the dump without anyone having deleted it. The diff is filtered through the same + JSON lens the boundary uses, so such a key is left alone. Every failure mode of + that probe is conservative — an unprobeable key is simply not deletable — because + losing a delete is recoverable and destroying a field on evidence that was never + there is not. One residual miss follows and is named here rather than discovered + later: a `bigint`-valued key crosses into the VM as a string but is dropped by the + probe, so deleting one is still lost. + + Measured consumer cost of the reported half: a guest-intake app stripped the + fields an anonymous web-to-case / web-to-lead submitter must not write — + internal staff notes, the resolution, the escalation flag, the owner — with + fifteen `delete` statements, every one inert. A submission carrying + `internal_notes` and `resolution` stored them verbatim, and the app's unit tests + stayed green throughout, because they drive the handler with a plain object where + `delete` genuinely works. +- af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + + Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling + (2026-08-26, verbatim and untranslated: 「同意」). + + ## The registration follows the declaration (#12359) + + `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but + the only thing that REGISTERED it was the automation service's manifest — + because flows were the ledger's first and, until packaged actions landed, only + consumer. Packaged actions are a second consumer with a different owner: their + consult and write path live on the ObjectQL engine, present in every + composition that can execute an action. + + So a deployment with actions and no automation service had no ledger table, and + the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — + correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be + reported as one) and permanently. Measured on a real boot; it is now this + change's positive test, measured on the same boot: + + ``` + POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED + ``` + + `PlatformObjectsPlugin` registers it now, so every composition carrying + platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, + `skill`, `position`) inherits it. **MOVE, not add** — the automation service no + longer names the object. That was not a style choice: a second code package + claiming one object throws `Object "…" is already owned by package "…"` + (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot + failure rather than a duplicate. + + **Upgrade is a no-op for existing data, and that is measured rather than + asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` + step 4 routes an object by its owning package's `defaultDatasource` — so the + registrar carries the table's datasource with it: + + ``` + owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' + owner com.objectstack.platform-objects (none) -> undefined (global default driver) + ``` + + The ledger table already exists in live databases, so on any deployment + carrying a `cloud` datasource that difference would leave the rows in one + database and read another — every disabled artifact silently re-arming. The + ledger therefore rides its own manifest from the same plugin, carrying the + automation manifest's `scope` / `namespace` / `defaultDatasource` triple + verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, + `sys_secret`) deliberately do not get it and keep riding the project database. + + ## One implementation of the §4 row contract (#12350) + + ADR-0126 §4 declares one activation ledger; it had two independent + implementations of that one row contract — `ObjectStoreFlowActivationStore` + (service-automation) and `ObjectStoreActionActivationStore` (objectql). They + agreed because the second was written from the first, and nothing structurally + held them together; §8 pre-charts `tool`, `skill` and `position`, and a third + and fourth copy is where the org-row skip and the `0`-is-false read get lost + quietly, in the direction (an artifact re-arming) nothing else measures. + + Neither consumer could import the other, so the contract now lives once in + `@objectstack/core` — the package both already depend on — as + `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside + `InMemoryMetadataActivationStore`, `MetadataActivationRow`, + `MetadataActivationStore`, `MetadataActivationStoreEngine` and + `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own + one-argument constructor and its own docs, and fixes the discriminator. + + **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / + `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and + `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / + `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` + / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same + shapes. Row semantics are byte-equivalent: install-level rows only + (`organization_id` never written), org-carrying rows skipped on read and + ignored when deciding insert-vs-update, a driver `0` read as false, + read-then-write rather than a blind upsert, and no `delete` in the engine slice + because re-enabling rewrites the row. + + Both existing pin suites stay green **unchanged**, which is what makes them the + proof the consolidation lost nothing — verified by ablation: removing the + org-row skip from the one shared implementation turns both of them red on their + own org-skip assertion, so both really reach it. +- e5ce2ed: Packaged actions can be switched off, on the same activation ledger as flows + + A packaged action can now be disabled for an installation, generalizing the + packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on + the maintainer's amendment ruling 3). The flip writes an install-level row to + the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — + no new table, no new column, no schema change of any kind. Absence of a row + means the packaged default, active, so a deployment that never flips anything + behaves exactly as before, and an empty ledger changes nothing anywhere. + + The consult point is action DISPATCH, and it is present on every door that + dispatches a declared action: the REST `POST /actions/:object/:action` route and + the MCP `run_action` bridge. Both call one shared guard, and a disabled action + is refused `409 ACTION_DISABLED` before anything runs — before the handler body + (which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action + reaches the automation engine, before the param contract is enforced and before + the subject record is read. The refusal names the ledger and the remedies. The + code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and + answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, + which would tell an operator to go looking for a flow that does not exist. + + The consult reads a projection the ObjectQL engine holds and hydrates at boot, + so a disabled action stays disabled across a restart and across the handler + re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — + the ledger records the customer's choice, and nothing re-arms it silently). + + The write door is `POST /actions/_activation/:object/:action` with a + `{ enabled?: boolean }` body. Its first segment is reserved rather than deep in + the path because a machine name can never begin with `_`, so it cannot collide + with an object, an action or a record id. It carries the same two authority + tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture + rule — in the `group` and `isolated` postures the install-wide switch requires + the platform operator, while `single`, where install-level and org-level are the + same scope, is unchanged. That gate is now one implementation shared with + `POST /automation/:name/toggle`; the flow refusal text is unchanged. + + Two refusals are worth knowing about. The ledger addresses an action by its + machine name, so a name declared on more than one object is refused with + `409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off + silently. And a flip that cannot be made durable — no ledger table reachable — + is answered as a failure instead of a 200, because a switch reported as durable + that reverts on the next restart is the failure this whole family exists to + remove. + + Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so + disable is the only primitive here and authoring a new sibling action stays + exactly as it is today. +- 9086761: `FieldSchema` now rejects an authored `deleteBehavior: 'set_null'` on a `master_detail` field at parse time (#9689). The engine has always resolved every value except `restrict` on that type to `cascade`, so the declaration asked for the child rows to be kept and got them deleted — silently, at the moment the parent went away. The rejection names the outcome and both legal re-declarations (`restrict` refuses the parent delete while children exist — no data loss; `cascade`, or omitting the key, accepts the cascade deliberately; a `lookup` is the type to use when children must survive the parent). + + Mechanism (the #7918 Option A shape, plus the 2026-08-24 idempotent-materialization ruling): the property-level `.default('set_null')` moved off `deleteBehavior` into a post-check `.overwrite()`, so the schema can tell an authored `set_null` from a defaulted one — and the `.overwrite()` never materializes a default the schema itself would refuse as authored. A bare `master_detail` now parses to output that OMITS `deleteBehavior` (previously the baked `set_null` was indistinguishable from an authored one by design, so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse — every app build with a bare `master_detail` failed). Built app artifacts stop carrying a value the schema itself refuses; the engine treats absent exactly as it treated the baked value (both cascade — measured, behavior unchanged). Every other field type keeps byte-identical output — non-reference types still carry the default at its shape position, and `set_null` on `lookup` stays legal. The inferred `Field` output type now declares `deleteBehavior` as optional (the same accepted cost as the currency `precision` relocation); at runtime a parsed field carries it on every type except `master_detail`, where absence is the honest spelling. + + There is deliberately no automatic conversion (`field-master-detail-set-null-refused` in the migration registry): only the author knows whether they meant `restrict` (keep-my-children, as a refusal) or `cascade`. Stored rows carrying the refused combination keep loading and serving — registry validation is a diagnostic, not a gate — and are refused on their next authoring-path save. + + `@objectstack/objectql`: the engine behavior is unchanged (an authored `set_null` on `master_detail` still cascades — the #9625 pin holds), but the coercion site now logs loudly (`error`, falling back to `warn`) when the combination reaches it via a raw registration or a pre-tightening stored row — the two populations parse-time rejection cannot catch. +- a11c1a5: `signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: + + - **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. + - **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). + - **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. + + Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. +- 5d16379: **BREAKING (accept-set tightening)**: a by-id `update` whose bound truthy scalar payload `data.id` stands beside a DECLARED but non-scalar `options.where.id` — `{ $in: [...] }`, an array, `null` — is now refused loudly (`UPDATE_ID_MISMATCH`, HTTP 400) instead of silently binding the payload row and discarding both the id predicate and any declared `multi: true` (#11230). + + `update(obj, { id: 'rec_1', title: 'x' }, { where: { id: { $in: ['a', 'b'] } }, multi: true })` used to write exactly one row — `rec_1` — with no diagnostic: the payload id outranked `where` and `multi` alike (#5748), so the declared row SET and the declared bulk intent were both dropped, and `rec_1` need not even have been a member of the set. This was the LAST silent member of the dropped-declaration family (#5748 payload operator-objects, #11009 extra `where` keys, #11142 unequal scalar `where.id`); closing it reverses the remaining half of the #5748-pinned verdict `a SCALAR data.id still outranks where and multi`, which the maintainer ruling on #11230 (2026-08-23) authorizes. + + What changes, per call shape (`resolveEngineUpdateDispatch`, so every pinned test double inherits the same verdict): + + - A truthy scalar `data.id` beside a **non-scalar** `where.id` — an operator object, an array, `null`, or an explicitly-`undefined` `id` key — now **throws** `UPDATE_ID_MISMATCH` with `status: 400`, naming the payload id and the KIND of predicate the caller wrote. `multi: true` does not rescue the call (the payload id outranks `multi` per #5748, so the contradiction stands). Previously the write landed on the payload row with both declarations silently ignored. + - Boundaries that do **not** move: a **falsy** scalar `where.id` (`0`, `''`) is a scalar and keeps its #11142 verdict (by-id); a `where` that declares **no** `id` key at all (`{}`, or no `where`) is untouched; and with **no** scalar payload id the ladder is exactly as #5748 left it (`multi` when declared, otherwise `reject`) — the refusal lives only on the payload-sourced by-id arm. + - The refusal shares the #11142 error code deliberately — one ADR-0112 ledger member for one defect class, two messages. No new code is registered. + + A caller hitting the new refusal declared a row address and a row-set predicate in one call and meant one of them; each fix is a one-line edit at the call site: drop `id` from the payload to write EVERY row the predicate selects (`update(object, fields, { where: { id: { $in: [...] } }, multi: true })`), or drop `where.id` to write the single row the payload names (`update(object, { id, ...fields })`). The refusal text names both. Measured before shipping: **no in-repo call site constructs the pair** — every production `where.id` predicate (the outbox sweeps) carries a payload with no `id` — so the in-repo blast radius is nil; an external SDK caller can still write it, and today that silently drops both declarations. + + + +### Patch Changes + +- 30d96ab: `ObjectQL.introspectDatasource()` declares its real return type — the spec's `IntrospectedSchema` (the new `IDataEngine.introspectDatasource?` contract member) — instead of an untyped `Promise`, and the driver lookup inside it drops its `as any` now that `IDataDriver` declares `introspectSchema?`. Type-level only; runtime behaviour is byte-identical (#11493). +- 7bd6447: fix(objectql): the flat-input Proxy mirrors `data`'s own descriptor instead of synthesising one (#12397) + + `installFlatInput` hands a declarative hook a flat-record Proxy over the + engine's `{ data, options, id? }` wrapper. Its `getOwnPropertyDescriptor` trap + answered every key `data` carries with one fixed literal — + `{ configurable: true, enumerable: true, writable: true, value: data[prop] }` — + and never read `data`'s real descriptor. For a key created by ordinary + assignment that synthesis is the truth, which is why it cost nothing for as + long as assignment was the only way a key could arrive. + + #12277 routed `defineProperty` into `data`, so a hook can now put a key on the + record payload with non-default attributes for the first time, and the + synthesis reported the defaults back regardless: + + ```js + Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true }); + Object.getOwnPropertyDescriptor(ctx.input, 'k'); // reported enumerable: true — it is not + Object.keys(ctx.input); // …while this correctly omitted 'k' + ``` + + Two instruments over one payload, contradicting each other. The trap now + mirrors `data`'s own descriptor. + + `configurable` is the one attribute that cannot be mirrored: the proxy target + is the wrapper, which does not carry the record key, and a proxy may not report + a property its target lacks as non-configurable — a verbatim mirror throws + `TypeError` on any key `data` holds as `configurable: false`, and takes + `Object.keys` and spread down with it, since both reach every listed key + through this trap. It is forced `true`; `enumerable` / `writable` are mirrored. + + Two further observable consequences, both pinned: + + - Reading a descriptor no longer runs author code. The synthesis evaluated + `data[prop]` to fill `value`, so asking a payload that holds an accessor for + its descriptor invoked the getter; a mirror copies `get`/`set` across + untouched. + - `prop in data` is true for the whole prototype chain, so the synthesis + answered for inherited keys too — `Object.getOwnPropertyDescriptor(input, + 'toString')` returned an own, enumerable, writable data property no payload + has ever held, and `Object.hasOwn(input, 'toString')` was `true`. Only an own + key has a descriptor to mirror; inherited keys now report `undefined`, while + `'toString' in input` and the read itself are unchanged. + + Enumeration is untouched: `ownKeys` still lists exactly `data`'s own enumerable + keys and the mirror reports those as enumerable, so `Object.keys`, spread, + `Object.entries` and the sandbox's `unwrapProxyToPlain` see byte-identical + results. What a record payload may hold, how `defineProperty` routes into + `data`, and how the engine persists it are all untouched. +- 5a22dd7: fix(objectql): the flat-input proxy's `ownKeys` reports the payload's own key set, not its enumerable subset (#12578) + + `installFlatInput` answered the `ownKeys` trap from `Object.keys(data)` — own **enumerable + string** keys. That filtering was incidental to what the trap is for (hiding the wrapper keys + `id`/`options`/`ast`/`data` from `Object.keys`/`for…in`), and it cost a key: an own + **non-enumerable** key on the record payload was absent from `Object.getOwnPropertyNames(input)` + and `Reflect.ownKeys(input)` while `hasOwnProperty` and the descriptor trap both reported it — + and while the engine persisted the row holding it. Measured on the merged ref, for a payload + `{ subject }` a handler had added `k` to with + `Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true })`: + + ``` + Object.getOwnPropertyDescriptor(input, 'k') -> own, enumerable:false + Object.prototype.hasOwnProperty.call(input, 'k') -> true + Object.getOwnPropertyNames(input) -> ['subject'] <- not own? + Object.getOwnPropertyNames(persisted row) -> ['subject', 'k'] + ``` + + Three instruments, one payload, two answers about own-ness. Newly reachable rather than newly + written: #12277 routed `defineProperty` into the payload, so a handler can put a + non-default-attribute key there for the first time, and #12397 made the descriptor trap mirror + the payload instead of synthesising defaults — which is what gave the third instrument an + opinion to disagree with. + + The trap now reports `Object.getOwnPropertyNames(data)`. **The enumerable face is unchanged**: + `Object.keys`, spread, `Object.entries`, `for…in` and `JSON.stringify` still omit a + non-enumerable key, because each applies the `enumerable` filter itself, one layer up, through + the descriptor trap. Applying it inside `[[OwnPropertyKeys]]` as well did not make those answers + cleaner — it only starved the two surfaces whose entire job is to report the whole set. The + sandbox body face is byte-identical for the same reason: `unwrapProxyToPlain` + (`@objectstack/runtime`) snapshots `ctx.input` as `Object.entries` over this proxy. + + Wrapper keys stay excluded, which is the trap's purpose — achieved by reading `data` and never + the wrapper, not by subtracting those four names, which would hide a genuine payload field named + `id`. **Symbol keys remain unenumerated**: they already reach the payload and already persist, so + publishing them through `ownKeys` is a question about what a record payload may hold rather than + about this trap, and it is left open on #12578 rather than decided here. + + Pinned in `hook-input-ownkeys-agreement.test.ts` as the AGREEMENT of the three own-ness + instruments — not as one trap's output, which is the pin shape that let the halves diverge — with + the wrapper-key and symbol exceptions pinned as deliberate exceptions. Reverse-verified by + ablation: restoring `Object.keys(target.data)` fails exactly 2 of the 6 new cases (32 of 34 green + across the four hook-input suites), and the enumerable-face assertions stay green under the + mutation, which is what proves that half untouched. +- d395692: Withdraw the never-honored `IntrospectedTable.indexes` promise and widen two + introspection declarations to the measured emitted types (#11122, maintainer + ruling 2026-08-23, option B — 「其他同意你的意见」). + + The spec's introspection contract (`schema-diff-service.ts`) declared + `indexes: IntrospectedIndex[]` as REQUIRED, yet no producer has ever emitted + it — a consumer typed against the promise read `undefined` with no compiler + complaint. It also declared `defaultValue?: string` while the in-tree SQL + driver passes `knex.columnInfo().defaultValue` through raw (measured on live + SQLite: `null` for a column with no default, dialect-quoted strings such as + `'abc'` otherwise; other producers report native values such as `true`). + + - `IntrospectedTable.indexes` is now **optional**, and absence is meaningful: + an absent key means the producer did not read indexes; an empty array is a + positive claim the table HAS none. Producers that did not look must omit + the key rather than emit `[]`. Wiring the index read into + `introspectSchema()` is explicitly NOT part of this change. + - `IntrospectedColumn.defaultValue` is now `unknown` — consumers narrow + before use instead of trusting a string promise no producer kept. + - The SQL layer's extra `maxLength` fact (driver-sql / objectql + `IntrospectedColumn`, driver-sql `PhysicalColumn`) widens from `number` to + `number | string` — SQLite reports the string `"255"` where other dialects + report a number. + + With the spec now telling the truth, the deliberate `Omit` workarounds in + `@objectstack/driver-sql` and `@objectstack/objectql` (which carved + `defaultValue` and `indexes` out of the spec types to keep the divergence + visible) are retired: both packages' introspection types now extend the spec + contract directly. + + Consumers that read `table.indexes` must guard for absence (none exist + in-tree — the requirement was never honored, so today's readers would have + crashed on `undefined` anyway); consumers of `defaultValue` must narrow from + `unknown` before string operations. +- c5b9ccc: **Fix:** the engine's three privileged driver-level reads now JOIN an open ambient transaction instead of asking the connection pool for a second connection — which deadlocked `pool max=1` datasources and made `/admin/remove-user` refuse an entitled, signed-in caller with `401 UNAUTHENTICATED` (#10792). + + `resolveSecret`, `resolveSecretField` and `resolveInternalField` read at DRIVER level on purpose: that is the only layer where a masked or `internal: true`-omitted value still exists, and bypassing hooks, field-level security and sharing is the declared trust each of them places in its in-process caller. What they also bypassed — not by design — was the connection the surrounding transaction is holding. `buildDriverOptions` threads the ambient handle (ADR-0034) onto every ordinary read for exactly this reason; these three passed the driver **no options at all**, so their read went to a *fresh* pooled connection. + + On a roomy pool that is invisible: the pool simply hands out another connection. On a single-connection pool it is a deadlock. SQLite's knex pool is `max: 1` — `driver-sqlite-wasm` and `driver-sql`/better-sqlite3 both — and `pool max=1` is not a tuning choice there, it encodes SQLite's single-writer model. + + Measured on the erasure path, which is where the two met. `AuthManager.handleRequest` runs the `SESSION_ERASURE_PATHS` routes inside `engine.transaction(...)` so a refused erasure cannot leave the session and account deletes committed. Inside that transaction the vendor's session re-read reaches `resolveInternalField` through plugin-auth's internal-field readback; the read waited for a connection that could not be freed until the transaction waiting on the read finished, knex's acquire timeout fired (`Timeout acquiring a connection. The pool is probably full`), and the route degraded the block into an authentication refusal. On the default `objectstack dev` datasource, before this change: a caller better-auth's own admin gate **admits** was answered `401` after **120,196 ms** with the target row still present, and a signed-in plain member got the same `401` after **120,025 ms** instead of the `403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS` an authorization refusal owes them. After: `200` with the row deleted, `403`, and an anonymous caller's `401` unchanged — all promptly. Postgres and MySQL (`max >= 10`) always conformed and are unaffected; the reach nonetheless mattered because SQLite is the default datasource for `objectstack dev`, the showcase/dogfood boot, and any self-host that has not configured Postgres or MySQL. + + Two properties are deliberately **not** widened. The join is reads-only — the privileged write paths are untouched. And the #5351 same-origin gate still decides whether the handle is this object's driver's to use, so a privileged read that resolves to a *different* datasource keeps its own connection rather than executing someone else's statement on the wrong one. +- fe72aa5: fix(objectql): the `[Registry] Collision` warning fires in the cold-boot order too (#12027) + + The artifact-vs-DB collision warning was order-asymmetric, and silent in the + order a kernel boot actually produces. It was guarded on `packageId &&`, so it + spoke only when the PACKAGE registered second — but the artifact reaches the + registry in kernel Phase 1 (`AppPlugin.init` -> `manifest.register`) and the + `sys_metadata` overlay is rehydrated in Phase 2 (`ObjectQLPlugin.start` -> + `loadMetaFromDb`), under the bare name with no package id. The kernel runs + init-all then start-all, so at boot the overlay is ALWAYS the second arrival — + the exact order the guard excluded. The direction that did warn is the + late-registration one: a marketplace install, a post-`start()` + `manifest.register`, an HMR reload. + + The consequence is worse than a missing line, because the mechanism looked + sound to anyone who had seen it work: ADR-0005 says this warning is what makes + the silent shadowing "discoverable in startup logs", and in the only order + startup produces it was not discoverable at all. Measured on a real + `@objectstack/example-crm` boot before the fix: one stored `view` overlay of a + packaged view produced 0 collision lines and 4 silent shadowings (the container + plus its three expanded ViewItems). + + The cold-boot direction now warns with its own message rather than a widened + version of the existing one. Both orders end in the same state — the runtime + row wins either way — but the event differs, and the event is what an operator + acts on: a package that is dead on arrival behind a row that predates it, + versus a stored row taking over a definition this process just loaded from + code. Which definition wins is unchanged in both orders, and pinned as such. + + Graded `patch`: this adds a diagnostic to a path that printed nothing. No API + changes, no accept/reject behaviour changes, and resolution order is untouched. + The one operator-visible effect worth stating is the log itself — a deployment + that customizes packaged metadata will see one new `[Registry] Collision` line + per shadowed name per process, where it previously saw none. Volume was + measured rather than assumed: 0 lines on a stock boot (a stock `sys_metadata` + holds no overlay of a packaged name), and the line marks the transition into + the bare slot rather than the state, so the read-side hydration and the + write-through do not re-emit it on later reads and writes. +- aa0688a: Arm a deterministic flow when a runtime-authored flow reuses a packaged flow's name + + A runtime-authored flow that reused a packaged flow's name silently replaced it, + and which of the two ended up armed depended on registration order. The metadata + registry keys items `packageId:name` and deliberately coexists both (ADR-0048 + §3.4), `listItems('flow')` returns both with no precedence, and the automation + engine keys flows by bare name — so the boot pull registered both under one key + and Map iteration order picked the survivor. Measured: registering the package + first armed the runtime flow, registering the runtime row first armed the + packaged flow, with no warning and no way to tell which had won. + + The boot pull now collapses same-named definitions before anything is armed, + applying the ADR-0005 overlay precedence ADR-0048 §3.4 routes this case to: the + runtime/DB overlay wins over the packaged artifact, which is the sanctioned + override path. Two packages shipping one bare name resolve by package id, so + boot order no longer decides anything. + + Collisions are no longer silent. The pull warns once per colliding name — naming + the name, every contender, and which one is armed — and repeats it at bootstrap + beside the other automation audits. `getShadowedFlows()` is a new receipt listing + each contested name with its armed and shadowed definitions, and + `getFlowRuntimeStates()` rows now carry `armedFrom`/`shadowed` for contested + names; previously the displaced definition was invisible by construction, since + the flow map holds one entry per name. The `Pulled N flow(s)` line now counts + distinct names rather than registrations. + + `isCodeArtifactBody` is exported from `@objectstack/objectql` so consumers that + collapse same-named metadata answer "does a code package ship this?" with the + registry's own test instead of re-deriving it from `_packageId`. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [f93df4d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [376c70f] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [78f65ef] +- Updated dependencies [f4e7ae5] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [ece4dad] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [9a884c6] +- Updated dependencies [1cba33f] +- Updated dependencies [da1126a] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-protocol@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 23dd18dee2..9b3d816f39 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.2.0", + "version": "17.3.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 fd6e747cdb..9032331015 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,114 @@ # @objectstack/observability +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index ae1f5cc9f0..bc216315bc 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.2.0", + "version": "17.3.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 e79f4f1558..7d64843729 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,584 @@ # @objectstack/platform-objects +## 17.3.0 + +### Minor Changes + +- 09b4f4e: feat(platform-objects,cli): record which source revision a generated translation leaf was filled from (#11671) + + Closes the half of the sticky-translation-drift class that no value comparison + could reach, under maintainer ruling #12069 Option A — by extending the existing + #8765 Option B source-hash mechanism to the generated bundles rather than + building a second one. + + **The hole.** `os i18n extract --fill=default` fills gaps only: any non-empty + value in a translated locale wins forever. So the ordinary sequence — extract, + revise the source string, extract again — rewrites `en` and strands the previous + source text in every other locale. The bundle is still in sync by key, so + `check:i18n` reports OK; the leaf is still present, so `check:i18n-coverage` + counts it translated. Measured on #11659 at `bbe0b17`: three locales serving a + 602-char superseded draft of a 411-char help string under 31 green checks. Once + the source has moved, that stale fill is indistinguishable **by value** from a + real translation — 2648 of 3010 leaves differ from `en`, so "untranslated AND + differing from the source" describes an empty set, not a noisy one. + + **What is new.** `os i18n extract --source-hashes` writes + `.source-hashes.generated.ts` beside each generated bundle: per leaf, + the digest of the source revision that leaf is **still a byte copy of**. + `withSourceFallback` takes that table as a fourth argument and now judges the + `objects` / `metadataForms` sections as well as the hand-authored ones, so a + leaf whose source has moved underneath it serves the current source string + instead of a superseded draft — the same degradation an untranslated key already + produces, which is the invariance the #8765 ruling turned on. + + The generated half needs one conjunct the hand-authored half does not: the leaf + must still hold the recorded bytes. Its hash table is itself generated, so a + translator cannot be asked to refresh a digest by hand the way + `.source-hashes.ts` asks; without that conjunct, re-translating a stale + leaf would leave the old record standing and report the fresh translation as + stale forever. With it, editing the value clears the flag by itself. + + **Behaviour on the day it lands: unchanged for every leaf.** Records are + written only where a leaf is currently a byte copy of the **current** source, so + every record equals the current digest and nothing is stale. Measured across the + nine bundle sets: 9030 translated leaves, 1543 byte-equal to `en` (records + written), 7487 differing (left with no record — legacy-trusted, per the ruling's + property 1, since nothing in the tree says which revision they were made from). + No committed bundle changed a byte. + + **Scope.** `--source-hashes` is off by default and `@objectstack/platform-objects` + is the one bundle set that opts in, by documenting the flag in its extract + config. The other eight sets keep exactly today's behaviour and can be enabled + file-by-file later; a set with no companion is entirely legacy-trusted. + + The false "this hole cannot occur there" note that kept the generated sections + out of the mechanism is corrected in `source-hash.ts`, with the measurement that + falsifies it. +- 3954fb7: Declare sourced `maxLength` bounds on the thirteen unbounded keyed identity + columns, so their declared indexes can exist on MySQL + + `driver-sql` (since #11430) honours a keyed text-family field's declared + `maxLength`, emitting `varchar(maxLength)` instead of `TEXT` — but thirteen + identity columns declared no bound at all, so on MySQL every one of their + declared indexes was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB + column cannot be a key without a prefix length) and the objects landed + registered-but-broken. Measured on live MySQL 8.0.46 (`+08:00`, + `STRICT_TRANS_TABLES`): schema-sync failures drop **12/44 → 8/44** platform + objects and physically-present declared indexes rise **89/128 → 104/128**, + with the Postgres 16 control at 0 failures on both legs. `sys_session`, + `sys_api_key`, `sys_device_code` and `sys_oauth_consent` sync completely + clean — a MySQL stack can now enforce the session-token uniqueness its + sign-in path assumes. + + Every bound is derived from a named source, none guessed (maintainer ruling + on #11374, 2026-08-24 — route A; the full table with sources is in the PR): + better-auth 1.7.1's own MySQL schema mapping (`session.token` / + `verification.identifier` → 255), its device-authorization plugin's hard + runtime cap of 191 on both codes, IdP norms (`account_id` 256 = SAML Core + NameID cap, above OIDC Core's 255 `sub` cap), the landed bounds of referenced + or producing siblings (`client_id` × 4 → 255 from + `sys_oauth_application.client_id`; `provider_id` 255 from + `sys_sso_provider.provider_id`; `issuer` 2048 from `sys_sso_provider.issuer`), + and the in-repo producer (`sys_api_key.key` 64 = fixed sha-256 hex). + + This is an enforcement change on published objects — hence the minor grade: a + write wider than its column's new bound is now **refused** (measured: a + 300-char `sys_session.token` insert fails `ER_DATA_TOO_LONG` on a strict + server, 0 rows; a 255-char one lands). Every bound admits everything its + upstream producer can write, so only values the producing contracts already + forbid are affected. + + Deliberately not bounded, per the ruling's escape clause: + `sys_verification.value` (better-auth's oauth-provider stores JSON + authorization-code payloads there — no defensible bound exists), and + `sys_import_job.created_by` (outside this card's identity surface). + `sys_account.issuer`'s 2048 exceeds the 768-char utf8mb4 key ceiling on + purpose — tighter would refuse SSO sign-ins that `sys_sso_provider`'s own + contract admits — so its `(issuer, account_id)` unique stays for #11627's + hash-shadow route, alongside the `maxLength: 1024` token columns. A new pin + test enumerates every keyed text-family identity column and names any future + unbounded arrival. +- 4805b56: Declare a sourced `maxLength` on `sys_import_job.created_by`, so its declared + index can exist on MySQL — route A's last column + + `driver-sql` (since #11430) honours a keyed text-family field's declared + `maxLength`, emitting `varchar(maxLength)` instead of `TEXT`, and #11699 + declared bounds on thirteen keyed identity columns. `sys_import_job.created_by` + is keyed by `(created_by, created_at)` and declared no bound at all, so on MySQL + that index was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB column cannot + be a key without a prefix length) and the object landed registered-but-broken. + It was the only remaining such object outside the >768-character class that + #11627 tracks. + + The bound is **255**, derived by referenced-column transitivity rather than + chosen: the column holds a `sys_user.id` stamped by the rest-server import route + from `context.userId`, and `driver-sql` creates every table's primary key as + `table.string('id').primary()` — knex's `varchar(255)` — so no id this column + can receive exceeds 255. It agrees with what the column would get if declared + like its siblings (`Field.lookup('sys_user')` emits + `DEFAULT_STRING_VARCHAR_CHARS` = 255) and with the landed declarations for the + same value class (`sys_metadata_audit.actor`, `sys_metadata_commit.actor`, + `sys_view_definition.owner`, all 255). A minted platform id is 26 characters, so + the bound clears the floor with 229 characters of headroom. + + This is behaviour-narrowing on a published object: on a strict MySQL server a + `created_by` longer than 255 is now **refused** (`ER_DATA_TOO_LONG`, 0 rows) + rather than stored, where previously the column was unbounded `TEXT`. No value + the producing contract can emit is affected, because the id it copies is itself + capped at 255 by its own column. + + The route-A pin moves from `identity/identity-keyed-text-bounds.test.ts` to + `platform-keyed-text-bounds.test.ts` and now enumerates **every** platform + object the package exports, not just `identity/`. That directory scoping is + exactly how this column escaped the first pass — the pin could not see it — and + a new control asserts the enumeration reaches columns in `audit/`, `metadata/` + and `system/` so the narrowing cannot silently return. +- af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + + Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling + (2026-08-26, verbatim and untranslated: 「同意」). + + ## The registration follows the declaration (#12359) + + `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but + the only thing that REGISTERED it was the automation service's manifest — + because flows were the ledger's first and, until packaged actions landed, only + consumer. Packaged actions are a second consumer with a different owner: their + consult and write path live on the ObjectQL engine, present in every + composition that can execute an action. + + So a deployment with actions and no automation service had no ledger table, and + the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — + correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be + reported as one) and permanently. Measured on a real boot; it is now this + change's positive test, measured on the same boot: + + ``` + POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED + ``` + + `PlatformObjectsPlugin` registers it now, so every composition carrying + platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, + `skill`, `position`) inherits it. **MOVE, not add** — the automation service no + longer names the object. That was not a style choice: a second code package + claiming one object throws `Object "…" is already owned by package "…"` + (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot + failure rather than a duplicate. + + **Upgrade is a no-op for existing data, and that is measured rather than + asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` + step 4 routes an object by its owning package's `defaultDatasource` — so the + registrar carries the table's datasource with it: + + ``` + owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' + owner com.objectstack.platform-objects (none) -> undefined (global default driver) + ``` + + The ledger table already exists in live databases, so on any deployment + carrying a `cloud` datasource that difference would leave the rows in one + database and read another — every disabled artifact silently re-arming. The + ledger therefore rides its own manifest from the same plugin, carrying the + automation manifest's `scope` / `namespace` / `defaultDatasource` triple + verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, + `sys_secret`) deliberately do not get it and keep riding the project database. + + ## One implementation of the §4 row contract (#12350) + + ADR-0126 §4 declares one activation ledger; it had two independent + implementations of that one row contract — `ObjectStoreFlowActivationStore` + (service-automation) and `ObjectStoreActionActivationStore` (objectql). They + agreed because the second was written from the first, and nothing structurally + held them together; §8 pre-charts `tool`, `skill` and `position`, and a third + and fourth copy is where the org-row skip and the `0`-is-false read get lost + quietly, in the direction (an artifact re-arming) nothing else measures. + + Neither consumer could import the other, so the contract now lives once in + `@objectstack/core` — the package both already depend on — as + `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside + `InMemoryMetadataActivationStore`, `MetadataActivationRow`, + `MetadataActivationStore`, `MetadataActivationStoreEngine` and + `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own + one-argument constructor and its own docs, and fixes the discriminator. + + **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / + `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and + `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / + `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` + / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same + shapes. Row semantics are byte-equivalent: install-level rows only + (`organization_id` never written), org-carrying rows skipped on read and + ignored when deciding insert-vs-update, a driver `0` read as false, + read-then-write rather than a blind upsert, and no `delete` in the engine slice + because re-enabling rewrites the row. + + Both existing pin suites stay green **unchanged**, which is what makes them the + proof the consolidation lost nothing — verified by ablation: removing the + org-row skip from the one shared implementation turns both of them red on their + own org-skip assertion, so both really reach it. +- f6344e7: Make the last two non-unique keyed text indexes expressible on MySQL — remove one, + narrow one + + `driver-sql` emits a keyed text-family column as `varchar(maxLength)` only when the + declared bound is one MySQL can key (768 characters on utf8mb4, the 3072-byte key-part + ceiling); otherwise the column stays `TEXT`, MySQL refuses it as an index key + (`ER_BLOB_KEY_WITHOUT_LENGTH`), and the object's whole `syncSchema` fails — it lands + registered with its declared index absent. #11374 declared sourced bounds for thirteen + such columns and #11627 carried the over-long UNIQUE ones on a SHA-256 hash-shadow + column, taking live MySQL 8.0.46 from 12/44 → 8/44 → 2/44 failing objects. + + The two that remained are **non-unique**, and a hash shadow structurally cannot serve + them: a UNIQUE constraint is an equality-only predicate that survives hashing exactly, + but a non-unique index exists for an access path, and an index over a digest + accelerates no `WHERE col = ?` the planner can reach without rewriting the read side. + They are ruled separately (maintainer, 2026-08-25) because they are different problems: + + - **`sys_verification.value` — the declared index is removed.** The column is + genuinely unboundable (better-auth's oauth-provider writes OIDC authorization-code + payloads there as a JSON blob), and the index was measured dead: better-auth 1.7.1 + keys every verification lookup on `identifier`, `id` or `expiresAt` + (`internal-adapter.mjs`), upstream declares the field unindexed and unbounded, and no + in-repo query filters `sys_verification` by `value`. An index that silently does not + exist on one dialect is the worst of both worlds; removing it makes the metadata match + reality. + - **`sys_oauth_client_resource.resource_id` — the declared bound narrows 1024 → 768.** + This one is a live access path (the FK side of `sys_oauth_resource.identifier`, read + as a predicate by upstream's client-registration collision path), so it keeps its + index and becomes keyable instead. 768 is the widest utf8mb4 value a MySQL key part + holds, and the smallest narrowing that works. + + This is an enforcement change on published objects — hence the minor grade. On MySQL and + SQL Server a `resource_id` longer than 768 characters is now refused rather than stored, + and on PostgreSQL and SQLite the `sys_verification` `[value]` index is dropped on the + next schema sync (on MySQL it never existed). Neither narrows what the producing + contract can emit: the value is an RFC 8707 resource-indicator URI, and upstream + better-auth 1.7.1 stores that same identifier as `varchar(255)` on MySQL + (`get-migration.mjs`) and this referring column as `varchar(36)`, so a resource whose + identifier exceeded 768 characters could never have been registered upstream at all. + + The pin that enumerated the package for unbounded keyed text columns now also rejects a + non-unique index over any text column MySQL cannot key, so a third member of the class + fails at test time rather than on a live server. Its `UNBOUNDABLE` allowlist — which + existed to excuse `sys_verification.value` — is empty as a result, and a synthetic + control keeps the excusing branch exercised rather than letting it rot. +- 428f9b2: feat(platform-objects): declare `sys_metadata_activation`, the packaged-metadata activation ledger (#12155) + + Additive platform surface implementing **ADR-0126 §4 (D2)**: the disable+clone + family gets **one** data-plane platform object, declared beside its siblings so + it needs **zero `packages/spec` schema or contract surface** — it is an ordinary + platform object, not a metadata type. (The one spec file touched is the + mechanical name census described below, not protocol surface.) + + The whole schema, per §4: `metadata_type` · `name` · `package_id` · + `organization_id` (nullable, **reserved** — NULL on this entire line; the + per-org dimension is an additive column later, never a redesign) · `active`. + An earlier ADR draft carried designation columns (`replaced_by`, `cloned_from`); + amendment ruling 2 removed them — there is **no recorded linkage** between a + clone and its base, matching the landed #11513 posture ("an ordinary org-owned + set with no upgrade linkage"). The pin test asserts the column set by EQUALITY + and names both removed columns separately, so re-growing the linkage is loud. + + Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled + as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That + spelling is load-bearing, and the two obvious alternatives are both wrong here: + + - bare `unique: true` on a declared index is the positional spelling of + `'global'` — installation-wide over exactly the listed columns — and is + already warned by lint `unique/unscoped-declared-index` in 17.x; + - a hand-written `['metadata_type', 'name', 'organization_id']` composite is + NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row + by construction, so that index would enforce **nothing at all** (#5030, + measured) and one artifact could carry two contradictory `active` rows. + + `'organization'` is the arm that closes exactly that hole: the driver prepends + `COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which + is what §4's "NULL-collapsed" names. + + The name is also registered in `@objectstack/spec`'s platform-object name census + (`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a + curated set of REAL names, not a `sys_`-prefix pattern, precisely so a + cross-reference check can tell `sys_user` (real) from a fictional + platform-prefixed name; its module contract is explicit that "adding an object + to a platform package means adding its name here", and the owning package's + conformance pin fails otherwise. This is a one-name roster registration, **not** + protocol or schema surface — the ledger remains an ordinary platform object with + no zod/contract surface of its own, exactly as ADR-0126 §4 requires. Its + user-visible effect is that `isPlatformProvidedObjectName('sys_metadata_activation')` + now answers `true`, so lint stops reading a reference to the ledger as a typo. + + **No behavior change.** This leg ships the declaration only — the enable/disable + actions that write the ledger and the per-runtime consult points that read it + are separate legs, and nothing in the tree reads the object yet. Absence of a + row means the packaged default (**active**), so an empty ledger changes nothing + anywhere; there is no seeding mechanism, so a stock boot leaves the table empty. + The object deliberately declares **no `lifecycle` block** — unlike its telemetry + siblings `sys_flow_dispatch` / `sys_automation_run`, a row here is durable + configuration, and reaping one would silently re-arm an artifact an + administrator disabled. + +### Patch Changes + +- 5894d30: Surface the email-invite entry on the organization record page's default + Members tab, and stop it rendering as a twin of "Add Member" + + The in-shell Team surface (`sys_organization` record page, ADR-0081) opens on + tab-0 **Members**, whose related-list toolbar carried exactly one action — + `add_member`, which attaches an **already-registered** user by id. The + email-invite entry, `invite_user`, was declared only on `sys_invitation` and + `sys_user`, so it appeared only on tab-1 Invitations. An admin looking to + "invite a teammate by email" landed on Members, found no invite affordance and + concluded the product had none. The delivery half worked the whole time + (`sendInvitationEmail`, template `auth.invitation`) — only the door was in + another room. + + `sys_member` now declares its own `invite_user` on `list_toolbar`, ahead of + `add_member`: same endpoint (`/api/v1/auth/organization/invite-member`), same + email + role inputs, and the same `requiresFeature: 'organization'` capability + gate as the other two mirrors. Declaration order is render order in the + related-list toolbar bridge, so the invite button sits left of the attach one. + + **The `email` param names `objectOverride: 'sys_invitation'`, and must.** + `sys_member` has no `email` field, so a verbatim copy of the `sys_invitation` + declaration would leave the param unresolvable — the renderer answers that with + a `type: 'text'` fallback labelled by the raw field name, which still submits + and still looks fine (the ADR-0078 valid-but-inert class). `role` needs no + override: `sys_member` declares it, from the same + `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` constant `sys_invitation` reads. A test now + holds this over **all three** mirrors, so the next copy of any action cannot + reintroduce the shape. + + `add_member` keeps its behaviour and its label and is differentiated only in + chrome — `variant: 'secondary'` and `icon: 'link-2'` (the "attach an existing + record" icon `sys_account`'s `link_social` already uses) — so the two buttons + no longer render as identical primary `user-plus` twins. Both halves are + honoured by the renderer: it draws `primary` filled and every other variant + outlined. + + The `@objectstack/spec` half is one line of registry bookkeeping: + `PUBLIC_AUTH_FEATURES.organization.gatedInputs` books the new gated action, as + it already books the other twelve. No schema, export or authorable key changes. +- 064d484: Move both authored `record:alert` gates off `properties.visible` onto the + component-node `visibleWhen`, `has()`-guarded and served as a CEL envelope + (#9167) — the `sys_user` detail page's "Email not verified" banner, and the + showcase Task Detail page's "Awaiting review" banner. + + `record:alert` is the one record component that declares a props-level + `visible` predicate, but `PageComponentSchema.properties` is an opaque record: + the bag is served verbatim, so a bare string in `visible` never reaches + `ExpressionInputSchema` and is evaluated by the console's **legacy JS** + evaluator, which has no `has()`. The node-level `visibleWhen` declared at + `page.zod.ts:189` *is* an `ExpressionInputSchema`, so a page that goes through + the spec's transform serves `{ dialect: 'cel', source }` and runs on CEL — the + same engine, and the same `has()` semantics, every other predicate face was + migrated to. + + Three properties of that move were measured in a real console at the pinned + objectui SHA rather than reasoned about, and all three are load-bearing: + + - The `visible` key is **deleted**, not left beside the new gate. A node + `visibleWhen` and `properties.visible` compose as **AND**, so keeping both + would leave the legacy predicate load-bearing and make the migration + cosmetic. + - The `has()` guards are **mandatory**. On the CEL face an absent key is a + *fault*, and that face is fail-soft: measured, an unguarded gate with its key + stripped from the read left the banner VISIBLE, where the guarded gate hid + it. + - On `sys_user` the predicate is authored through `P` so it reaches the wire as + a CEL **envelope**. `SysUserDetailPage` is a raw `Page` object literal, so — + unlike a page built with `definePage()` — nothing normalizes it, and the + renderer keeps bare strings on the legacy path by design. Measured: the bare + form left "Email not verified" showing on *every* profile, including other + people's; the envelope restores every polarity. + + Behaviour for real users is unchanged in every polarity measured — a `todo` + task hides the banner and an `in_review` task shows it; a verified user hides + "Email not verified", an unverified user viewing their own profile shows it, + and another user's profile shows nothing. What changes is that a genuine fault + is now **loud** (CEL names the missing key) instead of silently answering + `false`, and that both predicates sit on the declared slot the platform teaches + everywhere else. +- 033a34c: **Fix:** `sys_user`'s **`set_user_role`** action ("Set Platform Role") is retired — removed from the object's declared actions, not re-implemented (#9968). + + The action's only effect was `POST /api/v1/auth/admin/set-role`, which better-auth's `admin` plugin lowers to `internalAdapter.updateUser(userId, { role })` — a gated, UI-driven writer for the legacy `sys_user.role` scalar that ADR-0068 D2 stopped synthesizing. Platform-admin membership is granted through `sys_user_permission_set` / `admin_full_access`; a working "Set Platform Role" button was a supported, one-user-at-a-time channel for resurrecting the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3). + + **What an operator will now observe.** The "Set Platform Role" button is gone from the Users list row menu and the user detail header. It was already dead for every platform admin before this change — better-auth's vendor `adminMiddleware` gates on the same retired scalar, so the button 403'd with `YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE` for platform admins and plain members alike. Removing it removes a byte-identical-refusal dead affordance, not a working capability. + + **Unchanged.** The vendor's `POST /api/v1/auth/admin/set-role` route itself stays mounted and vendor-gated exactly as before — this change touches only the `sys_user` console action pointing at it. Every other `sys_user` admin action (`ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`) is unaffected. + + `@objectstack/spec`'s `PUBLIC_AUTH_FEATURES.admin.gatedInputs` registry drops the corresponding `sys_user.actions.set_user_role` entry in the same change (`packages/spec/src/kernel/public-auth-features.ts`) — internal completeness-guard bookkeeping only, no public export shape change. +- 598b7ec: fix(i18n): re-translate the five leaves that served a superseded source revision (#12065) + + `os i18n extract` merges gaps only, so a revised source string leaves the previous + revision standing in every translated locale — in sync by key, green under + `check:i18n` and counted as translated by `check:i18n-coverage`. The five leaves + `check:i18n-stale-fill` froze in its baseline are re-translated here from the + **current** `en` source, and the baseline is ratcheted to empty in the same change. + + User-visible admin/Setup help text changes in `es-ES`, `ja-JP` and `zh-CN`: + + - `dataset.fields.measures.helpText` (metadata forms) — all three locales promised a + `"certified"` governance flag that was removed from the declaration in 16.0. + - `sys_webhook.fields.method.help` — all three locales served the pre-revision method + enumeration after the source became a prose description. + - `sys_webhook.pluralLabel` — `ja-JP` was an untranslated Latin fill and is now + Japanese; `zh-CN` keeps `Webhook`, which is the term this bundle's own Chinese prose + uses and which carries no plural inflection. + - `sys_http_delivery.fields.attempts.help` — `es-ES` / `ja-JP` held an English fill and + `zh-CN` a translation of the same superseded source; all three now carry the + PARKED / terminal-row clause the source documents. + - `sys_notification_subscription.fields.principal.help` — the selector list was missing + the `owner_of:object:id` and bare-email forms in all three locales. + + No schema, export or runtime behaviour changes: translated-locale leaf values only, + plus the shrink-only ratchet baseline. +- 811a3c2: fix(platform-objects): `sys_secret` field help stops asserting the settings-only reading (#12550) + + `sys_secret` has **three** privileged producers (#4270) — the object's own + `managedBy` note has said so for a while — but its most load-bearing field + descriptions still described a settings-only table. Those strings are not + internal comments: they are the field help an operator reads in the + `sys_secret` grid, and they compile into the shipped translation bundles. + + Measured on `origin/main@f93df4db`, by producer symbol rather than by line: + + | producer | `namespace` | `key` | the reference lives at | + |---|---|---|---| + | `SettingsService` (`settings-service.ts`, `secretStore.insert`) | settings namespace | specifier key | `sys_setting.value_enc` | + | engine `encryptSecretFields` (`objectql/src/engine.ts`) | **object name** | **field name** | a `secret:` ref on the business row itself | + | datasource credential binder (`datasource-secret-binder.ts`) | **caller-supplied**, default `datasource` | datasource name | the artefact's `sys_secret:` credentialsRef | + + So `'Settings namespace this secret belongs to.'` / `'Specifier key within the + namespace.'` / `'Opaque handle referenced by sys_setting.value_enc.'` were each + true of one producer out of three, and the pair they describe was presented as + if it identified an owner. That is exactly the `(namespace, key)` attribution + reading #8103's re-measurement rejected — the reason + `sys-secret-orphan-report.ts` reports a row it cannot attribute as + `'unattributable'` rather than `'orphaned'`. Field help asserting the rejected + reading is the safety-relevant direction of this drift. + + Corrected here: the object description, and the `namespace` / `key` / `id` + field descriptions, now name the producer-scoped reality and point at + `managedBy`. The `en` bundle was regenerated with the repo's own + `pnpm i18n:extract`; the three translated locales carried translations of the + superseded English, so their four affected leaves were re-translated by hand — + the action `.objects.generated.ts`'s own header prescribes when a + source string changes — and the bundles plus their `--source-hashes` + companions then come from one extract run. + + Text only. No field is added, removed, renamed or re-typed; no validation, + persistence or access rule moves; every `sys_secret` payload that parsed before + parses identically. ⛔ A producer/owner column stays out of scope — that is a + persist-path change and belongs to whoever takes that decision. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/metadata-core@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 310676c89f..57442849b7 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.2.0", + "version": "17.3.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 6d8ddf03ba..3590d07138 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,114 @@ # @objectstack/embedder-openai +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 231aeb3396..f60c531c33 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.2.0", + "version": "17.3.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 dbe5386128..6a5f76c2c7 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,121 @@ # @objectstack/knowledge-memory +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/service-knowledge@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 9dc25205a0..f25ae31361 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.2.0", + "version": "17.3.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 f290bbc623..cbebac240b 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,121 @@ # @objectstack/knowledge-ragflow +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/service-knowledge@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 2e2df76042..9298364162 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.2.0", + "version": "17.3.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/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index e64d625288..81cda09f03 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,205 @@ # @objectstack/plugin-approvals +## 17.3.0 + +### Minor Changes + +- 599515d: Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. + + The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. + + The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. + +### Patch Changes + +- 9bc403e: Correct what `sys_approval_request.payload_json` is documented to be FOR — it is audit evidence served redacted per reader, not a notification source + + The object's module docstring — which ships to consumers in the package's type + declarations — justified the snapshot column with: *"used by notifications so + they can render before the record is locked or changed."* That consumer does + not exist. Measured against every `this.notify(...)` call site in + `approval-service.ts`, all **12** of them, each passes a payload of + `{ title, message, actionUrl }` (two also carry `actions`), built from + `object_name` / `record_id` and the caller's own comment. **None** reads + `payload_json` or the parsed `payload`. + + This is more than tidiness: that sentence was the only documented + justification for the column holding a *full* row, and it was cited as such + during the #10749 consumer inventory before anyone checked it. The docstring + now states the real reason — the snapshot is retained as **audit evidence of + what was actually submitted**, so the column stays whole at rest, and is served + **redacted per reader** by the subject object's field-level read controls via + `getReadableFields`, on the approvals-inbox door and the generic data door + alike (#11039). + + The field's own `description` is deliberately unchanged: `Record snapshot at + submission time` is accurate, and it — unlike the JSDoc — is the string + extracted into the four generated i18n bundles, so no translation leaf moves + and no locale is left holding an English seed. + + Also carried in the same pass, the residual documentation the #10749 closure + assigned to the next docs touch in this lane: `payload-redaction.ts` recorded + `hidden`-vs-serialization as an **open** `packages/spec` question, and it has + since been ruled (maintainer, 2026-08-24, applying the 2026-08-12 lineage). + That paragraph now states the ruling — **`hidden: true` stays UI-only; + `internal: true` is the serialization primitive** — so an author who needs a + field kept out of read results is pointed at `internal: true` (#7728, + ADR-0049) rather than at `hidden`, which never governed serialization. + + Documentation only: no runtime behaviour, no schema field, and no public type + signature changes. +- 5a916c4: **Ops:** a one-off, idempotent backfill for the platform rows the pre-#10101 writers stranded with no organization — dry run first (#11308). + + #10101 fixed the WRITERS: a `sys_approval_request` and a `sys_automation_run` are now stamped from the SUBJECT record's organization, with the acting context as the ruled fallback. It wrote nothing to existing rows, so the population produced before it persists — a **pending** org-less approval request LOCKS the record it is about while being invisible in every organization-scoped inbox, its own owner's included, and automation-run history stays unattributed. This is the repair for those rows, on the maintainer's 2026-08-23 ruling (direction 3). + + `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` sweeps `sys_approval_request` (with its `sys_approval_action` / `sys_approval_approver` children, which move with their request) and `sys_automation_run`. It scans only rows whose organization column is unset, re-reads each row's subject at repair time — live record first, the write-time snapshot (`payload_json` / `context_json`'s `record`) second for a subject that has since been deleted — and stamps the platform row with the subject's own organization. + + **Dry run first, and by default.** `planPlatformRowOrganizationBackfill(engine)` reads only and returns a per-object report naming every row it would touch; `runPlatformRowOrganizationBackfill(engine, { dryRun: false })` writes. Nothing runs at boot and nothing is scheduled: this is an operator-invoked module, run once against an affected install. + + **Rows whose subject is equally org-less are counted and named, never written.** The acting-context fallback the writers apply is not available to a repair — the acting context is gone — and inventing one stays vetoed. Those ids are reported so the population is checkable and stays visible. + + **`sys_api_key`'s divergence is preserved, not flattened.** Both the column read on a subject and the column written on a platform row are resolved from the registered schema through the shared `createRecordOrganizationResolver` (`@objectstack/metadata-core`), so a platform row ABOUT an API key is repaired from that object's stamp-only `active_organization_id` (limb 0, #8778) and the credential table itself is never written to. + + **Idempotent, and asserted rather than claimed.** Every scan is `WHERE IS NULL` and every write fills that column, so a repaired row cannot match again; the test suite runs the sweep twice and pins the second run at zero writes. + + Publishes no runtime code: the module is not exported from the package index and not bundled into `dist` (`tsup` builds `src/index.ts`). It is graded rather than skipped because the release notes are where an operator of an affected install learns the repair exists, what it will and will not touch, and that the dry run comes first. +- 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. + + - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. + - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. + - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. + - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. + + The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index a1e68fe728..398032c271 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.2.0", + "version": "17.3.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 f774013205..03a2a20074 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,279 @@ # @objectstack/plugin-audit +## 17.3.0 + +### Minor Changes + +- 599515d: Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. + + The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. + + The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. +- 8d237b4: Seeds can now address an ActivityPointer (#11339, ADR-0052 §5): a `text` field may declare `referenceVia: ''`, marking it as the id half of a polymorphic pointer pair whose target object the sibling column names per row (`sys_activity.record_id` via `object_name`, `source_id` via `source_object` — both now declared). The seed loader resolves such pointers as natural keys against the object each row names — the same externalId probes, in-memory map and pass-2 deferral static lookup references use — so a packaged app's seed can ship timeline rows that actually attach to their records, and the shipped console filter `{ object_name, record_id }` matches them. + + The accept/reject contract changes with it, deliberately: an unresolvable pointer on a DECLARED pair is now a loud, counted failure (`success: false`, `error`-level log, record dropped when no pass 2 can heal it) instead of the old silent verbatim store — a row that rendered on no timeline and matched no filter. Undeclared text columns are untouched: only `referenceVia` opts a pair in. Authoring contradictions are refused at parse time (`referenceVia` is text-only and mutually exclusive with `reference`) and at `ObjectSchema.create` (the sibling must be a declared field). Internal-id-shaped values still pass through verbatim, so seeds wiring real ids keep working. + +### Patch Changes + +- f64668d: fix(plugin-audit,plugin-security): declare sourced bounds on the four keyed text columns that break MySQL schema-sync (#12059) + + Four text columns that a declared index keys on carried no `maxLength`, so + `driver-sql` emitted them `TEXT`. MySQL refuses a TEXT/BLOB column in a key + without a key length (`ER_BLOB_KEY_WITHOUT_LENGTH`): `CREATE TABLE` succeeds, + `ALTER TABLE … ADD INDEX` fails, and the object lands registered-but-broken + with its declared index silently absent. + + | Object | Column | Bound | Producer the bound is derived from | + |---|---|---|---| + | `sys_activity` | `record_id` | 255 | the physical `id` column — `driver-sql` creates every primary key as `table.string('id').primary()`, knex's `varchar(255)` | + | `sys_audit_log` | `record_id` | 255 | same | + | `sys_audience_binding_suggestion` | `package_id` | 255 | `sys_permission_set.package_id` (255), which the same boot pass writes the same value into | + | `sys_audience_binding_suggestion` | `permission_set_name` | 100 | `sys_permission_set.name` (100), the column this value resolves against at confirm time | + + Each bound is derived from a **named producer** and stated in the declaration + so it is vetoable in review (#11374 route A; PR #12058 is the worked + precedent). None of them narrows anything storable: + + - a record id cannot exceed the `varchar(255)` column the id itself lives in, + and the `referenceVia` seed path refuses an unresolvable pointer rather than + storing a natural key verbatim; + - a permission set name longer than 100 is already refused at the write seam + today — measured on a real engine, `ValidationError: API Name must be ≤ 100 + characters (got 101)` — so no set with such a name can exist, and a + suggestion naming one could never be confirmed. + + Measured at the driver level, shipped declaration vs. the same declaration with + the bounds stripped: `record_id`, `package_id` and `permission_set_name` move + `TEXT` → `varchar(255)` / `varchar(100)`, while `id` reads `varchar(255)` in + both — the transitivity premise, read off a real table rather than assumed. + + Existing deployments are not rewritten: a physical `TEXT` column is deliberately + not diffed against `maxLength` (#11431), so no `ALTER` is planned and no value + at rest is truncated. The repair takes effect where the decision is makeable at + all — at `CREATE TABLE` — because no dialect turns a TEXT column into a keyable + one afterwards. + + Each plugin also gains a keyed-text-bounds pin driven through its **own + registration path** (`init()` → the manifest `register({ objects })` call), + rather than a hand-written object list: the platform-objects pin enumerates only + that package's exports, which is exactly why these four columns escaped route + A's sweep after ADR-0029 K2 moved the objects out. +- 2d8dd8d: Publish the built-in `sys_activity.type` vocabulary from `@objectstack/spec` (#11807). + + `SYS_ACTIVITY_BUILTIN_TYPES` (and the derived `SysActivityBuiltinType` union) is now exported from `@objectstack/spec/data` (`feed.zod.ts`, alongside `FeedItemType`), and the `sys_activity` object declaration in `@objectstack/plugin-audit` derives its `type` options from it — one source instead of a hand-copied list per consumer. + + The constant is the platform's **built-in set, not the column's value domain**: `sys_activity.type` stays an open, author-extensible vocabulary (#11507 ruling — an app may contribute values via `activityMilestones[].type`, ADR-0052 §5b.2, or its own inserts, and undeclared values are stored verbatim). It is deliberately a plain `as const` tuple rather than a `z.enum`, so it cannot be used as a validator; consumers must render unknown values, never drop them. UI packages that hand-copied the list (objectui's feed-kind census, which drifted the day #11522 added `scheduled`) can now read this export instead. +- 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. + + - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. + - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. + - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. + - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. + + The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. +- 91b1342: Declare `sys_activity.type: 'scheduled'` and record its writer in the type census + + A shipped app action writes the value today. `objectstack-ai/hotcrm` + `src/actions/global.actions.ts` builds a `schedule_meeting` action for + `crm_lead`, `crm_contact`, `crm_account`, `crm_opportunity` and `crm_case` + whose body runs `ctx.api.object('sys_activity').insert({ type: EVENT_STATUS + === 'held' ? 'completed' : 'scheduled', … })`, and `SCHEDULE_MEETING_SPEC` + declares `eventStatus: 'planned'`, so that action always takes the `scheduled` + branch. Read at hotcrm `5eee1bd` on 2026-08-24. + + **Nothing accepts or rejects differently.** Every `sys_activity` field is + `readonly: true`, and `validateRecord` skips readonly fields on both the insert + and the update branch (`objectql/src/validation/record-validator.ts`), so the + `invalid_option` check that enforces a select field's declared options never + runs for one — measured here, not assumed: with the value added to the enum and + nothing else changed, exactly one of the package's 313 assertions moved, the + writer census below, which exists to force that row to be written. Every + behavioural case stayed green, including the one that inserts an undeclared + value into this very column and measures that it lands. Before and after, the + row is stored verbatim. What changes is that the declaration names the value + the platform stores, so the timeline filter offers it and the four generated + locale bundles carry a label for it (`已安排` / `予定` / `Programado`). + + The census (`sys-activity-type-vocabulary.test.ts`) gains the writer row, which + is what forces this to be measured rather than asserted: adding a value to the + enum without inventorying its writer fails that pin. Two further facts are + recorded there in passing — `completed` has a second writer at the same app + (`src/actions/contact.actions.ts`), and the census's in-repo sweep cannot see + either of them, because an app's server-side action reaches this column directly + through `ctx.api`. +- 88b9d74: Say out loud that `sys_activity.type` is an open, author-extensible vocabulary + — the declared options are the platform's **built-in** set, not a closed enum + + An author reading the declaration learned "writing another value will be + rejected". That was false in three independent ways, and the declaration was + the only place that did not say so. + + 1. Every field on `sys_activity` is `readonly: true`, and `validateRecord` + skips readonly fields on both write branches, so the `invalid_option` check + a `select` normally implies **never runs** on this column. + 2. ADR-0052 §5b.2 `activityMilestones[].type` is `z.string().optional()` in + the spec and is forwarded verbatim by the audit writer + (`if (milestone.type) activityType = milestone.type`) — a shipped, + documented, author-facing channel straight into the column. + 3. An app's own server-side action writes the column directly + (`ctx.api.object('sys_activity').insert({ type: … })`); no grep of this + repository can see those sites. + + Maintainer ruling, 2026-08-24 (#11507, direction 4 of four): the column **is** + an open vocabulary, ADR-0052 §5b.2 **stays** a sanctioned write path, and + every closed map over this vocabulary is now the bug. The status quo was the + one option more dangerous than either end state — most of all to an AI writing + metadata, which reads the declaration and believes it. + + So the declaration now carries the semantics, in the field's own + `description` — the slot the spec declares for exactly this and, unlike a + source comment, one the contract carries wherever the metadata goes (the + metadata API, the i18n bundles, whatever an author or an AI reads about this + field). No new schema concept was invented: `FieldSchema` has no + open/closed-vocabulary key, and the pin measures that rather than asserting + it, so the day `packages/spec` grows one this declaration is told to move. + + Nothing about enforcement changed — that was direction 3 and it was **not** + ruled. `validateRecord` is untouched, the built-in set is unchanged (twelve + values), and both existing vocabulary tests keep every assertion they had. + What changed in them is what a red MEANS: the two cases that used to be filed + as "a defect, characterized — delete these when enforcement lands" now measure + a ruled contract, and say that rejecting an author-contributed value is a + contract change to re-open #11507 over, not a fix to adapt them to. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c5b9ccc] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index c186a0fe70..706b25762f 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.2.0", + "version": "17.3.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 a05281c729..258ee12217 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,515 @@ # Changelog +## 17.3.0 + +### Minor Changes + +- 4f24e9d: feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` + + **BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. + + - New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. + - **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. + - `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. + - Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. + - `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. + - The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. + - `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). + + +- 8e31083: feat(settings,auth): expose the audience posture in the `auth` settings namespace (#11768) + + The audience posture shipped by #11739 (`invite_only | email_domain | open`, + default `invite_only`) was switchable only from stack config at boot; a + self-host admin had no console channel. The `auth` settings namespace now + carries an `audience` group — three new authorable keys, which is what a host + sees and why this is `minor`: + + - `audience_posture` — a select over the closed vocabulary (the option table + is enforced on `setMany` and on the `OS_AUTH_AUDIENCE_POSTURE` env-override + door); + - `audience_allowed_email_domains` — newline- or comma-separated bare domains + (exact, case-insensitive matching; subdomains need their own entries); + - `audience_self_registration_permission_set` — the `sys_permission_set` name + each self-registrant receives. + + `bindAuthSettings` maps the three keys — one atomic declaration — to one + `AuthManager.applyConfigPatch({ audience })`, which replaces the whole + audience object and validates the MERGED result. Every #11739 invariant holds + through the new channel: a self-registration posture with verification + explicitly off, an empty domain list under `email_domain`, and a missing or + `admin_full_access` permission set are all refused loudly (the standing + config keeps ruling — fail closed), and off-vocabulary postures are refused, + never coerced, per the `membership_policy` precedent (#5152). Only EXPLICIT + settings values apply: the manifest defaults never mask a deployment's + boot-config declaration. Switching back to `invite_only` always applies — + leftover text in the posture-hidden sibling fields cannot make closing the + wall refusable. +- 911da5f: **BREAKING (public export removed):** `AUTH_SCIM_PROVIDER_SCHEMA` no longer exists. It was a `scimProvider` column mapping exported from `@objectstack/plugin-auth` (via `export * from './auth-schema-config.js'`) that nothing ever read — one repo-wide hit, its own declaration — and that nothing ever could: `@better-auth/scim` hardcodes its model and exposes no `schema` option, still true of the installed `@better-auth/scim@1.7.0-rc.1`, whose `SCIMOptions` declares no `schema`, `modelName` or `fields` member at all. Its sibling constants in the same file are genuinely passed to their plugins; this one had nowhere to go, by construction and by its own doc comment. + + Removed under ADR-0049 enforce-or-remove, because it was a **second source of truth** for the same four column names. The load-bearing one is the adapter layer — `AUTH_MODEL_TO_PROTOCOL` plus the mechanical camelCase-to-snake_case field resolution in `objectql-adapter.ts`, over the `sys_scim_provider` platform object that declares the columns — and it is pinned by the dedicated sso/scim block in `better-auth-schema-parity.test.ts`. A dead copy is worse than none: nothing fails when it drifts from the live names, and the next reader cannot tell which of the two is authoritative. A comment in its place records why no such mapping exists and what owns the names instead, so it is not re-added. + + Behaviour is unchanged. No SCIM column name, platform object, adapter mapping or wire shape moves. + + Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`). + + +- b706af9: Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + + - `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). + - `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. + - `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). + - `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + + Forward-stamping only: existing org-less `sys_email` rows are not backfilled. +- 9735662: fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + + **BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or + `isolated`), shipped as `minor` under the repo's launch-window convention for + breaking changes. Single-org deployments are byte-for-byte unchanged. + + Measured defect (cloud#1509): on a walled multi-tenant SaaS with + `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the + FIRST self-registrant received the cross-tenant `admin_full_access` grant + (`platform_admin`, `isPlatformAdmin: true`) and — because the default-org + bootstrap binds "the platform admin" — was merged into the deployment's + Default Organization as its owner. Whoever curls the public sign-up endpoint + first owned the platform. + + Per the maintainer ruling of 2026-08-23 (verbatim: + 「1509 选择 env 指定 owner 邮箱」): + + - **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. + - **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). + - **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. + - The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + + Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the + operator account's email address before upgrading. Deployments that already + hold a human platform admin are untouched (the bootstrap remains a no-op once + any human holds the cross-tenant grant); the variable governs installs that + have not yet minted their admin. `@objectstack/types` gains the + `resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` + constant; the verify harness declares the owner email (defaulting to its dev + admin) for walled fixtures. + + + +### Patch Changes + +- 8064e6d: `POST /api/v1/auth/admin/has-permission` now answers an ObjectStack platform admin from the ADR-0068 platform-authz predicate. The vendor evaluated this permission query on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing, so a genuine platform admin was answered `success: false` — indistinguishable from a plain member. The route is now shaded by an ObjectStack raw mount: a platform admin's query is evaluated against the vendor's own admin access-control statements with only the identity signal replaced (an ungranted or unknown permission still answers `false`), while anonymous callers, plain members, and every request body the vendor refuses to evaluate are delegated to the vendor unchanged, byte for byte. +- 6dd3e69: fix(auth): authorize before the break-glass guard on `POST /api/v1/auth/admin/remove-user` (#11477) + + The break-glass last-local-credential guard is registered as a global better-auth + `hooks.before`, which runs ahead of an endpoint's own middleware. On + `/admin/remove-user` — served directly by better-auth's router, whose + `adminMiddleware` establishes only a session, with the role decision landing + later inside the vendor's handler — that ordering let the guard's lookup and its + distinctive refusal be reached by any **authenticated** caller before either + authorization layer had run. Because that refusal is target-dependent, the + refusal itself carried a per-record fact about a user the caller was not + entitled to ask about. + + `/admin/ban-user` already ran the same guard **after** authorization: #9652 + shades that path with an ObjectStack raw mount whose platform-admin gate fires + first. One guard, two routes, opposite orders, and nothing asserting either. + + `/admin/remove-user` now carries the same shading, converging the whole + `/admin/*` family on **authorization before the guard**. The mount reuses the + landed #9652 / #9653 pattern and introduces no new mechanism. + + What changes is **when** the guard decides, never **what** it decides: + + - an anonymous caller still gets `401 UNAUTHENTICATED`; + - an authenticated non-admin now gets `403 PERMISSION_DENIED` for every target, + so the guard is unreachable before authorization and its answer no longer + varies with the named user; + - a platform admin is unaffected in every respect — the mount **delegates** into + better-auth rather than re-implementing removal, so the path-keyed hook still + fires and the guard still refuses the removal of the last local password + login, and admission remains the vendor's own decision (#9969). + + An ordering pin ships with the fix so the sequence is mechanically checkable + rather than re-argued: it asserts that one authenticated non-admin naming two + different targets receives **indistinguishable** responses, and — so the pin + cannot be satisfied by deleting the guard — that an admitted platform admin + still hits the guard's refusal, and still succeeds on an ordinary user. +- 2efa1e1: Apply the workspace's SAVED auth settings at boot — `AuthPlugin` now declares + the settings ordering edge instead of reading in the pre-bind window + + `SettingsServicePlugin` registers the `settings` service in `init()` but binds + its DATA ENGINE from a `kernel:ready` hook it registers in `start()`. Between + those two moments the service is resolvable and answers reads — from an empty + in-memory fallback and the manifest defaults, with `source: 'default'` — while + the deployment's real `sys_setting` rows sit unread. Nothing distinguishes that + from "no row exists". + + `AuthPlugin` was reading inside that window. Its `start()`-registered + `kernel:ready` hooks reach `getService('settings')` at depth 3 (`runBackfill` → + `ensureAuthSettingsBound` → `bindAuthSettings`) and call + `getNamespace('auth')` in the same tick. Handlers fire in registration order, + registration order is `start()` order, and `AuthPlugin` declared + `dependencies: ['com.objectstack.engine.objectql']` and nothing about settings + — so nothing ordered it after the settings plugin. + + On the shipped composition that order was not merely unconstrained, it was + **wrong**: `os serve` does `kernel.use(new AuthPlugin(...))` before the + capability loop registers `SettingsServicePlugin`, and `resolvePluginOrder` + preserves insertion order for plugins with no edge between them. So everything + `applySettings()` derives was computed from DEFAULTS at boot — the ADR-0093 + membership policy the D6 backfill runs under, and the `google_*` social-provider + config. `settings.subscribe('auth', …)` only re-applies on a *later* change, so + a workspace that configured auth in Setup and never touched it again kept + booting with the wrong values: authored, stored, and silently not applied. + + The repair is one declaration, the same shape the three other shipped readers + (`plugin-email`, `service-sms`, `service-storage`) already carry: + + ```ts + optionalDependencies = ['com.objectstack.service.settings']; + ``` + + SOFT, not hard — a kernel with no settings service must still boot auth, and + `bindAuthSettings` already returns early when the service is absent. + `requiresServices` would not have done it: that asserts the service is + REGISTERED before `init()`, which it always is, and carries no `start()` + ordering. + + Enforced in both directions. `check:settings-bind-window` goes green with the + `com.objectstack.auth` entry **deleted** from its shrink-only ledger — deleting + it while the defect stood reproduces the finding, so the green is a measurement + rather than a suppression. And `auth-settings-ordering.pin.test.ts` resolves a + hostile registry that composes auth BEFORE settings, then removes the + declaration from a live instance and watches the order revert (ADR-0049: + a declaration nothing acts on is the defect, not the fix). +- 56d3c7a: `/delete-user` no longer lets a body-supplied `userId` win over the resolved actor + + `/delete-user` is the vendor's self-service delete: its contract names no + target, the subject IS the authenticated caller. The break-glass + last-local-credential guard's target resolution on that route still preferred + a body-supplied `userId` whenever one was present, so any authenticated + caller could steer the guard's own refusal at a user other than themselves. + + The guard's target on `/delete-user` is now the resolved actor unconditionally + — `body.userId` is never consulted for that route, only as a prior fallback. + `/admin/remove-user` and `/admin/ban-user` are unaffected: target-naming is + their own contract and is untouched here. For every caller acting on + themselves, nothing changes — the same lookup runs and the same outcome + (refuse the last local credential, admit everything else) is returned. +- d3bee87: **SDK:** `auth.setInitialPassword` binds the already-mounted `POST /api/v1/auth/set-initial-password` route, which had no client method. + + `AuthPlugin` has mounted this route on the raw Hono app for as long as the SSO-onboarding flow has existed, but `packages/client/src` built the URL nowhere — measured zero for both `setInitialPassword` and `set-initial-password`, against four sibling auth members returning non-zero on the same corpus, so the absence was an absence and not a broken search. Its only caller was `@object-ui/auth`'s `createAuthClient`, whose three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are all expressed on `ObjectStackClient`, and whose sibling branch in the very same Console password card — `changePassword` — has been ledgered `sdk` throughout. + + The method is shaped exactly like its namespace siblings (`this.getRoute('auth')` + `this.fetch`, `POST` with a JSON body, returning the parsed envelope), because the difference between it and `changePassword` is a **server-side** one and belongs there: better-auth registers `setPassword` with no HTTP path of its own (server-only `auth.api.setPassword`), so ObjectStack wraps it in an authenticated mount that requires a session and refuses with 409 `PASSWORD_ALREADY_SET` when a credential already exists. Callers that already have a password use `changePassword`, which verifies the current one. + + **Nothing about the route's behaviour moves.** Its accept/reject logic, its admit set and its server-side guards are untouched — this is a client binding to an existing mount, not a widening of what the mount allows. + + **Its `AUTH_ROUTE_LEDGER` row lands with it**, because the two halves are one statement and neither is true alone. `plugin-auth` gains `{ route: 'POST /api/v1/auth/set-initial-password', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.setInitialPassword' }` — the ninth mount of the #10534 census, whose disposition was escalated rather than guessed and which the maintainer ruled `sdk` (option C, 2026-08-22) and then ruled should land in one PR (2026-08-23). Without the row, the method's URL matched only the dispatcher's `* /auth/**` prefix family, and `client-url-conformance.test.ts` bounds wildcard-only matches at zero on purpose; with it, the same URL resolves to an enumerated route. The row also brings the `check:auth-mount-ledger` pending-disposition entry down — the exemption that carried this route while the question was open is deleted, which is that ratchet working rather than being relaxed. +- 0e0bf80: fix(plugin-auth): the invitation carve-out stopped admitting past 200 pending invitations (#11770) + + Under the `invite_only` audience posture that #11739 made the default, an + administrator could send an invitation and the invitee's account creation would + still be refused with `SELF_REGISTRATION_CLOSED` — silently, with no signal to + either party — as soon as the environment held more than 200 concurrently + pending invitations. A 500-employee onboarding is an ordinary way to reach that. + + `AuthManager.hasPendingInvitationFor` answered "does this address hold a pending + invitation?" by reading at most 200 rows filtered only on `status = 'pending'` + and scanning them in memory for a case-insensitive email match. Past the first + page the invitee simply was not there, so the fail-closed `catch`-alike branch + applied the posture and refused a legitimate invitee. + + The address now goes into the query — `sys_invitation.email` carries a declared + index — and the page chain is exhausted, so no row count can hide a live + invitation. A page is "pending invitations addressed to this one person", which + better-auth bounds by refusing a second pending invitation per organization, so + this is not a read of the environment's pending population on the sign-up path; + in practice it is a single indexed lookup where the old code always read 200 + rows. + + The in-memory scan existed on the stated belief that invitation addresses are + stored as the inviter typed them. Measured against the installed better-auth + 1.7.1, that is wrong on both halves — `organization/invite-member` lowercases + the address before storing it, and `internalAdapter.createUser` lowercases the + registrant's before calling `validateUserInfo` — and the vendor's own + `findPendingInvitation` / `listUserInvitations` / `findMemberByEmail` all query + with `email.toLowerCase()`, so a mixed-case row was never redeemable through + `accept-invitation` anyway. + + The row-side comparison is kept rather than deleted: `=` folds case on some + collations and folds accents with it, so every returned row is re-checked + against the normalized address — a case-only difference still matches, an + accent-only difference does not. Expiry stays in JS so a row with no readable + `expires_at` keeps reading as live. The security properties are unchanged: + `status = 'pending'` only, expiry still enforced, and an unanswerable probe + still means no carve-out. +- 7131f12: **Security:** the "is this user id a platform admin?" question is now asked in exactly one place, and the two copies that answered it differently are gone (#10348, #10949). + + ADR-0068 D2 defines platform standing as one thing — an unscoped `admin_full_access` grant, held now. `core/security/resolve-authz-context.ts` is the declared authority for authorization derivation and its header states that every entry point must resolve through it and never re-read the grant tables itself. `plugin-auth`'s `auth-manager.ts` did exactly that twice: once inside the `customSession` callback, and once in the predicate that authorizes `/sso/register` and, through the impersonation oracle, `/admin/impersonate-user`. Both copies are deleted. Both callers — and the session payload — now ask `hasPlatformAdminStanding(engine, userId)`, a projection of `resolveUserAuthzGrants` exported from `@objectstack/core`, so a platform-admin verdict is derived in one place for the whole platform. + + **What that changes, and it is a tightening on all three counts.** The deleted copies applied neither the ADR-0091 validity window nor the ADR-0049 `active` check, and resolved `admin_full_access` by matching a name over a page of the permission-set catalogue. The authority applies both checks before any derivation and resolves the set by id. So: + + - an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload; + - a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too; + - an environment holding **more permission sets than a single catalogue page** can no longer lose the `admin_full_access` row and demote every platform admin at once. + + **One behaviour widens, and it was ruled deliberately** (maintainer, 2026-08-24). The `customSession` copy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin — the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected: the two reads reached identical rows there already. + + **The org boundary is unchanged and now pinned at both gates.** An org owner, an org admin, a `TENANT_ADMIN`-posture principal and an org-scoped `admin_full_access` grant are all refused — the `PLATFORM_ADMIN` rung derives from the unscoped capability grant alone. The predicate takes an engine and a user id and nothing else: it deliberately does not accept the resolver's caller-supplied seeds, so no part of a request can supply part of its own verdict. + + Population queries are a different kind and are untouched: `ensure-default-organization.ts` asks *which* user is the platform admin, which a per-user predicate cannot express. +- 2cf5a96: An organization no longer stops accepting members at 100 — membership is not a + limited axis, and the ceiling nobody chose is now stated explicitly + + A customer adding users was refused with `Organization membership limit + reached`. Nothing in this codebase set that ceiling: better-auth's organization + plugin substitutes a vendor default of **100** for an absent `membershipLimit` + (`count >= (membershipLimit || 100)` in `routes/crud-members`), and + `auth-manager` passed `organizationLimit` — how many organizations one user may + CREATE — while never passing `membershipLimit`, which is a different question. + + The two read almost identically in a config block and mean nothing alike, which + is why the gap survived: the option that WAS set looked like the option that + was not. In the field the refusal is worse than merely wrong — it arrives while + an operator is looking at licences and seat counts, and reads as an entitlement + problem on an axis that carries no entitlement at all. Seats are metered on AI + usage; plain membership has never been billed. + + - `membershipLimit` is now passed explicitly, defaulting to unbounded. + - `OS_ORG_MEMBERSHIP_LIMIT` is the opt-in for a deployment that DOES want a + ceiling (a pilot, a trial tenant). Unusable values (empty, non-numeric, + zero, negative) read as unset rather than as a cap — a typo must not be the + thing that locks an organization, which is exactly the failure mode being + fixed. + - The decision lives in `resolveMembershipLimitOption()` rather than inside the + plugin-construction expression, so it is testable: the unset case, the + explicit ceiling, the unusable-value direction, and — deliberately — that the + chosen value clears the vendor's 100 by a wide margin. If a future + better-auth changes that default, the test says so instead of leaving an + unexplained constant behind. + + The unbounded value is `Number.MAX_SAFE_INTEGER`, not `Infinity`: the option is + compared numerically but also travels through option plumbing that may assume a + finite value, and nine quadrillion members is unlimited by any measure that + reaches a real deployment. +- c0714eb: Walled platform-admin elevation now requires the owner-email match to be + VERIFIED, and the bootstrap re-runs on the verifying update (#11343) + + Under walled postures (`group`/`isolated`), `bootstrapPlatformAdmin` matched + the env-declared `OS_PLATFORM_OWNER_EMAIL` against the raw email string on + `sys_user` — with no `email_verified` condition, while email verification is + off by default. #11211 narrowed elevation from "whoever registers first" to + "the declared owner's address" (a real and large narrowing); this closes the + remainder that card #11343 records: in the window before the owner registers, + an account created with the owner's address would still be elevated. + + Two halves, deliberately in one change: + + 1. **The elevation match requires `email_verified`** (fail-closed allow-list + over driver representations; an absent field on an imported/legacy row + reads as unverified). An unverified holder of the owner's address is + refused like any stranger — new reason `walled_owner_not_verified`, logged + loudly with the unblock in the line. Never falls back, same direction as + the undeclared-owner refusal. + 2. **The bootstrap-replay middleware now also fires on `sys_user` updates + touching `email_verified` / `email`** (trigger set extracted as + `shouldReplayBootstrapFor`, consumed by the middleware and its pins alike). + Verification is an UPDATE — with the old insert-only replay, requiring + verification would have refused the genuine owner at sign-up and then + never looked again, leaving the platform without any administrator. + + `single` posture is untouched both ways: first-user promotion (ruled + reasonable in #11184) does not gain a verification requirement, and the + owner-email variable is still never consulted there. Both directions are + pinned: the unverified holder is refused AND the verified owner is elevated — + including across the refuse-then-verify-then-re-run sequence. + + The seeded dev admin (`maybeSeedDevAdmin`, dev-only) is now provisioned with + `email_verified` stamped: it is created by the deployment's own boot command + with operator-known credentials — the same trust shape as a trusted-SSO + insert, not an unknown self-registrant — so walled dev/harness boots keep a + promotable declared owner. The generic sign-up path is unchanged. +- bf8d129: feat(plugin-auth): warn at boot when a walled deployment declares an owner it can never verify (#11640) + + A walled deployment (`OS_TENANCY_POSTURE=group|isolated`) that declares + `OS_PLATFORM_OWNER_EMAIL` but wires **no verification path** — no email + transport and no trusted federated sign-in — now emits a loud, named boot + warning (`walled_owner_no_verification_path`) on `kernel:ready`. + + Since #11343, walled platform-admin elevation requires the declared owner's + address to be **verified**, and verification can only arrive by an emailed + link or by a federated sign-in that inserts the account already verified. With + neither wired, the declared owner registers, is refused + (`walled_owner_not_verified`), and has no in-product way to satisfy the + condition — a dead end that previously surfaced only weeks later, at the + owner's rejected registration. The warning names both missing inputs and the + concrete wiring for either remedy (an email service, or SSO / a social + provider), since either one alone clears it. + + ⛔ **Boot proceeds — this is not a refusal**, and no accept/reject behaviour + changes anywhere: the walled + undeclared-owner boot refusal (#11184) and the + fail-closed elevation refusal (#11343) are untouched. Deployments already + wiring either verification path see no new output, and neither does a + dev/harness boot whose declared owner is the dev-admin the seed provisions and + stamps verified. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [6747718] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7181101] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [e25403c] +- Updated dependencies [d2b2381] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [6e33394] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 2342f57894..695802aad3 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.2.0", + "version": "17.3.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 20c793372d..8f71028174 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,197 @@ # @objectstack/plugin-dev +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [6747718] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [983edf1] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [56d3c7a] +- Updated dependencies [7181101] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [5cb62d8] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [6171331] +- Updated dependencies [0010797] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e170b0a] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c5b9ccc] +- Updated dependencies [c68c670] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [6e33394] +- Updated dependencies [911da5f] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [18b53ac] +- Updated dependencies [e1d773e] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [9981f31] +- Updated dependencies [a41069b] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [96940b7] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/service-storage@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/account@17.3.0 + - @objectstack/setup@17.3.0 + - @objectstack/driver-memory@17.3.0 + - @objectstack/plugin-hono-server@17.3.0 + - @objectstack/service-i18n@17.3.0 + - @objectstack/service-realtime@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index e5157d8395..2a3fd57ac2 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.2.0", + "version": "17.3.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 201a0e0f9a..03b4acfab8 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,140 @@ # @objectstack/plugin-email +## 17.3.0 + +### Minor Changes + +- b706af9: Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + + - `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). + - `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. + - `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). + - `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + + Forward-stamping only: existing org-less `sys_email` rows are not backfilled. + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 01c9a88251..2919690500 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.2.0", + "version": "17.3.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 74d40848ce..f4ba169bc2 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,126 @@ # @objectstack/plugin-hono-server +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/observability@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 367e37ef64..b37fa4ea68 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.2.0", + "version": "17.3.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 0ecb6c65cc..1cafe93911 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,42 @@ # @objectstack/plugin-pinyin-search +## 17.3.0 + +### Patch Changes + +- Updated dependencies [983edf1] +- Updated dependencies [0010797] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [d395692] +- Updated dependencies [af56546] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [9086761] +- Updated dependencies [c5b9ccc] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [b72db01] +- Updated dependencies [fe72aa5] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [f7b25c5] +- Updated dependencies [9735662] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] + - @objectstack/core@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index a4cec2237e..34bb854a9f 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.2.0", + "version": "17.3.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 7f7095a3d8..1981fbe21f 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,129 @@ # @objectstack/plugin-reports +## 17.3.0 + +### Patch Changes + +- a1c804b: Report CSV and HTML exports now infer their columns from **every** row of the result set, not from the first 50. Column inference sampled `rows.slice(0, 50)` while the projection it produced was applied to all rows, so when a report declared no explicit `query.fields`, any key whose first occurrence fell at row 51 or later was absent from the header *and* dropped from every row that carried it. The export gave no signal: the CSV was well-formed, every row had the same arity, and nothing marked a column as inferred rather than declared, so a recipient of a scheduled report attachment could not tell. Sparse columns are the normal shape of report output — an optional field, a formula only some records satisfy, a lookup that resolves for a subset — and the sampled prefix is the query's first page in its own `orderBy`, so for a report sorted by status or created date the sample correlated with exactly the column it dropped. Both affected renderers are fixed (`csv`, which is also the `default:` format branch, and `html_table`); `json` was never affected. Already-inferred columns keep their position and late-appearing ones are appended, so an export that was correct before is byte-identical now. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 330ebcc98d..66d7aaffa4 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.2.0", + "version": "17.3.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 471dc85aaf..a57706d5b6 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,870 @@ # @objectstack/plugin-security +## 17.3.0 + +### Minor Changes + +- 6171331: fix(plugin-security): `controlled_by_parent` composes across a chain — a child whose master is itself derived is no longer readable and writable org-wide (#11082) + + **BREAKING** access tightening, shipped as `minor` under the repo's + launch-window convention. It denies reads and writes that previously + succeeded — which is the whole point: they were never authorized by any + declaration, and the app author could not tell. + + `controlled_by_parent` (ADR-0055) resolves a detail's access from its master. + #5386 made that resolution fold in the master's ownership and its + `sys_record_share` grants, not just the master's RLS policies. It did not + recurse, and both halves it composes answer "no restriction" for a master that + is **itself** `controlled_by_parent`: + + - the RLS half is `null`, because a derived object authors no policy — + declaring `controlled_by_parent` *is* its policy; + - the sharing half is `null` too: `plugin-sharing`'s `buildReadFilter` opts out + of every model that is not `private`, and `effectiveSharingModel` maps + `controlled_by_parent` to `public`. + + Composed: `null`. The derivation's master query then ran as **system** with an + empty predicate and returned **every master row**, so a two-level chain was + enforced at level one and org-wide at level two. The write half failed through + a separate mechanism with the same result: the master gate asks `canEdit` on + the master row, `checkEdit` returns `abstain` for a `public`-mapped model, and + `abstain` is not `deny` — so it answered `true` for every master row. + + Both halves now walk the chain. The read derivation composes the master's own + `controlled_by_parent` filter as a third layer, and the write gate runs its + three master-edit legs on each hop until it reaches a master that governs its + own rows. The master set is therefore point-for-point equal to what a direct + read of the master returns, at every level, which is the equality #5386 + established for one level. + + This is **not** a blanket refusal for chained declarations: a detail whose + whole chain is reachable stays readable and writable, and the single-level case + is unchanged. Two guards bound the walk and both fail **closed**, never to "no + restriction": a metadata cycle is refused, and so is a chain deeper than 8 + links (a cost ceiling, not a supported-length statement — termination is + already guaranteed by the cycle guard). + + What an app may observe: a detail under a `controlled_by_parent` master that + was reachable before is now reachable only if the caller can reach the whole + chain above it. Apps whose masters are `private`, `public_read` or + `public_read_write` — every `controlled_by_parent` object authored in this + repo — are unaffected. + + +- 25b1b81: Surface "declared ≠ enforced" on package-declared permission sets, and give + operators a sanctioned, audited way to discard a stale environment overlay. + + Field report: an rc→GA upgraded environment can freeze a package's + permission set at a stale snapshot while the shipped artifact keeps + shipping grant changes — silently, with only a boot log counter as a + signal. Two independent mechanisms can cause this, and either (or both + together) can be live on one row: + + - **overlay shadow** — a Studio permission-matrix save on a package-declared + set materializes a `sys_metadata` overlay that shadows every later package + edit to that set, forever, surviving redeploys and restarts; + - **provenance skip** — a `sys_permission_set` row whose `managed_by` column + predates package provenance tracking is treated as environment-authored + and never reconciled with the package. + + `sys_permission_set` now carries `drift_status` / `drift_detail`, recomputed + every boot, naming the set and the cause — a new "Needs Attention" Setup + list view surfaces only sets that actually differ from their shipped + artifact (an in-sync set is never flagged; `drift_status` stays `null`). + + A new "Discard Overlay" Setup action (`POST + /api/v1/security/permission-sets/:id/discard-overlay`) removes a stale + overlay and resyncs the record to the current artifact synchronously — the + supported, audited counterpart to the raw-SQL remediation the field report + had to use. It targets package-declared sets only: a set with no current + package declaration is refused, so a genuinely environment-authored set can + never be discarded by name collision. + + Boot-time auto-adoption of legacy rows and a bulk `os meta + adopt-permission-sets` command remain out of scope (2026-08-20 maintainer + ruling) — the manual SQL adoption recipe stays documented for the rc→GA + provenance-skip case; see the ops runbook. +- e170b0a: Lock package-declared permission sets at the save door; clone to customize (#11513) + + Maintainer ruling of 2026-08-24, recorded verbatim and untranslated: + 「同意 第一步(创业阶段,Salesforce 式)」 — step 1 of the mainstream-platform + comparison: lock the base, clone to customize. + + A Studio/API save that targets a **package-declared** permission set is now + **refused at the server**, with a message that names the sanctioned path — clone + it and edit the clone. Previously the data door translated the write into a + metadata write and left the refusal entirely to the metadata protocol's ADR-0005 + tier gate. That gate is exactly what the documented + `OS_METADATA_WRITABLE=permission` operator hatch switches off, so on a + deployment running with the hatch there was no refusal at all: the save minted a + `sys_metadata` overlay of a packaged set, and boot reconciliation re-projected + that overlay onto the record on every boot, unconditionally, forever — the set + froze at the fork and every future package upgrade of it was ignored, silently. + + **Clone-to-customize** is the sanctioned path and is unchanged: the clone is an + ordinary org-owned set (`managed_by: 'admin'`, no `package_id`, so no upgrade + linkage), and upgrades keep flowing to the package-declared base untouched. + + **Existing forks** get a **detection reading** at boot — count *and names*, + warned loudly, saying outright that nothing was reaped. It reads `sys_metadata` + directly rather than the `customized` column, which is forced `false` on the + exact confounded shape the field report measured (a genuinely package-declared + set whose row's `managed_by` predates provenance tracking). Nothing is reaped, + merged or migrated: disposition of an existing fork is a follow-up reading for + the maintainer, and the per-set remedy remains the explicit, audited + "Discard Overlay" action a human invokes. + + Behaviour deliberately NOT narrowed: + + - an **ordinary org-owned** set is still fully editable (pinned as a control — + a lock that refuses everything would satisfy the refusal pin perfectly); + - the **activate / deactivate** actions still write their column: a bare + `{ active }` patch is row state, not a customization of the definition; + - a `managed_by: 'package'` row with **no artifact behind it** — published + through the metadata door (ADR-0070) and materialized by the ADR-0086 P2 + path — keeps editing in place. That is ADR-0094 D5-R's surviving + `allowRuntimeCreate` neighbour, and `managed_by` is measurably not the + artifact-provenance fact. Provenance is read from the engine SchemaRegistry, + the one source this plugin already calls "package-declared". + + Provenance is **fail-closed**: a read that cannot answer refuses the save rather + than accepting it, and the read is not a name-keyed page over + `sys_permission_set`, so it cannot be truncated into a false "not packaged". +- 5619aac: The packaged-permission-set lock now guards the metadata door as well as the data door. The pre-persistence authoring-gate seam gains a `permission` registration (`registerPackagedPermissionSetLockGate`, new export) that consults the same `classifyPackagedPermissionSet` classifier and throws the same `PackagedPermissionSetLockedError` the `sys_permission_set` write door already uses — one spelling of "package-declared", two doors, one refusal. + + What stops working, and for whom: an operator using the `OS_METADATA_WRITABLE=permission` escape hatch to save a permission set that an installed package declares now gets `403 NOT_OVERRIDABLE` (the refusal names the sanctioned clone path) instead of silently minting a `sys_metadata` overlay whose grants win at read over the package's declaration. This applies hatch open or closed, draft and publish saves alike, and also means stored-overlay maintenance passes (e.g. stored-item migration) report a per-row refusal for such grandfathered forks rather than rewriting them. + + What keeps working unchanged: hatch writes to any name no installed package declares — the hatch's documented per-org/env override capability — land exactly as before, and package-door authoring of workspace-owned sets (definitions living in `sys_metadata`; ADR-0070, ADR-0094 D5-R) stays editable. Package publishes travel the `package-author` channel, which this seam exempts by contract. +- 18b53ac: `SecurityPlugin`'s own report sink is now **console-backed by default** — loud until a host + injects one — instead of being initialised to an empty object. Its fail-closed refusals + (`getReadFilter … denying (fail-closed, #2852)` and `#4467`, `checkAuthoredRowWrite … + abstaining`, the ADR-0123 tenant-wall refusal) previously went nowhere at all on any instance + whose lifecycle had not yet reached the sink binding; they now reach `console.warn` / + `console.error`. A host that injects a logger is unaffected: `start()` assigns `ctx.logger` + over the default, above both of its early bail-outs (#10706), so a degraded boot still reports + through the host. + + **Operator-visible:** a deployment that never injects a sink will begin seeing these refusals + on the console. That is the intended change — the refusal itself is not moving, only whether + anyone can see it. + + Why `minor` and not `patch`: the observable output of a running deployment changes. The + declared shape changes with it — the field's `warn` channel is now non-optional, which is what + #9754 requires of a sink declaring an optional `error`, and what a default of `{}` made + impossible to state honestly. `error` deliberately stays optional (#9754 option C, falsified: + hosts do inject reduced sinks). The maintainer ruled on 2026-08-24 (#10556) that the default + becomes console-backed and that silent-by-declaration is rejected. +- 9735662: fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + + **BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or + `isolated`), shipped as `minor` under the repo's launch-window convention for + breaking changes. Single-org deployments are byte-for-byte unchanged. + + Measured defect (cloud#1509): on a walled multi-tenant SaaS with + `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the + FIRST self-registrant received the cross-tenant `admin_full_access` grant + (`platform_admin`, `isPlatformAdmin: true`) and — because the default-org + bootstrap binds "the platform admin" — was merged into the deployment's + Default Organization as its owner. Whoever curls the public sign-up endpoint + first owned the platform. + + Per the maintainer ruling of 2026-08-23 (verbatim: + 「1509 选择 env 指定 owner 邮箱」): + + - **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. + - **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). + - **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. + - The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + + Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the + operator account's email address before upgrading. Deployments that already + hold a human platform admin are untouched (the bootstrap remains a no-op once + any human holds the cross-tenant grant); the variable governs installs that + have not yet minted their admin. `@objectstack/types` gains the + `resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` + constant; the verify harness declares the owner email (defaulting to its dev + admin) for walled fixtures. + + + +### Patch Changes + +- 0e4e51b: feat(spec): `ActionParamSchema.carryOver` — the declared carry-over param: seeded from the row, rendered as a non-editable summary, submitted verbatim (#11753 ruling, spec half; #11992) + + + + The maintainer's 2026-08-25 ruling on #11753 (recommendation A) declares ONE + carry-over contract instead of a rendering convention: a param may state, in + metadata, that its value is carried through the action dialog rather than + collected from the user. + + - `carryOver: true` — seed from the current row (`defaultFromRow: true` is + required alongside, enforced at parse time), render as a NON-EDITABLE + summary, submit VERBATIM. Unlike `visible: false` — the measured non-answer, + which omits the param from the submission entirely — a carry-over param is + always sent. + - Aliases: `readonly` / `disabled` are refused with guidance naming + `carryOver` (a field's `readonly` means write-path strip, which is exactly + the wrong half here). + - Exemplar (`@objectstack/plugin-security`): the five `clone_permission_set` + JSON facet params (`object_permissions`, `field_permissions`, + `system_permissions`, `row_level_security`, `tab_permissions`) declare it, + so the sanctioned clone path stops offering five prefilled raw-JSON + textareas an admin could hand-mangle into a clone that grants MORE than its + base. `description` stays an ordinary editable param. The send-side contract + is unchanged (#11703 pin 6 stays green). + + The objectui renderer leg (honouring the declaration in `ActionParamDialog`) + is the downstream card tracked on #11753. +- c6c895c: **Perf:** the declared-capability boot seed and the environment permission-set overlay reconciler each pay ONE batched existence read instead of one per item, and stop re-writing rows that already match (#11096, #11097). + + Both were read-then-write reconcilers over a set known in full before their loop started, and both had the shape #10946 removed from the permission-set and position seeders next door: + + - `bootstrapDeclaredCapabilities` issued a `SELECT … WHERE name = ? LIMIT 1` per declared capability, then an `UPDATE` on its own row whether or not anything had changed; + - `reconcilePermissionSetProjection` projected every environment-scope `permission` overlay in a per-name loop, each iteration issuing its own existence `SELECT` inside `upsertEnvPermissionSet` plus an unconditional `UPDATE`. + + On a local file database these loops are invisible. On the remote libsql/Turso database every hosted environment runs, each leg is its own sequential HTTP request, and the capability set is typically the largest of the identity axes — it is the union of every capability every declared package contributes, not a count bounded by the number of permission sets. + + Both now hoist one chunked `{ name: { $in: [...] } }` read out of the loop through `buildExistingByName`, which keeps the tri-state judgement that makes hoisting safe: **a read that could not ANSWER is not the answer "none of them exist"**. A batched read fails for the whole set at once, so collapsing those two would make a boot during a brief outage try to re-create everything; the seeders now decline the names they could not read, and say so. + + **The write-skip is an equality test, and the reconciliation leg is pinned.** A row whose stored value genuinely differs still gets its `UPDATE` — a reconciler that skipped writes outright would show a perfect round-trip count while silently reconciling nothing, so every counting test added here is paired one-for-one with a drift test over the same fixture, and both pairs were ablated to confirm the drift half fails when the write is removed. + + Two behaviour repairs the write-skip REQUIRED, both on the environment door — not optional polish, but corrections the equality test itself demands, verified by ablation (each one made a specific test fail when reverted): + + - **`customized` is now compared, not just written.** The flag is provenance rather than definition, so `recordDiffersFromBody` deliberately does not compare it; skipping on the facets alone would have stopped maintaining a flag the Setup list badges on and the reset action reads. It gets its own comparison term, against the same `managed_by:'package'` condition the write uses. + - **A newly created environment-authored record is no longer born badged "customized".** The INSERT used to stamp the caller's raw overlay opinion (`!!customized`) while the UPDATE branch's rule stamps `false` for a non-package row — those two disagree for any fresh `managed_by:'admin'` row created while its overlay is still active. Before this changeset, that disagreement was invisible: every boot re-wrote every record unconditionally, so the very next reconciliation pass silently overwrote the wrong value back to `false`. Once writes are equality-gated, that disagreement stops being invisible and becomes a REAL, PERMANENT one-boot-late corrective `UPDATE` after every such creation — the "steady state" round-trip count is not actually flat without this fix. Confirmed on this branch: reverting it to `!!customized` fails `#11097 — env overlay reconciliation: round trips > does not grow the steady-state round-trip count` and `#11097 — drift STILL reconciles > only the DRIFTED overlay is written` (both start seeing a real `UPDATE` on the boot immediately after any overlay-backed admin row is created). + + `projectPermissionMutation` also syncs the in-memory evaluator registry on an unchanged record, not only on a write. That sync is not a database round trip, and the evaluator resolves permission sets registry-first — gating it on "a write happened" would have left a steady-state boot enforcing the stale declared body while the record and Setup showed the overlay. + + ⚠️ **This is a behaviour change beyond the write COUNT**, flagged explicitly: today, a brand-new environment-authored permission set with no package baseline can be observed `customized: true` for the one boot between its creation and the next reconciliation pass (or, on the live write-through door, self-heals within the same request). After this changeset it is never observed `true`. The change is required for the round-trip fix's own steady-state claim to hold on this path — the two are not separable — but it is a resulting-STATE change, not merely a write-count change, and is called out here for that reason. + + ⚠️ **No curve number is claimed for either axis.** The hosted `bootstrap-curve.mjs` rig lives in `objectstack-ai/cloud` and neither of these axes has ever been measured on it. What is established is that the code shape is the one measured at slope 4.0000 / R² = 1.000000 on the two sibling loops in #10946, and that the round-trip COUNT is now flat in the number of declared items — which is what the new tests assert, in counts, never in wall time. +- c33f185: Seed the curated platform capabilities with ONE batched existence read, and stop + rewriting rows that already match + + `bootstrapSystemCapabilities` built its whole definition set in memory and then + issued a separate `SELECT … WHERE name = ? LIMIT 1` per definition, followed by + an `UPDATE` that fired whether or not `label`/`description` had changed. On a + local file database that loop is invisible; on the remote libsql/Turso database + every hosted environment runs, each leg is its own sequential HTTP request, + competing for the same boot request budget as everything else. On a stock + installation that is 8 reads plus 8 writes, every `kernel:ready`, to store bytes + already there. + + The curated half's existence read is now one batched `$in`, and the reconcile is + equality-gated. On a steady-state rebuild the curated half costs **1 round trip** + instead of 16, and the write gate sits after the derived-ownership guard, so it + removes the redundant `UPDATE` from **both** halves. + + **The #8470 predicate travels inside the batched query, not applied to its + answer.** The curated half does not ask "is there a row with this name" — it asks + for the platform's own organization-less row (`managed_by: 'platform'` + + `organization_id: null`), and since `sys_capability.name` became unique per + ORGANIZATION those are different questions. Batching the wide question and + filtering afterwards reads every organization's row for every curated name — a + set bounded only by the number of organizations — against a page capped at one + row per name, so the page truncates, and a truncated page reads as "absent", + which inserts. Both harms are pinned as tests rather than argued: without the + predicate the shared name resolves to an organization's row, and two curated + names whose platform rows demonstrably exist come back absent. + + **An unreadable database now declines instead of guessing.** Hoisting a read out + of a loop changes what a failure means: per item a failed read fell through to an + insert the unique index refused, for that one name; batched, one failure speaks + for the whole set. `unknown` is therefore never read as "absent" — the affected + definitions are left entirely alone, counted in the new `unreadable`, and warned + once. This also retires a misdiagnosis: an unreadable database used to make this + half attempt an insert per curated name and then report a `blockedCurated` + collision for each, describing a blocking row nobody ever saw. + + `CapabilitySeedResult` gains `unchanged` and `unreadable`. Reporting "wrote + nothing because nothing differed" separately from "wrote nothing because the + writes stopped working" is what keeps the round-trip count from being satisfiable + by an implementation that simply stopped reconciling. + + **The derived half keeps its per-item read**, and not because it is the smaller + one — it is the half that grows. Its lookup is cross-organization by + construction, and `skippedAuthored` and the `platformStampedInOrg` anomaly signal + are computed from the lowest-id row installation-wide; narrowing it to the + platform bucket answers a different question and would silently reverse part of a + maintainer ruling, while batching it unnarrowed needs an unbounded read. Filed + rather than taken. + + No speedup is claimed. The hosted boot-curve rig lives in another repository and + its axes are permission sets / positions / objects, not this one. What is + established here is the round-trip count and the identity of the row each leg + reads and writes, both pinned in-repo. +- 5cb62d8: Make `clone_permission_set` carry the system permissions, row-level security + and tab permissions it was silently dropping + + The Clone action POSTs its `params` values to `/api/v1/data/sys_permission_set`, + so the params list *is* the payload. It named two of the six definition facets a + `sys_permission_set` row carries — `object_permissions` and `field_permissions` + — leaving `system_permissions`, `row_level_security` and `tab_permissions` + absent from the body. `permissionSetBodyFromRow()` then read each one through + `parseMaybeJson(undefined, …)` and filled the empty default, so cloning a set + that grants `setup.access`, or one carrying row-level security policies, + produced a clone with none of them: record created, success toast fired, and the + missing half discoverable only by diffing the two records. + + The three now travel, in the same JSON-string shape the two listed columns + already used. Nothing about what the door ACCEPTS changed — `permissionSetBodyFromRow()` + already read all six columns; what changed is what the action SENDS. + + This became urgent one commit ago. The save door now refuses an in-place edit of + a package-declared permission set **and its refusal message tells the admin to + clone**, which made this action the platform's own recommended remedy while it + was still dropping three facets — an admin following that instruction lost + grants quietly. The failure direction was fail-closed (fewer grants), which is + why it was quiet. + + `admin_scope` is **deliberately not copied** (maintainer ruling 2026-08-24). + Putting an ADR-0090 D12 delegated-admin authority onto a brand-new + organization-owned set on the admin's behalf is a privilege decision, not a + field copy. The Clone dialog now says so in its description, so the omission + reads to the admin as a decision rather than as the same silent drop — grant a + scope deliberately on the new set if it needs one. + + Pinned by `packaged-permission-set-lock.test.ts` pin 6, which assembles the + clone payload by READING the action's params list rather than restating it, and + asserts each facet by identity against a non-empty value — the empty default + (`[]` / `{}`) is exactly what a "present" assertion would have accepted. Its + control proves the exclusion is live: the base fixture carries a real + `admin_scope`, and the clone still has none. +- 1a68552: perf(security): batch the derived half of `bootstrapSystemCapabilities`, unnarrowed (#11520) + + `bootstrapSystemCapabilities` reconciles two halves. #11451 batched the CURATED + half into one `$in` read carrying the #8470 predicate and left the DERIVED + half — the union of every `systemPermissions` string that nothing declares — + reading one row at a time, so a rebuild cost `1 + derived` round trips. + + That residue was filed rather than fixed for a reason that has since expired. + Two objections stood: narrowing the derived read to the platform bucket answers + a different question and reverses ruled ground, and batching it *unnarrowed* + needed an unbounded read. #11518 removed the second one — `readNamePage` now + asks for one row more than its page budget and reports the overflow as + `truncated` = "could not answer", degrading loudly to the per-item read — so + the wide batched read became bounded without becoming a different question. + + The derived half now consults its own `buildExistingByName` index, built with + **no predicate**: the read emits `{ name: { $in: … } }` under `seedCtx()` + (`{ isSystem: true }`, the same context the per-item read used), and unscoped + `resolveOwnOrganizationRow` returns the FIRST row with no bucket filter — so + the index resolves to the same lowest-`id` row installation-wide that + `tryFind(…, 1)[0]` returned under #4363's `ORDER BY id ASC`. A steady-state + rebuild costs 2 reads at every derived size instead of `1 + derived`. + + ⛔ The first objection still stands and is now pinned rather than only + documented: the derived read is **not** narrowed to `organization_id: null`. + Doing so would silence #8751's `platformStampedInOrg` anomaly signal in exactly + the case its doc says it is counted for, and would seed the platform bucket in + the case #8552 ruled must be left alone. A new test asserts the derived read's + key set is `name` and nothing else. + + One behaviour change, in the direction #10946 chose deliberately for the + curated half: a derived name whose existence read **cannot answer** is now + DECLINED (counted in `unreadable`) instead of being read as absent. The old + `tryFind` swallowed a failed read into `[]`, which routed the name to its + insert branch — a duplicate placeholder wherever the read failed but the write + did not, refused only where the unique index happens to exist, and silent + either way because the `blockedCurated` diagnostic is curated-only. The + `unreadable` counter and its summary warning now cover both halves; the warning + reports the whole definition set as its total rather than the curated count. +- c61ad20: fix(plugin-security): fail CLOSED on a non-object row in the platform-admin promotion predicate (#12515) + + `bootstrapPlatformAdmin`'s local `isHumanUser` decided "is this `sys_user` row a + HUMAN?" with a bare truthiness check followed by two property comparisons: + + ```ts + const isHumanUser = (u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; + ``` + + On a truthy NON-object input (`'usr_alice'`, a number, `true`) both comparisons + read `undefined` and therefore both pass, so the input scored **human**. The + same question's consolidated owner — `isHumanUserRow` in `@objectstack/plugin-auth` + — requires `typeof row === 'object'` and answers **non-human** for those inputs. + Two owners of one question, disagreeing, and the disagreement fell the wrong way + on the security-critical side: this is the copy that performs the + **platform-admin promotion**, so it failed OPEN. Its worst shape is the system + account's own id arriving as a bare string, which the old spelling would have + promoted. + + The predicate now mirrors `isHumanUserRow` — the same `typeof` guard, and a real + boolean return instead of echoing a falsy input back: + + ```ts + const isHumanUser = (u: any) => + !!u && typeof u === 'object' && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; + ``` + + **Why mirroring rather than a stricter rule of its own.** Over-tightening this + predicate has a worse failure mode than the bug: an install that cannot promote + its first admin is locked out of itself. The guard was therefore measured before + it was chosen, not after. Against a real `SqlDriver` over the shipped `SysUser` + declaration, every row a real `sys_user` read yields is a plain object — zero + truthy non-objects, and zero rows whose verdict moves when the guard is added. + The mirrored guard is also already the incumbent on this exact population: + `plugin-auth`'s dev-admin seed filters the byte-identical read (`sys_user`, + `where: {}`, `limit: 50`, system context) through `isHumanUserRow` today. + + **No reachable behaviour changes.** The divergence is unreachable through any + live call site, so this ships as a hardening of malformed-input handling rather + than a behavioural fix. The 14 existing agreement cases in the cross-package + pin are byte-for-byte unmoved; the pin gains the non-object class it previously + had to exclude (it would have failed), which is what now stops the asymmetry + returning — consolidating the two copies into a shared package stays declined, + so nothing else was going to retire it. +- 8af88dd: feat(spec): retire the `allowRestore` / `allowPurge` object-permission bits — declared gates on operations that do not exist (#12497, ADR-0049) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's + recommendation B; **the keys return with the M2 lifecycle initiative** (feature + + RBAC in one batch) — anchor card #1883 stays open. + + `allowRestore` and `allowPurge` claimed to gate `restore` (undelete) and + `purge` (hard-delete / GDPR erase) ObjectQL operations that have never + existed: no destructive lifecycle verb is in the engine's dispatch vocabulary + (pinned by objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106). + Authoring the bits granted nothing — and in the `allowPurge: false` direction + the failure was ADR-0049's worst false-compliance shape: an admin believed a + lock on permanent deletion existed when the operation itself did not. The + sibling `allowTransfer` is **enforced** (#3004, the insert/update `owner_id` + door) and is untouched. + + **What is refused:** authoring either key, with any value — both are + `retiredKey()` tombstones (`ObjectPermissionSchema` is reachable from the + `permission` metadata root, so the tombstone route keeps the removal audible: + a tsc `never` on the input type plus a parse-time prescription). The former + `restore` / `purge` bare-verb aliases now answer with the same prescription + instead of a rename onto a tombstone. The tombstone rides the `.extend()` + clone into `EffectiveObjectPermissionSchema`, so the response-side def carries + the same `[RETIRED]` rows. + + **What stays accepted:** every other object-permission bit parses + byte-identically (`allow*` CRUD, `allowExport`, `allowTransfer`, + `viewAllRecords`, `modifyAllRecords`, `readScope` / `writeScope`). + + **Runtime (plugin-security):** the evaluator's pre-mapping rows + (`OPERATION_TO_PERMISSION` restore→allowRestore / purge→allowPurge) retired in + the same batch — with the bits unwritable, a mapping onto them was a claim + about a surface that rejects authoring. Behaviour is deny-before and + deny-after: a dispatched `restore` / `purge` is refused fail-closed by the + `DESTRUCTIVE_OPERATIONS` backstop, now unconditionally (not even + `modifyAllRecords` reaches an unmapped destructive op — the bypass re-covers + them only when the M2 batch re-adds the rows). `transfer` keeps its row and + its bypass. `describeHighPrivilegeBits` stopped reading `allowPurge` (a legacy + stored value grants nothing, so flagging it guarded nothing real); the + delete/purge/transfer class message is unchanged. + + The retirement kit: + + - `retiredKey()` tombstones + former-alias `guidance` prescriptions at the + schema (`packages/spec/src/security/permission.zod.ts`) + - ADR-0087 registration: retired-key entries + `security/ObjectPermission:allowRestore` / `:allowPurge` (and the + `security/EffectiveObjectPermission` pair for the cloned rows) and the D2 + conversion `permission-allow-restore-purge-removed` (protocol 18), wired + into the step-18 chain — `os migrate meta --from 17` strips the keys from + every object grant in `permissions[].objects` (pure lossless delete; they + never had an effect to lose) + - liveness ledger: both entries flipped to `dead` with the retiredKey evidence + (entries stay — the tombstone keeps the keys in the walked shape, the + `rls.priority` precedent) + - pin tests (`permission.test.ts` — refusal pins asserting the prescription; + `security-plugin.test.ts` — fail-closed pins incl. the legacy-stored-grant + and modifyAllRecords directions; `audience-anchors.test.ts` — the predicate + no longer reads the retired bit) + - generated baselines/docs follow the schema (`authorable-surface/`, + `authorable-defaults/`, spec-changes, upgrade guide, reference docs) + + ## FROM → TO + + ```ts + // before — parsed green; nothing ever read the bits, no operation existed + definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { + allowRead: true, allowEdit: true, + allowRestore: true, // claimed: can undelete — nothing enforced it + allowPurge: false, // claimed: GDPR erase locked — no lock existed + }, + }, + }); + + // after — delete the keys; restore/purge dispatches are denied fail-closed + // until the M2 lifecycle batch ships the operations WITH their RBAC bits + definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { allowRead: true, allowEdit: true }, + }, + }); + ``` + + +- 502ff8b: An organization-less `sys_permission_set` row grants again — #11121 revoked standing access silently + + #11121 made the request-time permission-set loader tenant-scoped so two + organizations holding a row for the same name stop answering each other's + requests. It shipped the second half as a COMMENT — "an organization-less + leftover only where it does not [have its own]" — and the code read `.own` + alone, which by `resolveOwnOrganizationRow`'s own documented contract is never + a residue once an organization is supplied. + + That helper is written for SEEDERS, where refusing to read a residue as + "already seeded" is the entire point. Enforcement wants the opposite reading: an + organization-less row is still a row the principal was granted, and dropping it + revokes standing access with no signal at the moment of loss — the failure this + catalog's own header, and `resolve-authz-context`'s `sys_position` read, both + name as the thing not to do. + + The asymmetry was observable on a single row: its `system_permissions` and + `tab_permissions` kept applying, because that read is unscoped and by id, while + its `object_permissions` and `admin_scope` stopped. One row, two enforcement + planes, opposite verdicts. Every walled deployment carrying pre-#11121 rows — + or any row authored without a tenant, which includes admin-UI-authored sets — + lost those grants on upgrade, reported only as a boot WARN about "leftovers" + that states the catalog is complete. + + Found by cloud's `apps/ee-group-showcase` dogfood suites, which had been failing + four ADR-0111 / ADR-0105 assertions on cloud main while turbo replayed them from + cache. + + Preference order is unchanged, so the cross-tenant bleed #11121 closed stays + closed: this organization's own row still WINS wherever it exists, and a + leftover is consulted only in its absence. #11121's suite covers seeding and the + `sys_position` sweep; the three cases added here cover the loader path it did + not — residue resolves, own beats residue, and the single-posture carve-out is + untouched. Reverting the one-line fix reddens exactly the first of them. +- d7b3963: Export the kernel platform-admin capability declaration from `@objectstack/spec` (`ADMIN_FULL_ACCESS_CAPABILITIES`) and import it in plugin-security's `admin_full_access` permission-set declaration, so exactly one copy of the capability list exists (#11663 Choice 6A, leg L1). Behaviour-neutral: the declared capability set is byte-for-byte unchanged, pinned by test. +- f64668d: fix(plugin-audit,plugin-security): declare sourced bounds on the four keyed text columns that break MySQL schema-sync (#12059) + + Four text columns that a declared index keys on carried no `maxLength`, so + `driver-sql` emitted them `TEXT`. MySQL refuses a TEXT/BLOB column in a key + without a key length (`ER_BLOB_KEY_WITHOUT_LENGTH`): `CREATE TABLE` succeeds, + `ALTER TABLE … ADD INDEX` fails, and the object lands registered-but-broken + with its declared index silently absent. + + | Object | Column | Bound | Producer the bound is derived from | + |---|---|---|---| + | `sys_activity` | `record_id` | 255 | the physical `id` column — `driver-sql` creates every primary key as `table.string('id').primary()`, knex's `varchar(255)` | + | `sys_audit_log` | `record_id` | 255 | same | + | `sys_audience_binding_suggestion` | `package_id` | 255 | `sys_permission_set.package_id` (255), which the same boot pass writes the same value into | + | `sys_audience_binding_suggestion` | `permission_set_name` | 100 | `sys_permission_set.name` (100), the column this value resolves against at confirm time | + + Each bound is derived from a **named producer** and stated in the declaration + so it is vetoable in review (#11374 route A; PR #12058 is the worked + precedent). None of them narrows anything storable: + + - a record id cannot exceed the `varchar(255)` column the id itself lives in, + and the `referenceVia` seed path refuses an unresolvable pointer rather than + storing a natural key verbatim; + - a permission set name longer than 100 is already refused at the write seam + today — measured on a real engine, `ValidationError: API Name must be ≤ 100 + characters (got 101)` — so no set with such a name can exist, and a + suggestion naming one could never be confirmed. + + Measured at the driver level, shipped declaration vs. the same declaration with + the bounds stripped: `record_id`, `package_id` and `permission_set_name` move + `TEXT` → `varchar(255)` / `varchar(100)`, while `id` reads `varchar(255)` in + both — the transitivity premise, read off a real table rather than assumed. + + Existing deployments are not rewritten: a physical `TEXT` column is deliberately + not diffed against `maxLength` (#11431), so no `ALTER` is planned and no value + at rest is truncated. The repair takes effect where the decision is makeable at + all — at `CREATE TABLE` — because no dialect turns a TEXT column into a keyable + one afterwards. + + Each plugin also gains a keyed-text-bounds pin driven through its **own + registration path** (`init()` → the manifest `register({ objects })` call), + rather than a hand-written object list: the platform-objects pin enumerates only + that package's exports, which is exactly why these four columns escaped route + A's sweep after ADR-0029 K2 moved the objects out. +- e1d773e: fix(security): stop reading a truncated existence page as "absent" — the unscoped page cap is now measured, not trusted (#11518) + + `buildExistingByName` (`seed-name-lookup.ts`) is the batched existence oracle the + identity seeders consult in place of a per-item read. Its UNSCOPED page was + capped at `limit: names.length`, which is exact only while one row can exist per + name. Since #8461 / ADR-0120 D1 `sys_capability.name` and + `sys_permission_set.name` are unique **per organization**, and ADR-0066 D1 + explicitly encourages admins to EXTEND the registry inside their own + organization — so one name legitimately carries a row per organization plus the + platform's, and an unscoped page of N names can match far more than N rows. + + The rows that fall off a full page are the highest `id`s under #4363's + `ORDER BY id ASC` tie-breaker, so **whole names vanish from the page** — and a + name missing from the page reads as `absent`, which routes its caller to the + **INSERT** branch. #10103 had already found and repaired exactly this on the + SCOPED arm; the unscoped arm never got the repair, and two seeders on `main` + read unscoped (`bootstrapDeclaredCapabilities`, `permission-set-projection`'s + env-overlay pass). + + ⛔ `names.length * 2` would have been the same defect with a larger constant: + rows-per-name is bounded only by the number of organizations, so no constant + multiplier is correct. Instead the cap stopped being a promise and became a + **measurement** — the read asks for one row MORE than it is willing to hold, and + a page that comes back carrying that extra row is a PREFIX of the answer rather + than the answer. It then joins the module's existing "could not answer" causes + and degrades to the per-item read, the fallback already there for a driver + without `$in`. Both directions are exact: no complete page is ever mistaken for + a truncated one, and no truncated page for a complete one. + + **Behaviour change, stated rather than slipped in.** In the truncating case the + two unscoped seeders go from a **silent wrong answer to a loud slow one**: names + that used to be reported `absent` (and re-inserted, or refused by the unique key + as a collision naming a row nobody ever saw) are now answered correctly, at the + cost of one read per name plus a warning naming the object and the budget it + could not fit inside. An install that does not overflow the budget — every stock + one, where a name carries a single row — issues exactly the same single read it + issued before and says nothing. + + The SCOPED arm keeps #10103's cap exactly (`names.length * 2`), because there the + number is a proven bound rather than a budget: `applyTenantScope` returns this + organization's rows plus organization-less ones, and the declared name index is + unique per organization. It gains the same probe, which turns a scoped page that + overflows that bound — reachable only where the unique index is absent or not yet + created — into the same loud degradation instead of a silent truncation. +- c0714eb: Walled platform-admin elevation now requires the owner-email match to be + VERIFIED, and the bootstrap re-runs on the verifying update (#11343) + + Under walled postures (`group`/`isolated`), `bootstrapPlatformAdmin` matched + the env-declared `OS_PLATFORM_OWNER_EMAIL` against the raw email string on + `sys_user` — with no `email_verified` condition, while email verification is + off by default. #11211 narrowed elevation from "whoever registers first" to + "the declared owner's address" (a real and large narrowing); this closes the + remainder that card #11343 records: in the window before the owner registers, + an account created with the owner's address would still be elevated. + + Two halves, deliberately in one change: + + 1. **The elevation match requires `email_verified`** (fail-closed allow-list + over driver representations; an absent field on an imported/legacy row + reads as unverified). An unverified holder of the owner's address is + refused like any stranger — new reason `walled_owner_not_verified`, logged + loudly with the unblock in the line. Never falls back, same direction as + the undeclared-owner refusal. + 2. **The bootstrap-replay middleware now also fires on `sys_user` updates + touching `email_verified` / `email`** (trigger set extracted as + `shouldReplayBootstrapFor`, consumed by the middleware and its pins alike). + Verification is an UPDATE — with the old insert-only replay, requiring + verification would have refused the genuine owner at sign-up and then + never looked again, leaving the platform without any administrator. + + `single` posture is untouched both ways: first-user promotion (ruled + reasonable in #11184) does not gain a verification requirement, and the + owner-email variable is still never consulted there. Both directions are + pinned: the unverified holder is refused AND the verified owner is elevated — + including across the refuse-then-verify-then-re-run sequence. + + The seeded dev admin (`maybeSeedDevAdmin`, dev-only) is now provisioned with + `email_verified` stamped: it is created by the deployment's own boot command + with operator-known credentials — the same trust shape as a trusted-SSO + insert, not an unknown self-registrant — so walled dev/harness boots keep a + promotable declared owner. The generic sign-up path is unchanged. +- e3f056f: Stop the per-organization catalog pass from reporting the platform's own + permission sets as "pre-fix" leftovers with a remedy that recreates them + + On a fresh walled deployment (`OS_TENANCY_POSTURE=isolated`, three + organizations) the boot log warned, once per organization, that *"pre-fix + organization-less `sys_permission_set` rows are still present"* and offered + *"re-initialize the deployment, or adopt each row by hand"*. Both halves were + wrong there: + + - **Nothing was pre-fix.** The eight rows it named (`admin_full_access`, + `organization_admin`, `organization_admin_no_bypass`, `member_default`, + `viewer_readonly`, `mcp_agent_data_read`, `mcp_agent_data_write`, + `mcp_agent_restricted`) were minted 1.3 s earlier — before the deployment's + first organization existed — by `bootstrapPlatformAdmin`, the fifth seeder, + which the #10103 ruling deliberately left outside the per-organization + conversion. An operator on a deployment hours old was told they were carrying + legacy state they never had. + - **Its first remedy did not terminate.** Re-initializing a fresh walled + deployment mints exactly those eight rows again on the next boot, so only the + hand-adoption branch ends — and that one hands a platform-wide bucket to a + single tenant. + + The pass now separates the two classes it was conflating and reports each with + the remedy that fits, carrying a machine-readable `origin` + (`'platform-bucket'` / `'pre-fix-residue'`) beside the named rows: + + - the **platform bucket** — names an organization-less writer still seeds on + every boot — is reported as what it is, states that this organization's own + copies were created and no action is required, and says plainly that + re-initializing does *not* clear it; + - a **genuine pre-fix leftover** keeps the original wording and the original + remedy, unchanged. + + Membership is decided by name rather than by `managed_by`, because the question + the remedy turns on is "will a re-initialized deployment have this row again?" + — true for these names whatever provenance the current row carries (a + pre-#8692 install stores `'admin'` on the very same names). It falls back to the + shipped `defaultPermissionSets`, so a host that never threads the new + `platformBucketNames` option still classifies correctly; the option exists for + a host that overrode `SecurityPluginOptions.defaultPermissionSets`. + + `bootstrapPlatformAdmin` also declares what it wrote: under a walled posture it + now logs that the platform defaults were seeded *without* an organization and + that each organization's copies come from the catalog pass. The rig's boot line + read `{"seeded":8}` with nothing to indicate the rows carried no organization + at all, so the operator's first sight of them was the warning above. + + **No behaviour change to the seeding itself.** The eight rows are still minted, + still organization-less, still unreaped — that is the ruled outcome of #10103 + (2026-08-20), and `PLATFORM_ADMIN` is derived from an unscoped grant pointing at + the `admin_full_access` row *by row id*, so removing them would silently demote + every platform admin. Whether the platform bucket should be materialized per + organization remains the maintainer's open call, not this change. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 480c0d8187..a92530c128 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.2.0", + "version": "17.3.0", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack \u2014 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 1475556a41..96714408d3 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,227 @@ # @objectstack/plugin-sharing +## 17.3.0 + +### Minor Changes + +- 599515d: Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id. + + The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched. + + The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test. +- 18b53ac: ⚠️ **Published-contract break for external hosts.** The `logger` option on the three + PUBLICLY EXPORTED options types — `SharingServiceOptions`, `ShareLinkServiceOptions` and + `SharingRuleServiceOptions` — now **requires** a `warn` channel, and its members carry real + signatures (`(msg: any, ...rest: any[]) => void`) instead of bare `Function`. A host that + constructs any of these services with `{ logger: { info, error } }` compiles today and stops + compiling after this release; add `warn` (or drop the `logger` option) to migrate. Nothing + about the services' runtime behaviour changes, and no call site inside this package moved: + the tightening was measured at ZERO compile errors within `plugin-sharing`, so the whole cost + falls on hosts, which is why it is declared here rather than shipped as a patch. + + Why: #9754 rules that a sink declaring an optional `error` must declare a NON-optional + `warn`, so a durability report always has somewhere to land — an optional `error` beside an + optional `warn` is a contract that permits silence. These three sinks were red against that + rule from the day it was written and were only invisible to its checker until #11069 taught it + to read bare `Function` as a channel. The maintainer ruled on 2026-08-24 (#10556) that they + tighten rather than stay baselined, shipped `minor` with the break named here. + + The bare-`Function` spelling was also its own defect: `Function` is not assignable to a + concrete signature, so `record-orphan-cleanup.ts` could not tighten its own `MinimalLogger` + while these producers stayed loose (#10692). That producer-side blocker is now clear. +- 3194c91: ⚠️ **BREAKING (published parameter tightened):** `sweepOrphanedRowsByRecordExistence` — + publicly exported from `@objectstack/plugin-sharing` — now types its optional `logger` + parameter as `{ info?, warn }` with real signatures (`(msg: any, ...rest: any[]) => void`) + and a **required** `warn`, replacing the old `{ info?: Function, warn?: Function }`. A host + that passed this function a logger without a `warn` member (for example an + `{ info, error }`-only literal) compiles today and stops compiling after this release. The + one-line fix: add a `warn` callback to that logger object — or omit the `logger` argument + entirely, since the parameter itself stays optional. Nothing about the sweep's runtime + behaviour changes, and no call site in this repo moved: both in-package callers forward the + owning services' `logger` options, whose `warn` is already required since the producer + tightening (`SharingServiceOptions` / `ShareLinkServiceOptions` / + `SharingRuleServiceOptions`) shipped in the previous release. + + Why: every report this sweep emits lands on `warn` — the "could not check whether records + still exist", "stopped early" and "revoked N rows" lines — so a logger without a + guaranteed `warn` is one the sweep can lose its ONLY output into. That is #9754's + permit-silence shape, one module downstream of the producers #10556 tightened, and the + maintainer ruled (#10692, 2026-08-25) that this package's logger contracts refuse it + loudly at compile time rather than keep it. The bare `Function` members were also their + own defect: they documented no call shape and caught no arity mistake. The refusal is + pinned at compile time in `logger-required-warn.pin.ts`, which also pins the three + publicly exported options types above. + + Breaking ships as `minor` per the launch-window convention + (`scripts/check-changeset-no-major.mjs`). + + + +### Patch Changes + +- 9e72090: fix(sharing): honour the declared `organizationId` in `managerOf` (#10231) + + `ITeamGraphService.managerOf(userId, organizationId?)` declares an organization + parameter. `TeamGraphService.managerOf` spelled it `_organizationId` and + discarded it, and the `BusinessUnitGraphService` standalone fallback read + `sys_user` the same unscreened way — a declared-but-unenforced parameter on a + security seam, while `expandRoleUsers` on the same class applied + `organization_id` to its own read. + + Both now apply the screen #10153 landed for the identical column + (`sys_user.manager_id`) on the approvals side: a manager who is **provably** + outside the caller's organization — membership rows exist for him, none of + them in that organization — is dropped. The read is `sys_member`, because + `sys_user` is the global better-auth identity table and carries no + `organization_id` at all; filtering the `sys_user` read on a column that does + not exist would match nothing and silently return `null` for every lookup. + + The screen is fail-open on an ABSENT tenancy fact (no membership rows, or the + membership read failed) and issues no query at all when no organization is in + play, so callers that pass nothing — which is how the parameter is used today — + are byte-identical to before. The manager cache key is now organization- + qualified; a user-keyed cache would have served one screened `null` to every + unscoped reader behind it. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [0010797] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c5b9ccc] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 8fd0d866f9..9411a3a5a4 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.2.0", + "version": "17.3.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 25e8031234..87f786af51 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,154 @@ # @objectstack/plugin-webhooks +## 17.3.0 + +### Patch Changes + +- 3dafd8c: Fixed the `sys_webhook` object's `ja-JP` display name (`label`) to ウェブフック, matching the already-Japanese `pluralLabel` and the bundle's own help prose (`object_name.help`, `triggers.help`), which both use the same term. Previously `label` was a stale `Webhook` fill left over from a prior source revision, so the object rendered its own name two different ways within the same locale in the Setup/admin UI. `description` (which uses "Webhook" inside a longer Japanese sentence) and the `Webhook ID` field label are unchanged — this only renames the object itself. `zh-CN` is deliberately untouched: it uses `Webhook` for both slots on purpose, as there is no established Chinese rendering of the term in this codebase. +- 598b7ec: fix(i18n): re-translate the five leaves that served a superseded source revision (#12065) + + `os i18n extract` merges gaps only, so a revised source string leaves the previous + revision standing in every translated locale — in sync by key, green under + `check:i18n` and counted as translated by `check:i18n-coverage`. The five leaves + `check:i18n-stale-fill` froze in its baseline are re-translated here from the + **current** `en` source, and the baseline is ratcheted to empty in the same change. + + User-visible admin/Setup help text changes in `es-ES`, `ja-JP` and `zh-CN`: + + - `dataset.fields.measures.helpText` (metadata forms) — all three locales promised a + `"certified"` governance flag that was removed from the declaration in 16.0. + - `sys_webhook.fields.method.help` — all three locales served the pre-revision method + enumeration after the source became a prose description. + - `sys_webhook.pluralLabel` — `ja-JP` was an untranslated Latin fill and is now + Japanese; `zh-CN` keeps `Webhook`, which is the term this bundle's own Chinese prose + uses and which carries no plural inflection. + - `sys_http_delivery.fields.attempts.help` — `es-ES` / `ja-JP` held an English fill and + `zh-CN` a translation of the same superseded source; all three now carry the + PARKED / terminal-row clause the source documents. + - `sys_notification_subscription.fields.principal.help` — the selector list was missing + the `owner_of:object:id` and bare-email forms in all three locales. + + No schema, export or runtime behaviour changes: translated-locale leaf values only, + plus the shrink-only ratchet baseline. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [b6d9432] +- Updated dependencies [3b5f036] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [e577445] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [d9cf78e] +- Updated dependencies [1a47a53] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [ffbb7a1] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/service-messaging@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index ba903777d4..5c81f18a0a 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.2.0", + "version": "17.3.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 d253871141..010a82d43e 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,209 @@ # @objectstack/dogfood +## 0.0.43 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [74cee59] +- Updated dependencies [399ecad] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [56d3c7a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [5cb62d8] +- Updated dependencies [dcb10a5] +- Updated dependencies [6171331] +- Updated dependencies [0010797] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [f4e7ae5] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [3dafd8c] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e170b0a] +- Updated dependencies [e5ce2ed] +- Updated dependencies [9e72090] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [4ceae8a] +- Updated dependencies [48318f7] +- Updated dependencies [b6d9432] +- Updated dependencies [3b5f036] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [e577445] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [017130a] +- Updated dependencies [466b389] +- Updated dependencies [c5b9ccc] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [d9cf78e] +- Updated dependencies [1a47a53] +- Updated dependencies [2a6122b] +- Updated dependencies [8649b39] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [599515d] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [911da5f] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [18b53ac] +- Updated dependencies [e1d773e] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [18b53ac] +- Updated dependencies [3194c91] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [ffbb7a1] +- Updated dependencies [a41069b] +- Updated dependencies [122ef38] +- Updated dependencies [91b1342] +- Updated dependencies [88b9d74] +- Updated dependencies [96940b7] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] + - @objectstack/spec@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/service-analytics@17.3.0 + - @objectstack/verify@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/service-storage@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/plugin-webhooks@17.3.0 + - @objectstack/plugin-sharing@17.3.0 + - @objectstack/mcp@17.3.0 + - @objectstack/service-messaging@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-audit@17.3.0 + - @objectstack/plugin-email@17.3.0 + - @objectstack/example-crm@4.0.95 + - @objectstack/example-showcase@0.3.17 + - @objectstack/connector-mcp@17.3.0 + - @objectstack/connector-openapi@17.3.0 + - @objectstack/connector-rest@17.3.0 + ## 0.0.42 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 0a7b5db2b4..9034b11d87 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.42", + "version": "0.0.43", "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 d2bdead110..34a1006f76 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,114 @@ # @objectstack/downstream-contract +## 0.0.41 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + ## 0.0.40 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index 190948fb3e..7ab3c3dc79 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.40", + "version": "0.0.41", "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 a23cddd4e8..d67c466baf 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,20 @@ # @objectstack/http-conformance +## 0.1.3 + +### Patch Changes + +- Updated dependencies [983edf1] +- Updated dependencies [f658793] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [af56546] +- Updated dependencies [a17da05] +- Updated dependencies [7131f12] +- Updated dependencies [b72db01] +- Updated dependencies [f7b25c5] + - @objectstack/core@17.3.0 + ## 0.1.2 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 7a4eb6cfe6..6646106b27 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.2", + "version": "0.1.3", "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 b02780c2ab..fe10c7d391 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,881 @@ # @objectstack/rest +## 17.3.0 + +### Minor Changes + +- 25b1b81: Surface "declared ≠ enforced" on package-declared permission sets, and give + operators a sanctioned, audited way to discard a stale environment overlay. + + Field report: an rc→GA upgraded environment can freeze a package's + permission set at a stale snapshot while the shipped artifact keeps + shipping grant changes — silently, with only a boot log counter as a + signal. Two independent mechanisms can cause this, and either (or both + together) can be live on one row: + + - **overlay shadow** — a Studio permission-matrix save on a package-declared + set materializes a `sys_metadata` overlay that shadows every later package + edit to that set, forever, surviving redeploys and restarts; + - **provenance skip** — a `sys_permission_set` row whose `managed_by` column + predates package provenance tracking is treated as environment-authored + and never reconciled with the package. + + `sys_permission_set` now carries `drift_status` / `drift_detail`, recomputed + every boot, naming the set and the cause — a new "Needs Attention" Setup + list view surfaces only sets that actually differ from their shipped + artifact (an in-sync set is never flagged; `drift_status` stays `null`). + + A new "Discard Overlay" Setup action (`POST + /api/v1/security/permission-sets/:id/discard-overlay`) removes a stale + overlay and resyncs the record to the current artifact synchronously — the + supported, audited counterpart to the raw-SQL remediation the field report + had to use. It targets package-declared sets only: a set with no current + package declaration is refused, so a genuinely environment-authored set can + never be discarded by name collision. + + Boot-time auto-adoption of legacy rows and a bulk `os meta + adopt-permission-sets` command remain out of scope (2026-08-20 maintainer + ruling) — the manual SQL adoption recipe stays documented for the rc→GA + provenance-skip case; see the ops runbook. +- d2b2381: **Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095). + + `saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one. + + The maintainer ruled a **split**, and the two halves are not the same fix: + + - **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON. + - **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first. + + For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes. + + The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them. +- 1394768: **BREAKING (accept-set tightening)**: `RestServer` now parses `config.api` + against `RestApiConfigSchema` at construction instead of casting to it, so a + deployment whose `api` config the spec rejects fails loudly at boot rather than + booting into a structurally broken URL space (#11637). + + The regex was always declared. `packages/spec/src/api/rest-server.zod.ts` + constrains `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v1')`, and + `version` is spliced into `getApiBasePath()` — the base of **every** route this + server mounts. Nothing ran it: both hops into `@objectstack/rest` are casts + (`config.api as any` in `rest-api-plugin.ts`, then `as Partial` + in `normalizeConfig`), the plugin declares no `configSchema`, and the kernel's + `PluginConfigValidator` could not have covered it either — `PluginLoader` + invokes its own `validatePluginConfig(metadata)` with **no config argument** and + returns early, and `createRestApiPlugin` closes over its config so the kernel + never receives it. `??` was the only guard left, and `??` substitutes + `null`/`undefined` only. Measured on the pre-fix code: `api.version: ''` + constructed happily and mounted the whole API — `/data`, `/meta`, `/discovery`, + `openapi.json` — under `/api//`. + + **Newly refused, all at `new RestServer(...)` / `createRestApiPlugin().start()`:** + + - `api.version: ''` — the reported case. Refused with + `Invalid string: must match pattern /^[a-zA-Z0-9_\-\.]+$/`. + - `api.version` carrying any character outside `[a-zA-Z0-9_-.]` — `'v1/beta'` + (which spliced an extra path segment into every route), `'v1 beta'`, `'v1%2F'` + and so on. + - A declared key written with the wrong type: `api.enableCrud: 'yes'`, + `api.basePath: 42`, a malformed `api.documentation` / `api.responseFormat`. + + **Deliberately NOT refused** — the narrowing is exactly what the schema + declares, and no more: + + - `api.projectResolution`. The declared enum is + `z.enum(['required', 'optional', 'auto'])`, but the value this platform + actually ships is **`'none'`**: `@objectstack/runtime`'s + `StandaloneStackResult.api` declares the literal type + `{ enableProjectScoping: false; projectResolution: 'none' }`, and `os serve` + forwards it into this config unchanged (`apiConfig.projectResolution ?? 'auto'` + does not fire — `'none'` is not nullish). Three packages disagree about this + key's vocabulary and have done so silently for exactly as long as nothing ran + the schema. Parsing it here would not settle that disagreement, it would turn + every `os serve` boot into a crash, so the key is `.omit()`ed and the + divergence is filed as #11999. Which spelling wins is a contract question + about project-scoping semantics that this seam cannot answer. + - `api.requireAuth`. The retired key (#3963) is `.omit()`ed from the + validation: it keeps the warn-and-ignore posture `rest-api-plugin.ts` gives + it, and `tsc` still refuses it at any typed authoring site. Converting that + warn into a boot failure is #3963's decision to make, not this seam's. + - Keys no schema in `packages/spec` declares, `api.enableSearch` first among + them. The parse is run for its verdict only and its output is **discarded** — + `RestApiConfigSchema` is not `.strict()`, so a non-strict `z.object()` strips + what it does not declare, and consuming the parsed value would have silently + turned search back on for a deployment that turned it off. + - `api.basePath: ''` — a bare `z.string()` with no declared constraint stays + accepted. + - `crud`, `metadata`, `batch` and `routes`. Those sub-objects are still cast, + not parsed, and carry unenforced constraints of their own + (`batch.maxBatchSize: z.number().int().min(1).max(1000)`, the + `routes.nameTransform` enum). Same defect class, filed separately — this + change deliberately puts one narrowing in front of contract review, not five. + + **Migration.** Delete or correct the offending key; the refusal names the path, + the declared rule that rejected it, and why an empty version is not survivable. + A deployment that meant "no version segment" wants `api.apiPath: '/api'`, which + sets the base outright and is unconstrained. + + **In-repo blast radius, measured repo-wide.** The census is mechanical, not a + reading: 173 files scanned, 316 `api: { … }` blocks brace-matched, every scalar + literal written at each of the 14 declared keys parsed against the schema this + seam runs. **Two values are refused, and both are the deliberate `''` cases in + this change's own pin file.** Every other in-repo literal is accepted, including + all four `projectResolution` spellings in use and the 96 fixtures carrying the + retired `api.requireAuth`. Nested `documentation` / `responseFormat` literals + exist only in `packages/spec`'s own schema tests, which never construct a + server. Of the 237 construction sites repo-wide, exactly one feeds computed + values (`os serve`), traced to the typed literal in `@objectstack/runtime` + above. One in-repo pin had to be retired: `rest-openapi-route.test.ts`'s falsy + `api.version` case, which carried its own written instruction to retire if + normalization ever started rejecting it, replaced here by a pin on the refusal. + + ⚠️ The first version of this census was scoped to `packages/rest` and missed the + five `packages/cli` e2e boots that go through `os serve`; CI caught it. The + radius that matters is every package that CONSTRUCTS a REST server, not the + package the change lives in. + + +- 7986d97: Retire compound-name metadata addressing (`/meta/:type/:section/:name`) + + Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. + Stage 1 declared the item-name grammar and refuses every slash-bearing name at + the publish door, so the routes removed here addressed only names that can no + longer be created. + + **BREAKING — three public REST routes stop answering:** + + | stops answering | use instead | + | :-- | :-- | + | `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | + | `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | + | `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | + + Each retired route folded its `:section` and `:name` segments back into one + slash-bearing key (`views/all_leads`) that the protocol layer then treated as a + single opaque string — the section half was never stored, filtered or + enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. + + The `@objectstack/runtime` dispatcher stops folding in the same way: its + `/meta` handler requires exactly two path segments for an item and three for + `…/published`, instead of re-joining every trailing segment. A `/meta` path + that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than + falling through to the adapter's anonymous 404. + + **FROM → TO for callers.** Address every item through the single-segment route + and percent-encode the name: + + ``` + GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads + ``` + + `@objectstack/client` now calls `encodeURIComponent` on every `/meta` item + address, so SDK callers need no change: the SDK already sends the new spelling. + Encoding is a **no-op** for every name the item-name grammar admits (lowercase + snake_case segments, optionally dot-qualified), so the bytes on the wire are + unchanged for every name that can be written today. + + A pre-grammar **residue** row whose stored name contains a slash remains + readable, writable and deletable: `%2F` matches the single-segment pattern and + the parameter is decoded back to the stored spelling before the handler runs. + Nothing that could be stored has become unaddressable. + + Two SDK doc comments that promised "compound names pass through unencoded" + (`meta.getPublished`, `meta.publishItem`) are corrected, and the + `SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored + at the compound door and published live — is closed at the source: there is one + door, and it reads every member of the options bag. + + + +### Patch Changes + +- 6747718: fix(rest): `POST /analytics/dataset/query` relays a producer-declared 5xx instead of collapsing it to `500 ANALYTICS_QUERY_FAILED` (#11718) + + **Response-contract change on a shipped public route.** The door is + `POST /api/v1/analytics/dataset/query` (and its environment-scoped twin). An + error whose producer declared a 5xx `status` now reaches the client with **that + status and that ADR-0112 `code`**, where the route previously answered a + hand-built `500` `ANALYTICS_QUERY_FAILED` for every one of them. + + Measured door-to-door before the fix, one error object, both handlers driven in + process: + + | face | answer | + |---|---| + | `POST /api/v1/data/:object` | `503` — `{"error":"Internal server error","code":"SERVICE_UNAVAILABLE"}` | + | `POST /api/v1/analytics/dataset/query` | `500` — `{"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` | + + `/data` is the reference and does not move. Its relay is #5582's ruling — `502` + and `503` are `isExpectedDataStatus` lifecycle outcomes that proxies and retry + policies read differently from a `500`, so collapsing them destroys the + declaration — and that ruling never reached this route because the analytics + catch built its 5xx envelope by hand. The **sibling** analytics face + `/analytics/query` already relayed both halves through + `dispatcher-plugin.errorResponseBase`, so this door was the only one of three + overwriting a producer's declaration. + + The repair imports `/data`'s own arm rather than restating it: the branch is + lifted into `declaredServerFaultAnswer` in `error-response.ts` and read by both + doors, the same way the 4xx arm already imports `classifiedRefusalAnswer`. A + third local opinion at this boundary is how the two faces came to disagree. + + **Not a re-opening of #5352/#5367/#5811 — the prose is still withheld.** A + declared server fault's message is still replaced by the generic sentence, from + the same shared arm, and the full original text still reaches the operator: the + `logError` line runs *before* the relay branch and is unconditional, so a + producer cannot buy its way past the operator's log with a declared status. What + moves is the classification the producer declared and this route was + overwriting. + + **What callers see change:** + + - A declared `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` → `503` + `SERVICE_UNAVAILABLE` (was `500` `ANALYTICS_QUERY_FAILED`). + - An unregistered declared code demotes exactly as `/data` demotes it — `503` + `{"code":"SERVICE_UNAVAILABLE","declaredCode":"WAREHOUSE_UNAVAILABLE"}` (#9232). + - `read-scope-sql`'s ten fail-closed RLS refusals answer `500` + `READ_SCOPE_COMPILE_FAILED` instead of `500` `ANALYTICS_QUERY_FAILED`. Their + 2026-08-06 ruling is untouched in substance — still a SERVER fault, still + `500`, still with the RLS policy content withheld from the body and intact in + the log — and the code they now carry is the one they declare and the one the + sibling `/analytics/query` face has always shipped to clients. + + **Unchanged:** an *undeclared* fault. No declared status means nothing to relay, + so it keeps `500` `ANALYTICS_QUERY_FAILED` and #5667's tiering, which leaves a + self-authored fault readable. A declared **4xx** is untouched — that band is + arms ① and ①b, and the half-envelope rule (a 4xx status with no code invents no + code) still stands. +- 7181101: Serve a sandboxed hook's own refusal sentence on the bulk write routes, instead + of the QuickJS debug wrapper + + A hook's `throw new Error('删除被阻断…')` is a deliberate business rule, and + `classifyDataError`'s sandbox unwrap door exists precisely so the end user sees + only that sentence — the ` '' threw: ` prefix "belongs in + server logs", in the door's own words. Six write routes never reached the door. + Measured against the real route handlers: `PATCH /api/v1/data/:object/:id` + answered `Opportunity is closed.` while `POST …/batch`, `…/createMany`, + `…/updateMany`, `…/deleteMany` and `…/:id/clone` answered + `hook 'guard' threw: Error: Opportunity is closed.` — one hook, one refusal, a + different sentence depending on which route the caller happened to use. + + The branch is `resolveErrorResponse`'s declared-status passthrough, which is + checked *before* it delegates to `mapDataError` and answered its 4xx arm from + `error.message`. It now reads the business text through `sandboxBusinessMessage` + — the unwrap door's own two conditions (a non-empty string `.innerMessage`, and + not a `isScriptFaultMessage` crash) named once so the two doors ask the same + question. + + **Not a reorder.** The passthrough's own docblock argues the ordering: handing a + declared 5xx to `mapDataError` re-labels it from the message TEXT (the + overlay-delete fault comes back `404 OBJECT_NOT_FOUND` and stops being logged), + so the arm stays exactly where it is and keeps deciding the status. Only the + sentence it reads changes. #5437/#5582's unconditional 5xx prose withhold is + untouched — a sandbox refusal declaring a 5xx still answers with the generic + text, pinned on both spellings. + + What this restores is an invariant the same docblock already asserts. Its #7525 + paragraph says an error declaring `statusCode` instead falls to `mapDataError`, + "So the two doors already agree on the wire answer." For a sandbox refusal that + was false — `statusCode` was unwrapped and `status` was not — which is the + two-spellings asymmetry this card was filed on. The doors agree again, pinned + door-to-door across the whole 4xx band rather than asserted in a comment. + + **Bump level: `patch`, argued rather than defaulted.** The change is to message + TEXT on shipped routes, so the level is not automatic. It is a patch because + nothing about the envelope's contract moves: same status, same `code`, same + field set, no request newly accepted or refused. The delta is that one string + loses a debug prefix that this boundary already declares must never be on the + wire, and that the single-row routes never emitted — so no client could have + been reading it uniformly in the first place. Keying on the prefix would mean + substring-matching prose that is localised and deliberately reworded over time, + which is the practice the ADR-0112 `code` vocabulary exists to remove. + + `POST /api/v1/analytics/dataset/query` — the seventh row — needed its own + repair: it builds a `{ code, message }` envelope inline and touches neither + door. It now imports the same `sandboxBusinessMessage` rather than re-deriving + the unwrap, so the analytics face and the `/data` face cannot answer one refusal + two ways. Both of its client emissions are covered (the declared-4xx envelope + and the `500 ANALYTICS_QUERY_FAILED` fallback); `logError` still receives the + whole error and `looksLikeInternalErrorLeak` still reads the raw text, so the + operator's copy and the leak heuristic are untouched. + + `POST …/import` and `GET …/export` exit through `handleRouteError` like the bulk + routes, so they are repaired by the same change — measured rather than assumed. + + **Measured and deliberately NOT repaired here**, each recorded so it is not + rediscovered as new: the record-share routes (`…/:id/shares`, list/grant/revoke) + are a third branch again — `respondSharingError` classifies by + `message.startsWith(CODE)` and its fallback interpolates `error.message` into a + hand-built `500`, ignoring a declared `status`/`code` entirely. And on the + analytics route an *undeclared* hook refusal answers `500` where `/data` answers + `400`; only the sentence was corrected, the status disagreement is a separate + defect. Both are filed as their own issues. +- ce744bc: fix(rest): `?mode=draft` now stages on the compound-name metadata write door + + `PUT /api/v1/meta/:type/:section/:name` — the compound-name door, the one you + reach with a name like `views/all_leads` — built its `saveMetaItem` request + field by field and `mode` was not one of the fields. Its single-segment twin + `PUT /api/v1/meta/:type/:name` has read that parameter all along. The parameter + was never refused here, only dropped, so the request was answered `200` and + published **live**. Both doors now read it. + + **Two behaviour changes, and both can be observed by an unchanged caller.** + + **1. `?mode=draft` on this door changes OUTCOME, not acceptance.** The request + was accepted before and is accepted now; what moved is what it does. + + | Request | Before | After | + | --- | --- | --- | + | `PUT /meta/object/crm/task?mode=draft` | `200`, `"state":"active"` — the live row overwritten, nothing staged | `200`, `"state":"draft"` — a staged row written, the live row untouched | + + If you send `?mode=draft` to a compound name today and rely on the write going + live — for instance because you never call `POST /meta/:type/:name/publish` — + those writes stop taking effect immediately and start waiting for a promotion. + Drop the parameter to keep publishing straight away. `mode=publish`, an + unrecognised `mode=`, an empty `mode=` and no `mode` at all are all unchanged: + they publish, exactly as before. The spelling test is the twin's, `draft` + case-insensitive. + + ⚠️ **The draft you can now stage has no per-item REST promotion door in this + arity.** `POST /meta/:type/:name/publish` is mounted for single-segment names + only, while its read twin `GET /meta/:type/:section/:name/published` is mounted + for both — so a compound-named draft is writable and readable over REST and not + promotable there. Until that route exists, promote through + `POST /packages/:id/publish-drafts` (whole-package) or the runtime dispatcher's + own `meta.publish` verb. Tracked in #11932; this release does not change it. + + **2. A repeated `?mode` is now REFUSED where it was accepted.** This narrows + what the door takes. `?mode=draft&mode=draft` arrives as an array; the + `typeof === 'string'` test is false for it, so before this change it fell back + to publishing live under a `200`. It is now answered `400` + `{ "error": { "code": "VALIDATION_ERROR" } }` and nothing is written — the + #6877 guard this door already applied to `force` and `package`, extended to the + parameter it just gained, and the same answer the single-segment twin has given + for a repeated `mode` since #6877. A single occurrence encoded as an array + (`?mode=draft` once) is still accepted; the guard unwraps rather than + blanket-refusing. + + Nothing else on the door moved: `?force`, `?package`, the `meta-envelope` + write face, the `manage_metadata` gate and the `501` envelope are untouched, + and the single-segment twin is untouched. +- 7c0d0c3: fix(spec,rest): give `api.enableSearch` a declared seat, and stop reading runtime-honoured config keys through `as any` (#11983) + + `api.enableSearch` was a live REST config key with no declared seat: + `RestServer.normalizeConfig` read it through `(api as any)` and honoured it + (`enableSearch: false` really unmounted the search endpoints), but no schema in + `packages/spec` declared it. Because `RestApiConfigSchema` is not `.strict()`, + its own parse **stripped** the key — measured: + `RestApiConfigSchema.parse({ version: 'v1', enableSearch: false })` returned an + object with no `enableSearch` property at all — so any consumer of the parsed + config silently got search turned back on for a deployment that turned it off + (the ADR-0104 silent-strip class). It also forced #11637's construction-time + parse to be validation-only, discarding the parsed value. + + - `RestApiConfigSchema` now declares + `enableSearch: z.boolean().default(true)` beside `enableOpenApi`, with the + runtime's existing default. The opt-out now survives the key's own + contract's parse (pinned), and a TypeScript author can write + `api: { enableSearch: false }` without a cast. + - `packages/rest`'s `normalizeConfig` drops all three `as any` reads: the + newly declared `enableSearch`, the already-declared + `metadata.maskObjectFields` (its declared seat landed separately; the cast + was stale), and the long-declared `enableOpenApi` (stale residue from + before its declaration). `NormalizedRestServerConfig.api.enableSearch` is + now a required boolean like its siblings. + + No runtime behavior changes: defaults are identical (`enableSearch` on, + masking on per ADR-0106 D8, OpenAPI on); this change moves the keys from + cast-reachable to declared = enforced. +- 1f6d047: fix(rest): classify a refusal by what the producer declared, not by how its message starts + + The three record-share routes and `POST /api/v1/analytics/dataset/query` each + built their error answer by hand and shared no branch with the door every + `/data` face reports through, so one refusal got a different wire answer + depending on which route caught it. Both now ask that door first, through a + single new seam (`classifiedRefusalAnswer`), for a refusal the **producer + classified** — a declared 4xx `status`/`statusCode` **plus** a `code`, or a + sandboxed hook body's business `throw`. Everything else is untouched. + + **Per route, old answer → new answer.** Check your error handling if you branch + on any of these. + + `GET /api/v1/data/:object/:id/shares`, `POST` the same path, and + `DELETE /api/v1/data/:object/:id/shares/:shareId`: + + | the thrown refusal | was | is now | + | :--- | :--- | :--- | + | `{ code: 'RECORD_LOCKED', status: 409 }` (any code outside the five prefixes) | `500` `SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` | `409` `RECORD_LOCKED` | + | `{ code: 'FORBIDDEN', status: 403 }` — `plugin-sharing`'s own write gate | `500` `SHARE_*_FAILED` | `403` `FORBIDDEN` | + | the same declared as `statusCode` rather than `status` | `500` `SHARE_*_FAILED` | the declared status + code | + | a sandboxed hook refusal, no status declared | `500` `SHARE_*_FAILED`, message = the QuickJS wrapper `hook '' threw: Error: ` | `400` `VALIDATION_ERROR`, message = the hook's own sentence | + | a sandboxed hook body that CRASHED | `500` `SHARE_*_FAILED`, message = the wrapper around `TypeError: …` | `500` `SHARE_*_FAILED`, message = `Internal server error` | + | `VALIDATION_FAILED:` / `PERMISSION_DENIED:` / `NOT_FOUND:` / `CONFLICT:` / `SHARING_NOT_ENABLED:` prefixed messages | 400 / 403 / 404 / 409 / 422 with the prefix stripped | **unchanged** | + | anything else | `500` `SHARE_*_FAILED` with its own message | **unchanged** | + + `POST /api/v1/analytics/dataset/query`: + + | the thrown refusal | was | is now | + | :--- | :--- | :--- | + | a sandboxed hook refusal, no status and no code declared | `500` `{ code: 'ANALYTICS_QUERY_FAILED', error: }` | `400` `{ message: }` — the same status `POST /api/v1/data/:object` answers for the identical throw, and no code, because the producer declared none | + | a declared 4xx + code spelled `statusCode` rather than `status` | `500` `ANALYTICS_QUERY_FAILED` | the declared status + code | + | a declared 4xx + code spelled `status` | the declared status + code | **unchanged** | + | a declared 5xx, a crashed hook body, a driver fault, anything unclassified | `500` `ANALYTICS_QUERY_FAILED` | **unchanged** | + + The nested `{ success: false, error: { code, message } }` envelope the sharing + family answers is unchanged — only the status and code inside it move. The + `VALIDATION_ERROR` on the sandbox row is the catalog's declared floor for a + required `code` the producer did not name (`standardErrorCodeForHttpStatus`); + the flat `/data` body omits `code` there instead, because its `code` is + optional and ADR-0112 invents nothing. +- a02540f: `GET {basePath}/openapi.json` no longer falls back to the spec package's + compile-time version when `api.version` is configured empty + + The served `info.version` has always carried the API version identifier + (`api.version`, default `'v1'`), under a comment claiming it carried "the + runtime version so consumers don't pin to the spec package's compile-time + version". Both halves were false: the runtime version never reached the field, + and the `|| enriched.info.version` fallback published exactly the compile-time + version the comment said the line existed to avoid. + + The fallback was reachable rather than dead, though not because the contract + permits it: `RestApiConfigSchema` declares + `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which refuses `''`. Nothing + parses this config against that schema — both hops into the server are casts — + so `normalizeConfig`'s `??` is the only guard, it does not catch `''`, and the + document advertised `@objectstack/spec`'s package version. It now serves the configured value as + written, so a misconfigured deployment stays visibly misconfigured instead of + silently switching the field to a different kind of fact. Every non-empty + `api.version` — including the default — serves exactly what it served before. + + `info.version` is deliberately not the runtime version: OpenAPI 3.1 defines it + as "the version of the OpenAPI document (which is distinct from the OpenAPI + Specification version or the API implementation version)". Callers who want the + serving artifact read `{basePath}/discovery` or `/health`. +- 14b1145: fix(rest): the direct-mount package door carries a demoted producer code on `declaredCode` (#12405) + + `GET /api/v1/packages`, `GET /api/v1/packages/:id`, `POST /api/v1/packages/publish` + and `DELETE /api/v1/packages/:id` now put a producer's own error-code spelling on + the wire's `error.declaredCode` when the closed ADR-0112 vocabulary did not admit + it. Previously that spelling was resolved and then dropped: `sendThrownError` + (`packages/rest/src/package-routes.ts`) asked `resolveThrownHttpError` for the + answer — which returns `declaredCode` exactly when the demote happened — and then + forwarded only `details` to the shared envelope writer. + + Nothing invalid shipped, which is what made the loss silent and one-directional: + the closed `code` still carried the member the HTTP status derives, so every body + parsed, while an author's spelling vanished and a consumer told by ADR-0112 to read + `declaredCode` found nothing there. + + This ADOPTS the rule two sibling doors already apply rather than inventing one — the + demote is read through `demotedDeclaredCode`, the single definition of "presence + means demotion", exactly as the dispatcher's `errorFromThrown` + (`packages/runtime/src/http-dispatcher.ts`, #9106) and the flat `/data` door's + `thrownCodeFields` (`packages/rest/src/error-response.ts`, #9232) do. That matters + here specifically: the runtime dispatcher domain is the TWIN transport for + `/api/v1/packages`, and it has emitted this channel all along, while this + direct-mount registrar — which registers first and is therefore the one production + serves for the three routes both declare — dropped it. One path, two doors, + disagreeing on a declared channel. + + Additive and shape-preserving. A REGISTERED producer code still carries no + `declaredCode` (repeating it would put two spellings of one fact on every refusal), + a producer that declared no code still carries none, `details` is untouched, and + `code`/`status`/`message` are byte-identical to before on every existing path. The + 5xx message withhold is unchanged and does NOT suppress the demote: that withhold is + scoped to the prose by its own contract (`status`, `code` and `details` untouched), + `declaredCode` is a code channel, and the twin applies no status condition to it + either. + + ⛔ `userMessage` is deliberately NOT threaded here — the shared `sendError`'s `extra` + has admitted it since #12404, and that channel is threaded separately, in #12502. +- 092b9da: fix(rest): the direct-mount package door carries a producer-marked `userMessage` (#12502) + + `GET /api/v1/packages`, `GET /api/v1/packages/:id`, `POST /api/v1/packages/publish` + and `DELETE /api/v1/packages/:id` now put a producer's user-facing refusal text on + the wire's `error.userMessage` when the throw carried one. Previously that text was + resolved and then dropped: `sendThrownError` (`packages/rest/src/package-routes.ts`) + asked `resolveThrownHttpError` for the answer — which returns `userMessage` exactly + when the producer marked a non-empty string at throw time (#9934) — and then + forwarded only `details` and `declaredCode` to the shared envelope writer. + + Nothing invalid shipped, which is what made the loss silent: `code`, `status` and + `message` were all correct, so every body parsed, while an author's deliberate, + end-user-addressed sentence vanished and a consumer told by ADR-0112 to render + `userMessage` verbatim found nothing there and fell back to its generic + substitution — the #3821 behaviour this channel exists to override. + + This completes the pair the sibling change left open. That one threaded + `declaredCode` and said `userMessage` was "tracked separately"; this is it, and the + `extra` it spreads into has admitted the field since #12404. + + ⛔ The idiom is the INVERSE of the sibling's, deliberately. `declaredCode` must be + read through `demotedDeclaredCode` because its raw field carries a second meaning — + it is also set when the producer's spelling IS the registered member, so forwarding + it raw would put two spellings of one fact on every registered refusal. + `userMessage` has no second meaning: `declaredUserMessage` already decided what + counts as marked (a non-empty string, or nothing), so the caller passes + `thrown.userMessage` straight through, byte for byte what the dispatcher twin + serving this same path does (`errorFromThrown`, + `packages/runtime/src/http-dispatcher.ts`). Consumers must not read presence as + anything but "the producer opted in". + + Additive and shape-preserving. An unmarked refusal still carries no `userMessage`, + and the three shapes `declaredUserMessage` rejects — `''`, whitespace-only, a + non-string — still carry none, so nothing invents a marked message for a producer + that never wrote one. `details`, `declaredCode`, `code`, `status` and `message` are + byte-identical to before on every existing path. + + The 5xx message withhold is unchanged and does NOT suppress the mark: that withhold + rewrites a local `message` const and `looksLikeInternalErrorLeak` is only ever handed + `thrown.message`, so the marked channel is never an input to it. The two are + answering different questions — leaked diagnostic prose is withheld, while the + producer's own sentence to the end user discloses only what it chose to — and the + ruling that created the channel made it status-agnostic on purpose. + + ⚠️ Stated because it is the honest cost, and so the next channel added here does not + have to rediscover which bar applies: the IN-TREE producer set at this door is empty, + for this channel and for `declaredCode` alike. This door is judged live because it is + **composed rather than closed** — `resolvePackageService()` and the `protocol` slice + are open composition points whose throws all four handlers forward verbatim, and + ADR-0112's federation amendment exists precisely because the producer set is not + enumerable in-tree. The live population is the injected/federated limb, which is the + population the new pins in `packages/rest/src/package-door-user-message.test.ts` + drive. +- da43fde: `GET /api/v1/discovery` reports the serving artifact's version instead of the + URL path segment the caller just typed + + `registerDiscoveryEndpoints` called the producer and overwrote the answer one + line later: + + ```ts + const discovery = await protocol.getDiscovery(); + + // Override discovery information with actual server configuration + discovery.version = this.config.api.version; + ``` + + `config.api.version` is the **API version identifier**, not an artifact + identity. `normalizeConfig()` defaults it to `'v1'`, `packages/spec`'s + `plugin-rest-api.zod.ts` describes it as "API version identifier", and the same + value builds the mount — `getApiBasePath()` returns + ``api.apiPath ?? `${api.basePath}/${api.version}` `` → `/api/v1`. So on every + REST-served host, `GET /api/v1/discovery` answered `version: "v1"`: the segment + the caller had already typed to reach the endpoint, on every build of every + release, forever. + + `DiscoverySchema` declares `version` under **System Identity**, grouped with + `name` and `environment` — the "what server is this" question. The #10993 + ruling settled that reading and #11235/#11242 reaffirmed it. The override is + now gone and the producer's derived value reaches the wire. + + **What changes on the wire.** `version` on this endpoint was `"v1"` and is now + the value `getDiscovery()` derives: `OS_RUNTIME_VERSION` when a deployment or + build pipeline stamps one, else the resolved `@objectstack/metadata-protocol` + package version, else `"unknown"`. That is the same stamp `/health` and the + runtime dispatcher's own `/discovery` already read, so the two discovery + producers now give one answer rather than two dialects of one field. Before + #11297 this override masked two producers that genuinely disagreed (`'1.0.0'` + vs `'1.0'`); after it, it was overwriting a value that already agreed. + + **The API-version fact is not lost.** Every entry in the same document's + `routes` is prefixed with the mounted base path, which is built from + `api.version` — recoverable from the same response, in the field that means it. + No schema change, no new field: the accept set and the public surface are + unchanged, and `api.version` still does its real job of building the mount. + + Pinned in `packages/rest/src/discovery-schema-conformance.test.ts`, which drives + the **real** producer through the **real** handler. The assertions pin + provenance, never a literal version string — a stamp injected by the test must + appear on the wire, and the served value must equal what the producer answers + when called directly, including on a server configured with a different + `api.version` (where `routes.data` is asserted to still carry that segment). A + pin spelling a literal would rot at the next release. +- a933ed7: refactor(rest): the audit and reset door call sites are compiled against the declared contract (#11678, #11679) + + The `GET /meta/:type/:name/audit` and `DELETE /meta/:type/:name` doors in + `packages/rest/src/rest-server.ts` reached their protocol methods through + `(p as any)` — once for each feature-detection guard, once for each call — so + the compiler checked nothing about the request literals they built. The two + casts were load-bearing in opposite ways, both measured: the audit door's on + **member existence** (`auditMetaItem` was undeclared in `packages/spec` + entirely — removing the cast answered `TS2339`), the reset door's on **request + shape** (`deleteMetaItem` was declared, but its request schema carried 2 of the + 8 members the door sends — removing the cast answered `TS2353` on six keys). + + With `MetadataProtocol.auditMetaItem` declared and + `DeleteMetaItemRequestSchema` caught up (the spec half of this landing), the + guards are now `typeof p.auditMetaItem !== 'function'` / `if (!p.deleteMetaItem)` + and each request is a named const typed against the spec contract — the reset + door through `TransportScopedMetaRequest` (it still + spreads the transport-level `environmentId`, which stays layered on by the + #9741 envelope rather than becoming a protocol key), the audit door as a plain + `AuditMetaItemRequest` (it stopped sending `environmentId` when #8747 scoped + the read, so there is no transport member left to layer on). + + **No behaviour change of any kind, and nothing about the wire moves.** The + outgoing payloads are byte-identical (same keys, same conditional spreads); the + edits hoist each literal into a const and drop type-level casts. The 501 + feature-detection guards survive on purpose: both members are declared + **optional** (a kernel may implement neither door), and each guard is also what + narrows its member to callable at the call site. An undeclared key in either + literal is now a compile error instead of a payload member no contract has ever + seen. +- 6202043: Stop answering a terminal `404 RESOURCE_NOT_FOUND` for a REGISTRY read that + could not happen on `GET /api/v1/packages/:id` + + The detail door tries the durable `sys_packages` read first and falls back to + the in-memory registry via `protocol.getMetaItems({ type: 'package' })`. That + fallback sat in a bare `catch {} // Protocol unavailable`, so when the read + threw, control fell straight through to the line below and the door answered + **`404 RESOURCE_NOT_FOUND` — `Package "" was not found.`** + + This is the worse half of the family, not a smaller one. The list door's + version of the same swallow (#11130) answered a `200` whose `total` + under-counted; this one answers a terminal negative fact. `404` / + `RESOURCE_NOT_FOUND` is not "the answer may be incomplete", it is *"this package + does not exist"*, and callers act on it: an installer decides the package is not + installed and offers to install it, a console hides the entry, a script branches + to the create path. The producer's own words for the same condition are the + opposite — *"whether this item exists is unknown"*. + + It was also #5532's defect resurfacing one layer up. + `ObjectStackProtocolImplementation.getMetaItems` was taught **not** to report an + unreadable `sys_metadata` as "that item does not exist"; this consumer-side + catch then re-applied precisely that relabelling to the protocol's answer. So + the producer already declares the refusal — every non-benign overlay read + failure leaves as `SERVICE_UNAVAILABLE` / 503 with an ADR-0112 status+code on + the error — and the repair is the same one #11063 made for this door's durable + half and #11130 made for the list door's registry half: delete the catch and let + `sendThrownError` carry the producer's own status and code. + + Standing family ruling — #10965 · #10677 · #10789 · #11063 · #11130: **a read + that could not happen must not be reported as a read that found nothing.** + + Unchanged, and pinned in both directions because the defect was that a failed + read and an absent resource were indistinguishable: a genuine miss (both sources + read fine, neither holds the id) still answers `404 RESOURCE_NOT_FOUND`; a + composition with no protocol service is an absence rather than a failed read and + still reaches that same 404; a registry hit still answers `200` with + `source: 'registry'`; and a durable hit still answers `200` without consulting + the registry at all. No wire field is added — the response shape is a contract + decision this change does not carry. +- 8519095: **Fix:** `GET /api/v1/packages` no longer absorbs a failed **registry** read into a `200` — the other half of the two-source merge #11063 closed (#11130). + + The list door merges two sources: the in-memory registry, read through `protocol.getMetaItems({ type: 'package' })`, and the durable `sys_packages` rows, read through `PackageService.list()`. #11063 stopped the door swallowing a failure of the durable half. The registry half still carried its own bare `catch {}` commented *"Protocol unavailable — continue with database only"*, so the exact ambiguity #11063 closed stayed open on the other source: when `getMetaItems` threw, the door answered `200` with `{ packages, total }` built from the database alone, and `total` was presented as a **complete count** either way. The surviving entries kept `source: 'database'`, which reads as provenance rather than as a warning that the registry half is absent — nothing on the wire separated *"these are all the packages"* from *"these are the packages I could still see"*. Same standing family ruling as its sibling — #10965 · #10677 / PR #10788 · #10789 / PR #10964 · #11063: **a read that could not happen must not be reported as a read that found nothing.** + + **The producer already declared its refusal, so this is #11063's edit rather than a new posture.** The card left open whether a *present* `getMetaItems` even has a reachable throw. Measured on the live implementation — `ObjectStackProtocolImplementation` in `packages/metadata-protocol`, the object `MetadataProtocolPlugin` registers as the `protocol` service and the one `RestApiPlugin` hands to this registrar: it routes every non-benign `sys_metadata` overlay read failure through `rethrowUnlessMetadataStoreUnprovisioned` → `metadataStoreUnavailableError`, i.e. `SERVICE_UNAVAILABLE` / 503 with an ADR-0112 status+code **on the error** (#5532) — the same envelope #10965 gave `PackageService.list()`. Driving it with the door's own request answers `THREW status=503 code=SERVICE_UNAVAILABLE` on an `ECONNREFUSED` store. So route (b)'s first leg was already landed and only the second remained: stop swallowing. + + **What changes on the wire.** A registry read that refuses now reaches the client as the producer's own `503` / `SERVICE_UNAVAILABLE` through the declared envelope, instead of a `200` whose `total` lied. A registry read that throws something *undeclared* is answered as the `500 INTERNAL_ERROR` a fault deserves, instead of being eaten. Both were previously indistinguishable from a healthy, complete listing. + + **What deliberately does not change.** An **absent** protocol service is a different, already-handled case — the `if (options.protocol && typeof … === 'function')` guard is untouched, so a composition without the protocol service still answers `200` with the durable half alone. First boot is unaffected: `sys_metadata` not being provisioned yet is not a throw on that path (`isMissingTableError`), it is an empty overlay. The healthy merge — two sources, `source: 'registry' | 'database' | 'both'`, a truthful `total` — is byte-identical. The durable half is untouched (#11063 landed it), and so is the dispatcher twin in `packages/runtime/src/domains/packages.ts`, which serves a single in-memory read with nothing of this shape. + + **No response field is added.** The card's third shape — keep the `200` and make the tolerance visible with a partial-result marker — is a response-shape change, i.e. a contract decision, and was not authorized here. + + Pinned by `packages/rest/src/package-list-registry-read-refusal.test.ts`, which asserts the mechanism rather than "the door still answers": the status **and** the declared `code`, that no `total` is reported over a read that failed, that both halves of the one merge answer the same outage identically, and — as the overreach guards — that an absent protocol service and a healthy merge are unmoved. On the pre-fix tree 4 of its 6 cases fail, every one of them reading `200` where a refusal was owed. +- 6e33394: refactor(rest): the `publishMetaItem` call site is compiled against the declared contract (#11145) + + The `POST /meta/:type/:name/publish` door in `packages/rest/src/rest-server.ts` + reached its protocol method through `(p as any).publishMetaItem` — once for the + 501 feature-detection guard, once for the call — so the compiler checked nothing + about the request literal it built. The cast was load-bearing on **member + existence**, not on request shape: #10350 measured that deleting it answered + `TS2339: Property 'publishMetaItem' does not exist on type 'RestProtocol'`, not + a `TS2353` about an unknown key. `publishMetaItem` was an ADR-0076 D9 + server-only extension, so no amount of widening the implementation's own + parameter type in `@objectstack/metadata-protocol` (which this package + deliberately does not depend on) could have retired it. + + #11006 (maintainer ruling 2026-08-22, option B) declared the member on + `MetadataProtocol` with a `PublishMetaItemRequest`, which is what removes the + prop. The guard is now `if (!p.publishMetaItem)` and the request is a named + const typed `TransportScopedMetaRequest` — the same + shape #9741 gave the meta-read doors and #9805 gave the non-door helpers. + + **No behaviour change of any kind, and nothing about the wire moves.** The + outgoing payload is byte-identical (same keys, same conditional spreads); the + edit hoists the literal into a const and drops a type-level cast. Two things + deliberately survive: + + - the 501 feature-detection guard, because the declared member is **optional** + (a kernel may not implement the promotion door at all) — and it is also what + narrows the member to callable at the call site; + - the transport-level `environmentId`, which stays layered on by the + `TransportScopedMetaRequest` envelope rather than becoming a protocol key, per + the #9741 ruling (2026-08-18). + + What the typing buys, measured rather than asserted: an undeclared key in this + request literal is now `TS2353` at compile time instead of a payload member no + contract has ever seen. The docblock that existed only to explain why the cast + had to stay is replaced rather than left behind — a rationale for a prop that no + longer exists is a declaration that outlived its subject. +- cad8b42: Serve the `code` a sandboxed hook declared on `/api/v1/data` refusals, at every + status — not only where a bespoke arm happened to catch the condition first + + Measured on a booted 17.1.0 server: a hook throwing + `Object.assign(new Error(msg), { code: 'RECORD_LOCKED', status: 409 })` reached + the client as `409 {"error":"…","object":"crm_opportunity"}` — the status but no + machine-readable `code`. Same for `DUPLICATE_VALUE` on `POST` and `FORBIDDEN` on + `403`, while `DELETE_RESTRICTED` at the same 409 and `VALIDATION_FAILED` at 400 + carried theirs. A client that must tell "this record is frozen, do not retry" + from "this value is already taken, offer a merge" got `409` for both and had to + substring-match prose that is localised and deliberately reworded over time — + the failure mode the ADR-0112 `code` vocabulary exists to remove. + + The branch is `classifyDataError`'s **sandbox unwrap door** in + `error-response.ts` (`typeof error?.innerMessage === 'string'`), which rendered + from the raw error and emitted no `code` at all, while every arm around it + renders from the resolved envelope. That is the whole of the reported + correlation between "no `code`" and "the unwrapped message": they are one + branch, not cause and effect. + + It was never the status policy it looked like from outside. The door dropped + `code` on a declared **400** exactly as on a declared 409, and kept it on a + declared 5xx by falling through to the passthrough below — one sandboxed + producer, its code surviving 503 and lost at 409. What made the reading look + status-shaped is which codes have a bespoke arm above the door: + `DELETE_RESTRICTED` and `VALIDATION_FAILED` do and never reach it, + `RECORD_LOCKED` / `DUPLICATE_VALUE` / `FORBIDDEN` do not and did. + + The code now rides via `thrownCodeFields`, the one definition the three sibling + arms already use, so the door joins the closed ADR-0112 vocabulary: a registered + spelling arrives verbatim, an unregistered one is demoted to `declaredCode` + beside the status-derived member. Nothing is invented — a producer that declared + no code still gets a body carrying none. + + Unchanged: the business message is still the unwrapped `innerMessage` (never the + `hook 'x' threw: …` debug wrapper), a crashing hook body is still the sanitised + `500 INTERNAL_ERROR`, a declared 5xx still withholds its prose, and a refusal + that declares no status still answers 400. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/service-package@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 72f6eaee2f..5b98fff059 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.2.0", + "version": "17.3.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 1d350b0941..f48cd71d8e 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,734 @@ # @objectstack/runtime +## 17.3.0 + +### Minor Changes + +- 266436a: **BREAKING (authorization):** `POST /api/v1/automation/:name/toggle` now requires the `manage_metadata` capability. A caller that holds a session but not that capability is answered **403 `PERMISSION_DENIED`** where it previously received **200** with the flow's enablement changed. + + This narrows what the API accepts, so it ships as `minor` with the breaking surface named rather than as a `patch`. + + + + **The exact surface that moves** + + | | before | after | + |---|---|---| + | authenticated caller **with** `manage_metadata` | 200, flow toggled | 200, flow toggled — unchanged | + | authenticated caller **without** it | 200, flow toggled | **403 `PERMISSION_DENIED`**, `toggleFlow` never entered | + | anonymous caller | 401 | 401 — unchanged, the #5519 floor still answers first | + | engine self-invocation (`isSystem`) | 200 | 200 — unchanged | + + Nothing else on the domain moves. The execution doors keep their posture: `POST /:name/trigger`, the legacy `POST /trigger/:name` and `POST /:name/runs/:runId/resume` are untouched, so ordinary members can still run the flows built for them. The reads are untouched. `GET /automation/_status` still serves enablement to any authenticated caller — this change is about mutating the bit, not observing it. + + **Why enablement joined the metadata write set** + + #10145 gated the automation definition writes (`POST /`, `PUT /:name`, `DELETE /:name`) and deliberately left `toggle` out in the open, because whether disabling a flow is authoring or operating is a product call. It was filed, measured over HTTP, and ruled on 2026-08-23. + + The measurement is why "it is engine state, so leave it" did not survive: **the enabled bit is not a row, so no organization wall scopes it.** `toggleFlow(name, enabled)` writes an in-process map keyed by flow name only, `getFlowRuntimeStates()` reads that same map with no caller and no organization, and the automation service is one instance per environment. On a real, non-degraded `isolated` posture, a tenant org owner without the capability — refused 403 by `PUT /meta/:type/:name`, `POST /automation` and `DELETE /automation/:name` at the same session — switched a shipped flow off, and an unrelated tenant in a **different organization** plus the platform admin both read it off, symmetrically in both directions. Disabling a shipped flow is functionally equivalent to deleting it for as long as it stays off, and `DELETE /:name` was already gated. Mitigating but not exculpating: the override is process-local, so a cold boot reads `enabled: true` again. + + **No new capability name was minted.** The change is one arm on the existing `isFlowAuthoringWrite` predicate in `packages/runtime/src/domains/automation.ts` — the #10145 author wrote that as a single function precisely so this ruling would be one edit rather than a fourth copy of the policy. Fail-closed by construction, exactly like its three siblings: an absent `executionContext`, an absent `systemPermissions` or an empty one all refuse, and the gate runs ahead of the body checks so a refused caller learns nothing about the toggle contract. + + **Migration.** A caller that toggles flows programmatically — `client.automation.toggle(name, enabled)` — must present a principal holding `manage_metadata`; the same capability its `create` / `update` / `delete` neighbours have required since #10145. No caller of this route was found in this repo, in the Console UI (`objectstack-ai/objectui`, which posts only `/trigger` and `/resume` and merely *displays* enablement), or in the example apps, so the expected migration surface is programmatic SDK callers rather than end-user UI. +- 1524927: Packaged flows can be switched off durably, and the process-local off-switch is retired + + Disabling a packaged flow now writes an install-level row to the + `sys_metadata_activation` ledger (ADR-0126 §4/§7.2) instead of setting a + process-local map. The engine consults that ledger at the `execute()` seam — + the one seam every entry path crosses (record-change, schedule, time-relative, + api, subflow) — and refuses a disabled flow there with the existing + `FLOW_DISABLED` code; the ledger case is distinguished by the message, so no + new error code joins the ADR-0112 ledger. An install-level disable also unbinds + the flow's trigger, and re-enabling rebinds it. Absence of a row means the + packaged default, active, so a deployment that never flips anything behaves + exactly as before. + + This retires the mechanism behind #10243 rather than refining it. The old + `flowEnabled` map was not a row, so no organization wall scoped it: on a walled + multi-organization deployment a tenant org owner could switch a shipped flow + off environment-wide and an unrelated tenant read it off. The durable row + replaces it, and because a durable install-wide switch writable by tenants + would be that leak with persistence, the write is now authority-gated: + `POST /automation/:name/toggle` requires the platform operator in the `group` + and `isolated` postures, while the `single` posture — where install-level and + org-level are the same scope — is unchanged for the org admin who already holds + `manage_metadata`. The refusal names the posture and points at the clone path. + + Disabling a flow that packaged flows still call as a subflow is refused, and + the refusal names the callers (ADR-0126 §7.3). Without it a vendor flow breaks + mid-run at its subflow node with an inexplicable late failure. The check is a + definition scan at disable time over both `subflow` and `map` nodes; no + reference index is built. Enabling is never guarded. + + One behaviour change worth calling out: a disable now survives + unregister-and-re-register, which is what a package upgrade, a Studio publish + and the boot pull all do. ADR-0126 §6 requires it — the ledger records the + customer's choice, and no upgrade un-makes a choice — but it is the opposite of + what the retired in-process map did, where any re-registration silently + re-armed the flow. +- 8542bd4: **Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('')` resolves a related record's, and a `formula` title is evaluated server-side (#11293). + + A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) nor any accessor answering *"what is this record called?"*. The only way to name a record in a sentence was to re-implement the object's title inline, per hook. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check. + + What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**. + + ```js + // this record — nameField, formula or stored column alike + await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` }); + // a related record, through the lookup column that holds its id + const account = await ctx.title('account_id'); + ``` + + **Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns. + + **Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title()`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection. + + **It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value. + + Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here. +- 2af5eac: fix(objectql,runtime): `delete ctx.input.x` in a hook actually removes the field (#12277) + + A hook that stripped a field from its input with `delete` did nothing, on BOTH + execution paths, while an assignment made two lines above it on the same object + in the same call landed normally. Nothing raised, and nothing in the platform + reported it. + + Graded `minor` rather than `patch` deliberately: it moves data that reaches + downstream consumers. Any shipped hook that already contains + `delete ctx.input.` has been a no-op until now and starts taking effect + on upgrade — which is the point, and is also exactly why it must not arrive as + a silent patch. No API is removed and no accept set narrows. + + ### The two mechanisms, which were unrelated and produced one outcome + + **In-process (`installFlatInput`, `packages/objectql/src/hook-wrappers.ts`).** + The flat-record `Proxy` a declarative hook receives over the engine's + `{ data, options, id? }` wrapper trapped `get` / `set` / `has` / `ownKeys` / + `getOwnPropertyDescriptor` — but not `deleteProperty`. The delete therefore fell + through to `Reflect.deleteProperty` on the WRAPPER, one level above the record, + removing a key that was never there and returning `true`. `set` was trapped and + wrote into `data`, which is what the engine persists; hence assignment survived + and deletion evaporated. + + **Sandboxed (`applyMutationsToInput`, + `packages/runtime/src/sandbox/body-runner.ts`).** A QuickJS body's mutations + were written home with `Object.assign(target, result.mutatedInput)`. + `Object.assign` copies own enumerable properties and **has no way to represent a + removal**: a key the VM deleted is simply not in the snapshot, and the host's + key stayed. Deletions are now diffed against the entry snapshot and applied + separately. + + Both are fixed in one change on purpose. Closing either alone would make the + same authored `delete` behave differently depending on whether the hook body + runs in-process or in the sandbox — a worse contract than the symmetric silence + it replaced. + + ### What an author could see, before and after + + The sandboxed path is the one with no tell at all. Measured on the pre-fix code, + one hook call, host row alongside: + + ``` + delete ctx.input.internal_notes -> true + 'internal_notes' in ctx.input -> false <- the VM agrees + Object.keys(ctx.input) -> ['subject'] <- ...and so does this + host ctx.input after write-back -> { subject: 'HELP', + internal_notes: 'STAFF-ONLY' } + ``` + + The in-process path was less deceptive than reported, and the correction is + worth having in writing: only `delete`'s own return value lied there. `'k' in + input`, `input.k` and `Object.keys(input)` all went on honestly reporting the key + as present, so an author who checked with anything other than the return value + would have seen the no-op. + + ### `Object.defineProperty(ctx.input, …)` was the same gap, and nobody reported it + + Found while enumerating the trap set, fixed in the same stroke because it is the + strictly worse shape: it defined on the wrapper, and the `get` trap's + fall-through then read the value straight back — so `input.k` CONFIRMED a write + that never reached `data`, while `Object.keys(input)` denied it and the record + never received it. It now routes into `data` like `set` and `deleteProperty` do. + One inherited JS invariant follows: a proxy may not report success for an + explicitly `configurable: false` descriptor its target does not carry, so + `Object.defineProperty(input, 'x', { value: 1, configurable: false })` now throws + a `TypeError` where it used to define, silently and uselessly, on the wrapper. + Omitting `configurable` — the common spelling, and the one spread and + `Object.assign` produce — is unaffected. + + ### The direction the sandbox write-back deliberately does not overreach in + + Absence from the exit snapshot is the only evidence a deletion leaves, and on its + own it is ambiguous: a key whose host value is `undefined` (or a function, or a + symbol) never survived `JSON.stringify` INTO the VM either, so it is missing from + the dump without anyone having deleted it. The diff is filtered through the same + JSON lens the boundary uses, so such a key is left alone. Every failure mode of + that probe is conservative — an unprobeable key is simply not deletable — because + losing a delete is recoverable and destroying a field on evidence that was never + there is not. One residual miss follows and is named here rather than discovered + later: a `bigint`-valued key crosses into the VM as a string but is dropped by the + probe, so deleting one is still lost. + + Measured consumer cost of the reported half: a guest-intake app stripped the + fields an anonymous web-to-case / web-to-lead submitter must not write — + internal staff notes, the resolution, the escalation flag, the owner — with + fifteen `delete` statements, every one inert. A submission carrying + `internal_notes` and `resolution` stored them verbatim, and the app's unit tests + stayed green throughout, because they drive the handler with a plain object where + `delete` genuinely works. +- e5ce2ed: Packaged actions can be switched off, on the same activation ledger as flows + + A packaged action can now be disabled for an installation, generalizing the + packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on + the maintainer's amendment ruling 3). The flip writes an install-level row to + the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — + no new table, no new column, no schema change of any kind. Absence of a row + means the packaged default, active, so a deployment that never flips anything + behaves exactly as before, and an empty ledger changes nothing anywhere. + + The consult point is action DISPATCH, and it is present on every door that + dispatches a declared action: the REST `POST /actions/:object/:action` route and + the MCP `run_action` bridge. Both call one shared guard, and a disabled action + is refused `409 ACTION_DISABLED` before anything runs — before the handler body + (which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action + reaches the automation engine, before the param contract is enforced and before + the subject record is read. The refusal names the ledger and the remedies. The + code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and + answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, + which would tell an operator to go looking for a flow that does not exist. + + The consult reads a projection the ObjectQL engine holds and hydrates at boot, + so a disabled action stays disabled across a restart and across the handler + re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — + the ledger records the customer's choice, and nothing re-arms it silently). + + The write door is `POST /actions/_activation/:object/:action` with a + `{ enabled?: boolean }` body. Its first segment is reserved rather than deep in + the path because a machine name can never begin with `_`, so it cannot collide + with an object, an action or a record id. It carries the same two authority + tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture + rule — in the `group` and `isolated` postures the install-wide switch requires + the platform operator, while `single`, where install-level and org-level are the + same scope, is unchanged. That gate is now one implementation shared with + `POST /automation/:name/toggle`; the flow refusal text is unchanged. + + Two refusals are worth knowing about. The ledger addresses an action by its + machine name, so a name declared on more than one object is refused with + `409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off + silently. And a flip that cannot be made durable — no ledger table reachable — + is answered as a failure instead of a 200, because a switch reported as durable + that reverts on the next restart is the failure this whole family exists to + remove. + + Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so + disable is the only primitive here and authoring a new sibling action stays + exactly as it is today. +- c68c670: Add `POST /api/v1/automation/:name/clone` — whole-definition flow clone (ADR-0126 §7.1) + + An admin who cannot edit a packaged flow in place can now copy it to an ordinary + org-authored sibling and edit that instead. `POST /automation/:name/clone` takes + `{ name, label }` — both mandatory — and registers a copy of the source flow's + parsed definition under the new machine name. + + **The copy is whole-definition, never an enumerated facet list.** Every key the + source definition carries comes across; exactly `name`, `label` and `status` are + mutated. This is the shape ADR-0126 §7.1 rules for, and the reason is measured: + a clone assembled from an enumerated facet list silently dropped three of six + facets (#11703) — the record was created, the success toast fired, and the + difference was discoverable only by diffing the two rows. A flow has far more + facets than a permission set, so the acceptance test asserts deep equality of + the cloned definition against its source minus those three fields; a dropped + facet fails the test rather than shipping. + + **The new machine name is mandatory and a same-name clone is refused** with a + 409 `RESOURCE_CONFLICT` naming both the reason and the remedy. Not because + storage rejects it — storage legitimately holds both rows — but because the + automation engine keys flows by bare name, so a second definition under one name + silently shadows the other and which of the two dispatches depends on + registration order. + + **No ancestry is recorded.** Nothing tracks what a clone was copied from — no + provenance field on the definition, none on the response (ADR-0126 amendment + ruling 2, §9). The source's own ADR-0010 protection envelope (`_packageId`, + `_provenance`, `_lock`, …) is dropped rather than carried across, so the clone is + an org-owned flow the admin can actually edit rather than a second copy of the + package's locked artifact. + + **References are not re-pointed** — no reference index exists, so the clone calls + exactly what the original called. The response says so, along with the fact that + `status: 'draft'` is a lifecycle label and not an off-switch: the engine only + disables on `obsolete`/`invalid`, so a cloned record-change or schedule flow is + bound to its trigger and runs alongside the flow it was copied from. + + The route joins the `manage_metadata` authoring-write set (#10145/#10243) — it + registers flow metadata at environment scope, exactly as `POST /automation` does. +- fb5fbb8: Hook body sandbox context now carries the per-row dispatch signal and the D2 options projection (#11552). A shipped (L2 sandboxed) hook body observes `ctx.dispatch` — a frozen `{ mode: 'record' | 'per-row', index }` copy of the engine's #6966 dispatch marker (`scope` deliberately does not cross: a JSON copy cannot keep its shared-identity contract) — and `ctx.input.options` — a frozen, non-enumerable `{ multi?, where? }` projection of the caller's bag, the two members ADR-0058 Addendum II D2 declares visible to the `before*` phase. This closes the declared≠observable gap that made D3's routes 1 (batch-scoped throw) and 2 (`ctx.api` per row) inexpressible from a body-only hook: a guard written `ctx.dispatch?.mode === 'per-row'` previously evaluated `false` on every production dispatch. `Object.keys(ctx.input)` still enumerates payload fields only, `ctx.input.id` stays absent (read `ctx.previous.id`), and the post-run input write-back cannot carry the grafted keys back to the engine. The spec change is documentation-only: `HookContextSchema`'s `input`/`dispatch` TSDoc now states the body-face visibility. +- 7986d97: Retire compound-name metadata addressing (`/meta/:type/:section/:name`) + + Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. + Stage 1 declared the item-name grammar and refuses every slash-bearing name at + the publish door, so the routes removed here addressed only names that can no + longer be created. + + **BREAKING — three public REST routes stop answering:** + + | stops answering | use instead | + | :-- | :-- | + | `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | + | `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | + | `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | + + Each retired route folded its `:section` and `:name` segments back into one + slash-bearing key (`views/all_leads`) that the protocol layer then treated as a + single opaque string — the section half was never stored, filtered or + enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. + + The `@objectstack/runtime` dispatcher stops folding in the same way: its + `/meta` handler requires exactly two path segments for an item and three for + `…/published`, instead of re-joining every trailing segment. A `/meta` path + that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than + falling through to the adapter's anonymous 404. + + **FROM → TO for callers.** Address every item through the single-segment route + and percent-encode the name: + + ``` + GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads + ``` + + `@objectstack/client` now calls `encodeURIComponent` on every `/meta` item + address, so SDK callers need no change: the SDK already sends the new spelling. + Encoding is a **no-op** for every name the item-name grammar admits (lowercase + snake_case segments, optionally dot-qualified), so the bytes on the wire are + unchanged for every name that can be written today. + + A pre-grammar **residue** row whose stored name contains a slash remains + readable, writable and deletable: `%2F` matches the single-segment pattern and + the parameter is decoded back to the stored spelling before the handler runs. + Nothing that could be stored has become unaddressable. + + Two SDK doc comments that promised "compound names pass through unencoded" + (`meta.getPublished`, `meta.publishItem`) are corrected, and the + `SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored + at the compound door and published live — is closed at the source: there is one + door, and it reads every member of the options bag. + + + +### Patch Changes + +- 387e231: feat(spec,runtime): refuse the doubled post-success navigation channel on a `type: 'script'` action (#11519) + + **BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under + the repo's launch-window convention for breaking changes. + + Two independent channels could name a post-success destination for one + `type: 'script'` action: the declared `onSuccess` block (`{ navigate, openIn }`, + validated and visible in metadata) and the handler-returned `{ redirectUrl }` + convention (runtime-only). The spec ruled each surface's default in isolation + and said nothing about an action carrying both — so the renderer had to pick, + and the pick lived only in one renderer's implementation (declared `onSuccess` + wins, objectstack-ai/objectui#5933). Maintainer ruling 2026-08-24: refuse the + doubled channel; ⛔ no `precedence` contract field. + + The measured static-knowability partition: + + - **Authoring-time refine (spec):** "the handler can return `redirectUrl`" is + runtime-only in general (`target` names an opaque registry entry; + `HookBodySchema` declares no return contract) — but `opensInNewTab: true` is + a schema-visible declaration of the handler-redirect channel (its contract is + "pre-open a tab, then drive it to the handler's returned `redirectUrl`"). + A `type: 'script'` action declaring `onSuccess` beside `opensInNewTab: true` + is now **rejected at parse time**, with guidance naming both channels and the + remedy. Previously the pair parsed clean and one declaration was silently + dead at render. + - **Dispatch-seam diagnostic (runtime):** the runtime-only remainder — a + handler that actually returns `{ redirectUrl }` while the action declares + `onSuccess` — now logs a loud `[action-contract]` warning at both dispatch + surfaces (the REST `/actions` route and the MCP `run_action` bridge), naming + the action, both channels, the interim winner and the remedy. Observe-only: + the wire is untouched and the interim renderer precedence stands until the + author takes the remedy. + + Single-channel declarations are untouched and pinned byte-identically: only + `onSuccess`, only `opensInNewTab` (with or without `newTabUrl`), and + `opensInNewTab: false` beside `onSuccess` all parse exactly as before. The + corpus was measured at zero doubled producers (this repo's examples and + platform metadata, objectui metadata, and the cloud SSO handoff producers per + the #11519 measurement), so no shipped metadata is affected. + + **Migration.** An action refused by the new refine must pick its one + destination: keep `onSuccess` and drop `opensInNewTab` (and stop returning + `redirectUrl` from the handler), or keep `opensInNewTab` + the handler + redirect and drop `onSuccess`. Which channel is right is an authoring decision + the metadata cannot make for you, and zero such actions exist in any measured + corpus. + + +- fe3d74f: fix(runtime): a refused `POST /automation/:name/toggle` is told what it attempted (#11666) + + The enablement door refuses in its own words now. A caller without + `manage_metadata` that hit `POST /api/v1/automation/:name/toggle` was answered + with the refusal the three definition writes share: + + ```text + before: Authoring automation flows requires the `manage_metadata` capability. + after: Enabling or disabling an automation flow requires the `manage_metadata` capability. + ``` + + They were disabling a flow, not authoring one. The sentence was accurate about + the policy — #10243's ruling classified toggle into the `manage_metadata` + authoring write set — and it named a verb the caller did not use. + + ⛔ **Copy only; no policy moved.** The accept set is bit-identical: the same + callers are refused on the same four routes, `POST /` / `PUT /:name` / + `DELETE /:name` keep the shared sentence they read correctly with, and the + envelope is untouched — `PERMISSION_DENIED` / **403** on every arm, as #11660's + pins and the ADR-0112 vocabulary assert. Nothing becomes newly accepted or + newly rejected. + + Shaped on this domain's own precedent (`SCREEN_READ_DENY_MESSAGE` beside + `RUN_READ_DENY_MESSAGE`, #7968): a second constant for a second question, + rather than a reworded shared one. Rewording the shared sentence to cover both + was considered and declined — it would degrade the message for the three + definition writes in order to fix one arm. Both sentences still satisfy #7450: + each names the capability that would admit any caller, and nothing about this + one. + + A client branching on the human-readable prose of a 403 (rather than on + `error.code`) is the only thing that can notice. +- d2b2381: **Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095). + + `saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one. + + The maintainer ruled a **split**, and the two halves are not the same fix: + + - **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON. + - **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first. + + For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes. + + The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them. +- 67ceb9a: The dispatcher `/metadata` transport folds the URL segment before deciding + organization scope — `/metadata/translations/:name` no longer writes to a + different partition than `/metadata/translation/:name` + + Two maps that must agree did not. `protocol.saveMetaItem` folds the path + segment through `canonicalizeMetaRequestType` → `META_URL_TO_SINGULAR`, the + **complete** spelling map, for storage. The dispatcher handed the same string + **raw** to `organizationIdForMetaWrite`, whose `declaresOrgOverride` tolerates + only the manifest-collection spellings — incomplete by design. + + For the two URL-only spellings of `allowOrgOverride: true` types the two + answers diverged. `translation` has no manifest collection key at all; + `email_template`'s is the camelCase `emailTemplates`, so the snake_case plural + the registry derivation adds is URL-only too: + + ``` + PUT /metadata/translation/:name → org-scoped row (correct) + PUT /metadata/translations/:name → env-wide row (the defect) + PUT /metadata/email_template/:name → org-scoped row (correct) + PUT /metadata/email_templates/:name → env-wide row (the defect) + ``` + + Storage folded both spellings to the same canonical type, so the rows differed + in `organization_id` alone: one item in two partitions, addressed by spelling. + Measured end-to-end through the real dispatcher, protocol and repository — + writing an item under both spellings left **two** `sys_metadata` rows where + there should be one, and the env-wide one is shadowed by every read the + org-active author makes. Persisted, receipted 200, served by nothing. + + `GET /metadata/:type/:name/published` is the smaller second site of the same + class. After the layered overlay consult misses, the fallback reads the + code/package store, which is keyed by canonical type; handed the raw segment it + answered **404** under a recognised plural for an item the singular twin + answered **200** for. + + Both sites now fold through `canonicalMetaUrlType` at the boundary — the + correction the REST `/meta` doors already carry, and the one + `metadata-url-spelling.ts` mandates ("folding happens at the boundary and only + there; the layers below keep reading the single canonical singular"). ⛔ Not by + widening `declaresOrgOverride`: a predicate below the boundary consuming the + URL spelling contract is the repair that module's header forbids. + + Only the scope **argument** is folded. The request `type` stays the raw + segment, exactly as the REST doors leave it — the protocol boundary folds it + itself, and two pre-folds would hide a drift between them from the protocol's + own tests. A type the contract does not map (a plugin-registered kind such as + `webhook`) still reaches the store verbatim: the fold is a lookup, never a + spelling guesser. + + ⚠️ Whether real callers reach this transport with plural spellings has **not** + been measured. The REST transport was the measured, user-visible surface; this + one is corrected so the class is closed on both transports rather than one. +- 735f5c7: **Federation:** `SchemaDiffEntry` gains a distinct `unreachable` kind — "the remote could not be read" is no longer reported as `missing_table`, and a transient outage no longer aborts boot under the default `onMismatch: 'fail'` (#11166, maintainer ruling 2026-08-23). + + `ExternalDatasourceService.validateEach` used to convert **any** per-object validation throw — including `connect ECONNREFUSED` from remote introspection — into a `{ kind: 'missing_table', severity: 'error' }` row, indistinguishable from a genuinely dropped table. Downstream, that shape meant: the boot gate (`ExternalValidationPlugin.runValidation`) aborted startup for a 30-second network blip, and the background drift checker raised `external.schema.drift` events claiming the schema changed on every tick the remote stayed down. + + Now: + + - **`@objectstack/spec`** (minor): `SchemaDiffEntryKind` adds `'unreachable'` — the one kind that asserts *nothing about the remote schema*; it states that validation was indeterminate because the remote (or the object definition) could not be read. The throwing error's text is carried in `actual`. Every other kind remains a measured fact about a schema that was successfully read. Additive: existing entries and their meanings are unchanged. Consumers that exhaustively switch on the kind union (e.g. a `Record`) will get a compile-time prompt to label the new member; non-exhaustive consumers see a new string value at runtime and should render it as-is. + - **`@objectstack/service-datasource`** (patch): the per-object catch in `validateEach` classifies every throw as `unreachable` (rows stay `ok: false`, `severity: 'error'`). `missing_table` is still reported — but only from its measured branch: a table absent from an introspection that returned. + - **`@objectstack/runtime`** (patch): the boot gate no longer feeds `unreachable` rows to the `onMismatch` policy — no abort under `fail`; instead it logs a loud `warn` naming the datasource, the object, the underlying error, and that the object's schema is unverified for this boot, under every `onMismatch` value. Measured mismatches keep the existing policy behavior, including sitting beside an unreachable row in the same report. The drift checker still emits `external.schema.drift` for unreachable rows (consumers discriminate on `kind`), but its operator-facing summary now says "could not read the remote", never "drift detected", for them. +- 9981f31: Ship a **declared** `api.projectResolution` from the standalone boot path (#11999) + + `@objectstack/runtime`'s `createStandaloneStack()` / `createDefaultHostConfig()` + returned `api: { enableProjectScoping: false, projectResolution: 'none' }`, and + `os serve` forwarded it unchanged. `'none'` is not a member of the declared enum: + `RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) declares + `z.enum(['required', 'optional', 'auto'])`. Three packages disagreed about this + key's vocabulary, and the disagreement survived because nothing ever executed + the schema — `RestServer` cast its config instead of parsing it. + + `StandaloneStackResult['api']` now declares, and the factory now emits, + `projectResolution: 'auto'`. + + **Behaviour on the routing path is unchanged, and that is measured, not assumed.** + Every reader that acts on this key is gated on `enableProjectScoping` first: + `RestServer.registerRoutes` takes its `else` arm, `mountAndRecordDirectRoutes` + mounts `[versionedBase]`, and the Dispatcher plugin's two + `enableProjectScoping && … === 'required'` guards short-circuit. With scoping off + the strategy really is moot for routing — which is why this migrates the value + rather than teaching the enum a fourth member. + + **One reader is not gated, and that is the user-visible fix.** `RestServer`'s + discovery handler copies `api.projectResolution` into + `discovery.scoping.resolution` unconditionally, and `DiscoverySchema` declares + that field as the same three-member enum. So `GET /api/v1` on every `os serve` + boot advertised a payload the platform's own schema rejects. Clients that + validate discovery — or switch on `scoping.resolution` — now receive a declared + value. + + Both halves are pinned rather than described: `merge-boot-config.test.ts` parses + the CLI's real boot block against `RestApiConfigSchema` and against the discovery + field's enum, and `standalone-stack.test.ts` parses the block the factory + actually returns. Each pin asserts the refusal of `'none'` alongside the + acceptance of `'auto'`, so it can be seen to say no. The CLI constant is now + typed as `StandaloneStackResult['api']`, so it can no longer drift from the + producer without failing `tsc`. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [ef52884] +- Updated dependencies [6747718] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [983edf1] +- Updated dependencies [f93df4d] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [178f90c] +- Updated dependencies [56d3c7a] +- Updated dependencies [84de7e3] +- Updated dependencies [7181101] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [5cb62d8] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [6171331] +- Updated dependencies [0010797] +- Updated dependencies [3d79144] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [d2b2381] +- Updated dependencies [f9ffd01] +- Updated dependencies [376c70f] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [09f9361] +- Updated dependencies [c804f0c] +- Updated dependencies [9d3c04d] +- Updated dependencies [fcd0efc] +- Updated dependencies [d0e3a88] +- Updated dependencies [dd4113e] +- Updated dependencies [992161b] +- Updated dependencies [64505a5] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [78f65ef] +- Updated dependencies [107bb4b] +- Updated dependencies [f4e7ae5] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [bbf1167] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e170b0a] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [ece4dad] +- Updated dependencies [0e5bea6] +- Updated dependencies [9abe4e4] +- Updated dependencies [e40a28c] +- Updated dependencies [7e83932] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [c5b9ccc] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [c05b40b] +- Updated dependencies [80f1dcd] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [6757eb2] +- Updated dependencies [fe72aa5] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [6e33394] +- Updated dependencies [911da5f] +- Updated dependencies [7986d97] +- Updated dependencies [1c66fe4] +- Updated dependencies [033a34c] +- Updated dependencies [b826390] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [18b53ac] +- Updated dependencies [e1d773e] +- Updated dependencies [9a884c6] +- Updated dependencies [1cba33f] +- Updated dependencies [da1126a] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [5dd3bc9] +- Updated dependencies [4045b95] +- Updated dependencies [7adcd07] +- Updated dependencies [f5a7f9c] +- Updated dependencies [c49afd0] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [f24c90d] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/driver-sql@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-protocol@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/service-datasource@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/metadata@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/driver-memory@17.3.0 + - @objectstack/driver-sqlite-wasm@17.3.0 + - @objectstack/formula@17.3.0 + - @objectstack/observability@17.3.0 + - @objectstack/service-cluster@17.3.0 + - @objectstack/service-i18n@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 48c027a06a..3d5b89fda7 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.2.0", + "version": "17.3.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 0c1ffb8678..ca0e60d4c7 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,27 @@ # @objectstack/sdui-parser +## 17.3.0 + +### Patch Changes + +- 34f60b7: The JSX-source parser no longer deletes the space that separates a text run + from an adjacent sibling element. `parseChildren` collapsed each text run's + whitespace to a single space (correct — that is HTML's own whitespace model) + and then `.trim()`ed it (not correct — HTML collapses a whitespace run to one + space, it does not delete it), so `A x page` compiled to + `['A', {strong}, 'page']` and the words ran together wherever that tree is + rendered. + + The rule now applied: collapse the run, then keep one leading space when a + sibling precedes it and one trailing space when a sibling element follows it; + at the parent's own start/end the edge space is still dropped, so + `

    hi

    ` still compiles to `['hi']`. It is deliberately mechanical — it + invents no block/inline taxonomy for a schema tree that has none. Its one + bounded cost is that a whitespace-only run between two siblings survives as a + single space, so a pretty-printed `
      ` gains one `' '` child per inter-item + gap; the tests pin that bound. This matches the rule the downstream copy of + this parser already applies, so the two agree on the tree they produce. + ## 17.2.0 ## 17.1.0 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 59e6fab6fd..734ec9c716 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.2.0", + "version": "17.3.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 e14d94abc5..59fb96b120 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,148 @@ # Changelog — @objectstack/service-analytics +## 17.3.0 + +### Minor Changes + +- 74cee59: Resolve `{current_user_id}` (and every other filter placeholder) on the direct analytics query path, at parity with the list path and the dashboard dataset path. + + What changes for an app author: a widget or report whose filter says `owner: '{current_user_id}'` used to render `0` for every viewer whenever the query reached the SQL strategy — the literal text was bound into the `WHERE` and matched no row, silently. Now the same filter expression means the same thing on every surface: `AnalyticsService.query` and `generateSql` expand `where`, `timeDimensions[].dateRange`, and a registered dataset's own filter / measure filters against the requesting user before any strategy compiles, so each viewer gets their own rows. A placeholder that cannot be resolved — an unknown spelling, or `{current_user_id}` on an unauthenticated request — now refuses loudly with `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` (HTTP 400) instead of charting a plausible zero. + + This also closes a gap on the dashboard dataset door: the dataset-scope channel used to hand strategies the registry's unresolved filter copy, which was ANDed in beside the resolved one (`owner = $viewer AND owner = '{current_user_id}'`) and selected nothing. +- 399ecad: `ObjectQLStrategy` now refuses a cross-object leaf in a compiled measure's own `filter`, on both of its doors, instead of sending it to an engine that cannot join (#11461). This is the third producer of a predicate on that path — after the caller's `where` and the dataset's definition-level `filter` (#10861) — and the one `filterMemberView` did not fold in: #10413 phase 2 lowers `measureFilters[m]` onto that measure's `aggregations[].filter` entry (#10576), and the envelope check enumerated only two origins while its `query.measures` arm read each measure's resolved *field* and never its filter. + + Measured on one fixture before the change, both doors in one run: a measure declaring `filter: { 'account.region': 'West' }` on a cube with `include: ['account']` was ACCEPTED, `engine.aggregate` received `{field:"*",method:"count",alias:"west_count",filter:{"account.region":"West"}}`, and an honest evaluator answered `west_count: 0` where the truthful answer was `2` — beside a correct `total_count: 3`, so the wrong number came back wearing the same response shape as the right one. The `/analytics/sql` echo rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` carrying no join at all. Both doors now answer `INVALID_FIELD`/400 before the engine is reached, naming the offending field, the dataset, and — the locator neither sibling refusal has — the measure whose declaration holds the leaf. + + Ordinary per-measure filters are unaffected and still reach the engine carrying their own `aggregations[].filter`, and a cross-object filter declared on a measure a query does not ask for changes nothing: only the measures in `query.measures` are judged, which is exactly the set both doors lower. The same definition remains valid on a native-SQL driver, which the refusal says. + +### Patch Changes + +- 017130a: The ObjectQL analytics strategy now refuses a custom-SQL measure (`AggregationMetricType` `number` / `string` / `boolean`) with a loud `400 INVALID_FIELD` naming the measure and its metric type, instead of forwarding the raw SQL expression into `engine.aggregate` — where `driver-sql` rejected it blaming a `function` key the author never wrote, and the in-memory evaluator silently answered `null` for every bucket under the measure's own name. + + What stops being served, and for whom: on deployments whose driver has no native SQL capability (the ObjectQL aggregate path — e.g. Mongo or in-memory), a query or dataset widget selecting a custom-SQL measure now answers a 400 that says to use an aggregate measure (count/sum/avg/min/max/count_distinct) or run the cube on a native-SQL driver. Those queries previously "succeeded" with a per-bucket `null` (or a mis-attributed driver error), never with a correct number. Native-SQL driver behaviour is unchanged: custom-SQL measures still run there, emitted verbatim. +- 466b389: **Fix:** `/api/v1/analytics/query` on the ObjectQL door (MongoDB, the memory driver, or any deployment whose driver reports `objectqlAggregate` but not `nativeSql`) now honours a measure's own scoped `filter` — `won_count` and `won_amount`-style conditional measures answer the same numbers the dashboard door and the native-SQL door already did (#10413 phase 2). + + `ObjectQLStrategy.execute` lowers each measure's `filter` into the ONE aggregation it belongs to, via the per-aggregation `filter` field #10576 added to `engine.aggregate`'s contract (SQL `FILTER (WHERE …)` semantics) — not into the whole-call filter, which would have narrowed every measure (a fix shaped that way would make a conditional measure right while making every unconditional sibling measure in the same query wrong). An aggregation with no measure filter is unchanged and keeps the native-pushdown-eligible shape. + + `ObjectQLStrategy.generateSql` (the `/analytics/sql` echo) renders the same conditional aggregate — `COUNT(CASE WHEN … THEN … END)`-style — so the preview stays an honest description of what `execute()` now actually runs, matching the native-SQL strategy's existing echo for the same class of measure. + + Phase 1 (PR #10758) already ANDed a dataset's definition-level `filter` into the whole-call filter on this door; this closes the remaining half of the two-door disagreement #10413 reported. `NativeSQLStrategy` (#10298 / PR #10411) is unaffected by this change. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 5760448f3c..cdff3f86c2 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.2.0", + "version": "17.3.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 7f149225f9..3dcb2765e2 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,421 @@ # @objectstack/service-automation +## 17.3.0 + +### Minor Changes + +- 0d7b1f3: fix(service-automation): one renderer for the contested-flow phrase, and the two spellings it had drifted into (#12563) + + `minor`, not `patch`, and not empty: this adds a new export + (`renderFlowContender`) to a published package's public API, and it changes + **shipped operator-facing log text**. Both are real changes a consumer can + observe. + + ## What changed + + One event — a flow name claimed by more than one definition — was described to + an operator in three places, each with its own private `const describe` beside + the log call: `flow-precedence.ts`'s precedence warning, `plugin.ts`'s bootstrap + audit, and (in `@objectstack/cli`) the startup banner. Nothing held them equal, + and two axes had already drifted: + + - **Quoting.** `flow-precedence.ts` rendered `package "crm"`; the other two + rendered `package 'crm'`. + - **Absent package id.** The two engine copies interpolated a bare `undefined` + into the sentence; the CLI copy rendered a real fallback. + + The two copies in this package are now one exported renderer. The choice on + each axis was measured, not voted: + + - **Single quotes**, measured against this package rather than across the three + copies: of the interpolated identifiers in operator prose under + `service-automation/src`, 203 are single-quoted and 3 double-quoted — one of + those 3 being this phrase. The sentence already single-quotes the flow name + beside it. + - **A named fallback** (`a code-shipped package (id unknown)`) instead of + `package 'undefined'`. This package's own callers cannot reach that branch + today, because `isCodeArtifactBody` is false on a falsy `_packageId` — but + that is a property of today's callers, not of an exported function. + + ## Log text a consumer may be matching on + + `[Automation] Flow name collision: …` (the precedence warning) now renders a + packaged contender as `package 'crm'` rather than `package "crm"`. + `plugin.ts`'s bootstrap `[Automation] flow '' is claimed by …` warning is + byte-identical to before for every input its callers can produce; only its + unreachable absent-id branch changed. + + ## Why the CLI still renders its own + + `@objectstack/cli` deliberately keeps its own spelling and takes no value + import of this package for the banner: its engine reads are structural and + feature-detected so a host on an older automation package still boots. The + third copy is held equal by a test-only agreement pin + (`packages/cli/src/utils/format.flow-contender-agreement.test.ts`) that asserts + the banner line through this renderer, so it goes red in both directions. +- 8155855: Flow value expressions (`create_record`/`update_record` `config.fields`, `assignment` `config.assignments`) now support a small numeric function table — `round`, `floor`, `ceil`, `abs`, `min`, `max` — with every name and semantic mirrored 1:1 from the `@objectstack/formula` CEL stdlib (no second dialect: `round` is integer-only exactly like CEL's; for N-decimal rounding write `round(x * 100) / 100`, the same pattern CEL authors use). A flow can finally write a computed money value that satisfies its field's declared `scale` (`{round(amount * (1 - discount / 100) * 100) / 100}` → a `scale: 2` currency field). + + Loud diagnostic in the same stroke: an identifier in call position that is not a supported function — `ROUND(...)`, `Math.round(...)`, `(x).toFixed(2)`, or the next name anyone invents — now fails the node with a named `FlowExpressionFunctionError` (guard-marked, so a `fault` edge cannot swallow it) instead of being silently rewritten to `null` and writing the field as `undefined`. Non-call template resolution is unchanged: unresolved plain tokens still become `null`/empty, and `NOW()`/`TODAY()` whole-token macros behave exactly as before. +- 1524927: Packaged flows can be switched off durably, and the process-local off-switch is retired + + Disabling a packaged flow now writes an install-level row to the + `sys_metadata_activation` ledger (ADR-0126 §4/§7.2) instead of setting a + process-local map. The engine consults that ledger at the `execute()` seam — + the one seam every entry path crosses (record-change, schedule, time-relative, + api, subflow) — and refuses a disabled flow there with the existing + `FLOW_DISABLED` code; the ledger case is distinguished by the message, so no + new error code joins the ADR-0112 ledger. An install-level disable also unbinds + the flow's trigger, and re-enabling rebinds it. Absence of a row means the + packaged default, active, so a deployment that never flips anything behaves + exactly as before. + + This retires the mechanism behind #10243 rather than refining it. The old + `flowEnabled` map was not a row, so no organization wall scoped it: on a walled + multi-organization deployment a tenant org owner could switch a shipped flow + off environment-wide and an unrelated tenant read it off. The durable row + replaces it, and because a durable install-wide switch writable by tenants + would be that leak with persistence, the write is now authority-gated: + `POST /automation/:name/toggle` requires the platform operator in the `group` + and `isolated` postures, while the `single` posture — where install-level and + org-level are the same scope — is unchanged for the org admin who already holds + `manage_metadata`. The refusal names the posture and points at the clone path. + + Disabling a flow that packaged flows still call as a subflow is refused, and + the refusal names the callers (ADR-0126 §7.3). Without it a vendor flow breaks + mid-run at its subflow node with an inexplicable late failure. The check is a + definition scan at disable time over both `subflow` and `map` nodes; no + reference index is built. Enabling is never guarded. + + One behaviour change worth calling out: a disable now survives + unregister-and-re-register, which is what a package upgrade, a Studio publish + and the boot pull all do. ADR-0126 §6 requires it — the ledger records the + customer's choice, and no upgrade un-makes a choice — but it is the opposite of + what the retired in-process map did, where any re-registration silently + re-armed the flow. +- af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350) + + Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling + (2026-08-26, verbatim and untranslated: 「同意」). + + ## The registration follows the declaration (#12359) + + `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but + the only thing that REGISTERED it was the automation service's manifest — + because flows were the ledger's first and, until packaged actions landed, only + consumer. Packaged actions are a second consumer with a different owner: their + consult and write path live on the ObjectQL engine, present in every + composition that can execute an action. + + So a deployment with actions and no automation service had no ledger table, and + the activation door answered **503 SERVICE_UNAVAILABLE** on every flip — + correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be + reported as one) and permanently. Measured on a real boot; it is now this + change's positive test, measured on the same boot: + + ``` + POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false} + before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED + ``` + + `PlatformObjectsPlugin` registers it now, so every composition carrying + platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`, + `skill`, `position`) inherits it. **MOVE, not add** — the automation service no + longer names the object. That was not a style choice: a second code package + claiming one object throws `Object "…" is already owned by package "…"` + (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot + failure rather than a duplicate. + + **Upgrade is a no-op for existing data, and that is measured rather than + asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding` + step 4 routes an object by its owning package's `defaultDatasource` — so the + registrar carries the table's datasource with it: + + ``` + owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud' + owner com.objectstack.platform-objects (none) -> undefined (global default driver) + ``` + + The ledger table already exists in live databases, so on any deployment + carrying a `cloud` datasource that difference would leave the rows in one + database and read another — every disabled artifact silently re-arming. The + ledger therefore rides its own manifest from the same plugin, carrying the + automation manifest's `scope` / `namespace` / `defaultDatasource` triple + verbatim. The three siblings (`sys_migration`, `sys_migration_journal`, + `sys_secret`) deliberately do not get it and keep riding the project database. + + ## One implementation of the §4 row contract (#12350) + + ADR-0126 §4 declares one activation ledger; it had two independent + implementations of that one row contract — `ObjectStoreFlowActivationStore` + (service-automation) and `ObjectStoreActionActivationStore` (objectql). They + agreed because the second was written from the first, and nothing structurally + held them together; §8 pre-charts `tool`, `skill` and `position`, and a third + and fourth copy is where the org-row skip and the `0`-is-false read get lost + quietly, in the direction (an artifact re-arming) nothing else measures. + + Neither consumer could import the other, so the contract now lives once in + `@objectstack/core` — the package both already depend on — as + `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside + `InMemoryMetadataActivationStore`, `MetadataActivationRow`, + `MetadataActivationStore`, `MetadataActivationStoreEngine` and + `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own + one-argument constructor and its own docs, and fixes the discriminator. + + **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` / + `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and + `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` / + `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine` + / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same + shapes. Row semantics are byte-equivalent: install-level rows only + (`organization_id` never written), org-carrying rows skipped on read and + ignored when deciding insert-vs-update, a driver `0` read as false, + read-then-write rather than a blind upsert, and no `delete` in the engine slice + because re-enabling rewrites the row. + + Both existing pin suites stay green **unchanged**, which is what makes them the + proof the consolidation lost nothing — verified by ablation: removing the + org-row skip from the one shared implementation turns both of them red on their + own org-skip assertion, so both really reach it. + +### Patch Changes + +- e577445: The `notify` node's Studio form and the messaging registration log now state the locale the delivery path actually resolves — one per notification, not one per recipient + + `NotifyConfigSchema` was corrected in `packages/spec` to say that the `template` + path resolves `(name, locale)` with **one** locale for the whole notification. + The same retired promise survived outside the spec file, in the places an app + author is most likely to read it: + + - `service-automation/src/builtin/notify-node.ts` — the `template` field's + `configSchema` description, i.e. the text rendered in the **Studio form** the + author fills in. It said the row is "resolved by (name, recipient locale) at + delivery time and rendered per recipient". + - `content/docs/automation/email-templates.mdx` — the only site that stated the + conclusion outright rather than merely licensing it: "so one node mails each + person in their own language". + - `service-messaging/src/messaging-service-plugin.ts` — the channel-registration + log line, which advertised "resolve sys_email_template per recipient locale". + - Two internal comments in `notify-node.ts` and one in its test, describing the + payload the outbox snapshots as carrying a per-recipient-locale resolution. + + None of that is what the delivery path does. `payload.locale` is interpolated + **once, before fan-out**, so it is a single value for the whole notification, and + its fallback is the deployment default (`II18nService.getDefaultLocale()`). The + platform has no per-user locale to read — `sys_user` carries no locale column, + and request-scoped locale does not exist at async delivery time — so recipients + whose personal languages differ all receive the same template row. A per-user + locale is deferred until measured pull (maintainer ruling, 2026-08-13) and layers + in as an override at that same seam when it lands; the corrected wording dates + the deferral so it reads as a decision with provenance rather than an oversight. + + The gap was worth correcting because the wording licensed exactly one action — + convert `notify` nodes on the belief that non-English recipients get non-English + mail — and that action is a **net regression**: `TEMPLATE_*` failures classify + `permanent` and dead-letter, and the inbox channel starts requiring an email + service with `renderTemplate()` where inline text needed none. + + Text only: no schema accepts or refuses anything it did not before, no delivery + behaviour moves, and no wire value changes. A new pin in `notify-node.test.ts` + asserts the form description names `payload.locale` and the deployment default + and refuses a bare "recipient locale", so a later edit cannot quietly restore the + promise. +- 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary. + + - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence. + - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included. + - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all. + - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged. + + The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled. +- ffbb7a1: Stamp `organization_id` on flow-produced notifications and on `markRead` + receipts, so the notification family stops writing org-less rows + + An application project's read-only inventory found `sys_inbox_message`, + `sys_notification`, `sys_notification_receipt` and `sys_notification_delivery` + carrying `organization_id = NULL` on **100%** of their rows — existing rows and + same-day new ones alike, while `sys_approval_request` in the same database + carried an organization on every row. Ruled a gap, not a design choice. + + Everything below the messaging ingress was already threaded: `emit()` stamps the + `sys_notification` event, the inbox channel stamps `sys_inbox_message` and its + `delivered` receipt, and the outbox carries the value onto + `sys_notification_delivery`. Each of them reads `EmitInput.organizationId` — + and the `notify` flow node, the dominant producer, never supplied it. Its local + structural mirror of `emit()` did not even declare the field, so the value could + not have been passed. One missing argument, four tables at 100% null. + + The node now threads the organization from the run's own acting context + (`AutomationContext.tenantId`), the same source the `collab.mention` producer in + `@objectstack/plugin-audit` already uses, so the two notification producers agree + about whose organization a notification carries. + + A second producer of the same table is fixed alongside it: the `read` receipt + `markRead` inserts — written when a user reads a notification whose delivered + receipt never landed — named no organization at all. It now carries the + organization of the `sys_notification` row it is about. + + There is deliberately **no fallback limb** in either producer: not "the current + organization", not the install's first organization, not the recipient's first + membership. A run with no organization in scope still emits and still writes its + rows, and the `notify` node warns audibly naming the topic and the consequence. + A wrong `organization_id` is worse than a null — a null is visibly missing, + while a wrong value is silently authoritative to every report, export and + cleanup script that filters by organization. + + Forward-stamping only. Existing rows are not backfilled and no migration ships. +- aa0688a: Arm a deterministic flow when a runtime-authored flow reuses a packaged flow's name + + A runtime-authored flow that reused a packaged flow's name silently replaced it, + and which of the two ended up armed depended on registration order. The metadata + registry keys items `packageId:name` and deliberately coexists both (ADR-0048 + §3.4), `listItems('flow')` returns both with no precedence, and the automation + engine keys flows by bare name — so the boot pull registered both under one key + and Map iteration order picked the survivor. Measured: registering the package + first armed the runtime flow, registering the runtime row first armed the + packaged flow, with no warning and no way to tell which had won. + + The boot pull now collapses same-named definitions before anything is armed, + applying the ADR-0005 overlay precedence ADR-0048 §3.4 routes this case to: the + runtime/DB overlay wins over the packaged artifact, which is the sanctioned + override path. Two packages shipping one bare name resolve by package id, so + boot order no longer decides anything. + + Collisions are no longer silent. The pull warns once per colliding name — naming + the name, every contender, and which one is armed — and repeats it at bootstrap + beside the other automation audits. `getShadowedFlows()` is a new receipt listing + each contested name with its armed and shadowed definitions, and + `getFlowRuntimeStates()` rows now carry `armedFrom`/`shadowed` for contested + names; previously the displaced definition was invisible by construction, since + the flow map holds one entry per name. The `Pulled N flow(s)` line now counts + distinct names rather than registrations. + + `isCodeArtifactBody` is exported from `@objectstack/objectql` so consumers that + collapse same-named metadata answer "does a code package ship this?" with the + registry's own test instead of re-deriving it from `_packageId`. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/formula@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index aae37c910d..76002f6bb8 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.2.0", + "version": "17.3.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 459184f18b..9c6e1dbfda 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,121 @@ # @objectstack/service-cache +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/observability@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index e82f367a6f..524ba6444c 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.2.0", + "version": "17.3.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 77b7ba9092..a75b66c7d2 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,115 @@ # @objectstack/service-cluster-redis +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/service-cluster@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 1a8eeef897..ab5c213253 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.2.0", + "version": "17.3.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 af73e2e379..f5285eacf9 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/service-cluster +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index 00b7469ab7..a40ad2ed3a 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.2.0", + "version": "17.3.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 0b7f1295f4..32f115ec84 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,190 @@ # @objectstack/service-external-datasource +## 17.3.0 + +### Minor Changes + +- 3d79144: feat(service-datasource): `DatasourceDriverHandle.introspectSchema` declares the spec introspection contract, so a mis-shaped custom driver fails to compile naming the wrong field (#11381, option C of the #11123 ruling) + + **BREAKING** for TypeScript hosts that build custom external-datasource + drivers, shipped as `minor` under the repo's launch-window convention for + breaking changes. + + `DatasourceDriverHandle.introspectSchema` — the seam every host-built driver + crosses, since the framework deliberately ships no driver-by-id registry — + was typed `Promise`. The `isPrimary` → `primaryKey` retirement + (#11124, shipped in 17.2.0) named the compiler as the channel that reaches + every affected consumer, but against an `unknown` return that channel + provably never fired: a host driver spelling the per-column primary-key flag + `isPrimary`, or returning `{ tables }` with no `dialect`/`introspectedAt`, + compiled clean, and the mis-shape surfaced only as a federated table whose + records silently could not be located or updated. + + The member now declares `Promise` — the one introspection + contract in `packages/spec` (`contracts/schema-diff-service.ts`). A + mis-shaped driver is refused at compile time, at the offending field: + `Property 'primaryKey' is missing in type '…' but required in type + 'IntrospectedColumn'`, and on a fresh literal additionally `'isPrimary' does + not exist in type 'IntrospectedColumn'`. A driver that already returns the + spec shape — or a richer declared type extending it, the driver-sql / + objectql pattern (table-level `primaryKeys`, per-column `maxLength`) — + compiles unchanged. + + Runtime behaviour does not change. The `primaryKeyReader` compatibility belt + in `ExternalDatasourceService` keeps absorbing the retired spelling from + producers no compiler reaches (drivers already built against older versions, + plain-JS drivers, casts). Removing that belt is #11123 option B — a later, + separate step gated on this tightening being released and the retirement + being published — and is not part of this change. + + + +### Patch Changes + +- 30d96ab: `ExternalDatasourceServicePlugin` types the `'data'` service with the real engine contract (`IDataEngine`, `@objectstack/spec/contracts`) and deletes its private structural `DataEngineLike` re-declaration — the workaround the untyped `IDataEngine.introspectDatasource()` forced (#11493). The introspection fallback branch now probes `getDriverByName?` (the registry member the contract declares) instead of `getDatasourceDriver?`, a spelling no engine in either repository ever had, so the degradation path is reachable for the first time. +- bbf1167: `importObject` now refuses an explicit `opts.name` that violates the ADR-0028 + namespace-prefix rule (#11061). The override used to be taken verbatim + (`opts.name ?? draft.name`) and persisted through `metadata.register('object', + …)` — the one runtime write path no namespace gate looks at — so + `POST /api/v1/datasources/:name/external/tables/:remote/import` with + `{"name": "customers"}` minted an unprefixed federated object that + `defineStack()` and the publish pre-flight would both have refused. + + The refusal answers `400 EXTERNAL_IMPORT_ERROR` (the family's registered + ADR-0112 code, in the #8016 thrown-refusal shape) carrying + `validateObjectNamespacePrefix`'s own actionable message — the same text the + publish gate serves for the identical violation, e.g. `Object 'customers' is + missing the package namespace prefix. Rename it to 'wh_customers' (namespace = + 'wh').` A compliant override (`wh_customers`), a `sys_*` platform-reserved + name, and any override on a datasource whose package resolves no namespace are + accepted exactly as before; the derived-name path (no `name` in the body) is + unchanged. +- 735f5c7: **Federation:** `SchemaDiffEntry` gains a distinct `unreachable` kind — "the remote could not be read" is no longer reported as `missing_table`, and a transient outage no longer aborts boot under the default `onMismatch: 'fail'` (#11166, maintainer ruling 2026-08-23). + + `ExternalDatasourceService.validateEach` used to convert **any** per-object validation throw — including `connect ECONNREFUSED` from remote introspection — into a `{ kind: 'missing_table', severity: 'error' }` row, indistinguishable from a genuinely dropped table. Downstream, that shape meant: the boot gate (`ExternalValidationPlugin.runValidation`) aborted startup for a 30-second network blip, and the background drift checker raised `external.schema.drift` events claiming the schema changed on every tick the remote stayed down. + + Now: + + - **`@objectstack/spec`** (minor): `SchemaDiffEntryKind` adds `'unreachable'` — the one kind that asserts *nothing about the remote schema*; it states that validation was indeterminate because the remote (or the object definition) could not be read. The throwing error's text is carried in `actual`. Every other kind remains a measured fact about a schema that was successfully read. Additive: existing entries and their meanings are unchanged. Consumers that exhaustively switch on the kind union (e.g. a `Record`) will get a compile-time prompt to label the new member; non-exhaustive consumers see a new string value at runtime and should render it as-is. + - **`@objectstack/service-datasource`** (patch): the per-object catch in `validateEach` classifies every throw as `unreachable` (rows stay `ok: false`, `severity: 'error'`). `missing_table` is still reported — but only from its measured branch: a table absent from an introspection that returned. + - **`@objectstack/runtime`** (patch): the boot gate no longer feeds `unreachable` rows to the `onMismatch` policy — no abort under `fail`; instead it logs a loud `warn` naming the datasource, the object, the underlying error, and that the object's schema is unverified for this boot, under every `onMismatch` value. Measured mismatches keep the existing policy behavior, including sitting beside an unreachable row in the same report. The drift checker still emits `external.schema.drift` for unreachable rows (consumers discriminate on `kind`), but its operator-facing summary now says "could not read the remote", never "drift detected", for them. +- 29d0676: `validateAll`/`validateDatasource` now read each datasource's live schema once per sweep instead of once per federated object: the sweep threads a per-call introspection memo through the validation body, so M objects on one datasource cost one remote introspection round-trip (a rejected read is shared the same way — one connection attempt, M failure rows). The memo lives and dies inside a single call, so a long-lived service never serves a stale schema to a later sweep, and direct `validateObject` calls still read live every time. The `IExternalDatasourceService.validateAll` docstring, which promised "parallelised per datasource" while the implementation parallelised per object, now states the actual behaviour. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index b070dde947..6e1cf675be 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.2.0", + "version": "17.3.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 0088f86bc5..8f422434ba 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,125 @@ # @objectstack/service-i18n +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index fe1ff712ad..c2bb3edc06 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.2.0", + "version": "17.3.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 da0654086d..1993c6817c 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-job +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 1a2c612871..37d194665a 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.2.0", + "version": "17.3.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 e2186cab56..35be1e9df0 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/service-knowledge +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 2cfa109743..6adf09a306 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.2.0", + "version": "17.3.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 3bb4b7e8d7..f53cdb9b42 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,329 @@ # @objectstack/service-messaging +## 17.3.0 + +### Minor Changes + +- b6d9432: **Feature:** `MessagingService` gains a plugin-facing inbox write door scoped to the **authenticated caller** — `markReadAsCaller(caller, ids)` and `markAllReadAsCaller(caller)` (#10753). + + A plugin that pushes an "…awaiting your approval" message through `emit()` had no legitimate way to close it out again once the work was done, so the Console bell's unread badge stayed lit through a full page reload until the user hit "mark all read". The reporting project carries 30+ business hooks in that shape. + + What it was reaching for instead is the shape this closes. `markRead(userId, ids)` is the REST door's contract method (`INotificationService.markRead?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin ONE shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string. **Any plugin could mark any user's inbox messages read**, and the receipt lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check saw it either. This release is therefore both an API widening and the first tightening of in-process power on that path. + + The new pair takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId`, so "mark someone else's inbox read" has no spelling on this surface — it is unrepresentable rather than discouraged. That fits the case it was asked for exactly: the approver who clears a request *is* the recipient whose badge is stuck. + + `userId` is read, and nothing that merely resembles one: + + - `attributedUserId` is **attribution only** — its own contract states that nothing in the authorization path reads it, and a context carrying only it authorizes as anonymous (ADR-0118 D2). A `userId ?? attributedUserId` fallback would read as working and clear the wrong person's badge. + - `actor` is a service-principal label (`svc:`), not a `sys_user` id. + - `isSystem: true` with no user is refused rather than elevated: the system has no inbox to be the recipient of. + + Each refusal throws `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401` and the registered `code: 'UNAUTHENTICATED'` — and the refusal is evaluated **before** the empty-`ids` and no-data-engine short-circuits, which return `{ success: true, readCount: 0 }`. Reaching one of those with no authenticated caller would report success for a write that was never authorized, which is the silent-success shape this door exists to replace. + + Honest about what it is: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can write `sys_notification_receipt` directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. + + Nothing existing changes behaviour: `markRead` / `markAllRead` / `listInbox` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. +- 3b5f036: **Feature:** `MessagingService` gains the plugin-facing inbox **read** door scoped to the authenticated caller — `listInboxAsCaller(caller, opts)` (#11452), completing the axis the write door (`markReadAsCaller` / `markAllReadAsCaller`, #10753) established. + + `listInbox(userId, opts)` is the REST door's contract method (`INotificationService.listInbox?`), and on that path its `userId` is trustworthy because `runtime/src/domains/notifications.ts` binds it to an already-authenticated session and answers 401 when there is none. But the service is also a kernel service, and the kernel hands every plugin one shared `PluginContext` whose `getService` carries no caller identity — so for an in-process caller that same parameter is a free string, and the read lands context-lessly on an `engine-owned` object (ADR-0103), so no engine permission check sees it either. Any plugin could read any user's inbox titles, bodies and read-state — the exact shape the write door closed, on the arguably more sensitive half: inbox bodies carry rendered business content. + + `listInboxAsCaller` takes **no target user at all**. The recipient is derived from the caller's `ExecutionContext.userId` through the same `resolveInboxRecipient` the write door uses — one refusal vocabulary, not two. `attributedUserId` (attribution only, ADR-0118 D2), `actor` (a service-principal label) and `isSystem` are refused rather than promoted, with `InboxCallerError` carrying the ADR-0112 envelope pair a boundary reads — `status: 401`, registered `code: 'UNAUTHENTICATED'`. The refusal is evaluated **before** `listInbox`'s no-data-engine / no-user short-circuit, which answers a well-formed empty `{ notifications: [], unreadCount: 0 }` inbox — the read-side analog of the silent success the write door replaced. The options window (`read` / `type` / `limit`) is forwarded unchanged. + + Honest about what it is, same as the write door: a **discipline** boundary, not a security boundary. An in-process plugin already holds the data engine and can read `sys_inbox_message` rows directly; nothing at this layer stops trusted code that means to. What changes is that the correct pattern is the only one the plugin-facing surface expresses, and the incorrect one now fails loudly at the call site. + + Nothing existing changes behaviour: `listInbox` / `markRead` / `markAllRead` keep their signatures (they are the published `INotificationService` contract the REST door needs), and no schema, column or object declaration moves. `resolveInboxRecipient` gains an optional third parameter naming the target-user door its refusal prescribes; it defaults to the write door's existing text, so existing call sites keep their refusal bytes unchanged. +- d9cf78e: **BREAKING (interface member signature):** `INotificationOutbox.ack()` now takes back the claimed record instead of a bare row id, and its compare-and-set binds the claim credential the record carries (#11859). `claim()` / `claimDigest()` declare their true return type, `ClaimedDeliveryRecord[]` — the same rows as before, with the (`claimedBy`, `claimedAt`) pair the store stamps guaranteed present — so reads of claim results do not change; the one breaking edit is at ack call sites, which hand the whole record back where they previously handed `record.id` (the caller already holds it: `ack` completes a claim, and the record is what `claim()` returned). + + Why: `status = 'in_flight'` could prove a claim exists but not whose. In the reachable sequence — node A claims a row, the send outruns `claimTtlMs`, another node's `claim()` reaps and re-claims the row, A finishes late — A's ack still matched and wrote its outcome over B's live attempt. With the credential in the predicate a late ack matches nothing, is refused with the existing `NotificationAckError` (`DELIVERY_NOT_ELIGIBLE`, ADR-0112), and writes nothing; the caller never needs to know its own `nodeId`, because ownership is proven by round-tripping what `claim()` returned. Both implementations (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) enforce it identically. + + Breaking ships as `minor` per the launch-window convention (`scripts/check-changeset-no-major.mjs`). + + +- 1a47a53: `INotificationOutbox.ack()` enforces its declared precondition — the row must be claimed — in both implementations, and `attempts` moves only for a real dispatch attempt + + `ack()` is the dispatcher's completion callback for a row it CLAIMED, and + neither implementation checked that. `MemoryNotificationOutbox.ack` looked the + row up by id and mutated it; `SqlNotificationOutbox.ack` read only `attempts` + by id. So `ack(id, { success: false, suppressed: true })` on an unclaimed + `pending` row succeeded, flipped the row to terminal `suppressed`, and + incremented `attempts` — which made `ack` read like the cancellation primitive + this interface deliberately does not have. + + That was a trap in two directions. It **raced the dispatcher**: between a + caller's `list()` and its `ack()`, `claim()` could take the row — `claim` is + atomic by contract and `ack` was never part of that atom — so a suppression + could land on a delivery already on the wire, or a dispatcher's real outcome + could be overwritten by a caller that thought it was cancelling. And it + **corrupted `attempts`**: the counter feeds the retry schedule + (`classifyDeliveryAttempt(result, errorClass, row.attempts, …)`), so a row + "cancelled" this way arrived at its next real attempt with the backoff already + advanced by an attempt that never went out. + + Both implementations now refuse an ack on a row that is not `in_flight`, + throwing `NotificationAckError` with this package's already-registered + ADR-0112 code `DELIVERY_NOT_ELIGIBLE` — the same refusal + `SqlHttpOutbox.redeliver` raises when its own compare-and-set misses. A refused + ack writes **nothing**: status, `attempts` and `error` are left exactly as they + were, so the row stays claimable and its backoff position stays honest. An id + matching no row remains a silent no-op — an absent row has no state to corrupt + and no claim to lose. + + `SqlNotificationOutbox` does it as an **atomic conditional update** rather than + a read-then-write, because a read cannot hold a row still and a read-then-write + is the same defect wearing a different hat. The precondition is re-stated in + the write (`where: { id, status: 'in_flight' }`), which — per #11009 — must + ride the predicate path: on the by-id path the driver binds only the primary + key and the extra predicate is silently discarded. `attempts` is incremented + inside that condition and nowhere else, so the counter can only move for a row + that was genuinely claimed. A conditional write that matches nothing is + reported rather than passed off as success. + + `NotificationDispatcher` absorbs exactly one refusal — `DELIVERY_NOT_ELIGIBLE` + — logs it and continues with the rest of the batch, because a send slower than + `claimTtlMs` legitimately loses its claim to the visibility-timeout reap, and + letting that unwind the partition loop would strand every still-valid row in + the batch `in_flight` until its own timeout expired. Any other error still + propagates. + + The sibling HTTP outbox is deliberately untouched: `assertHttpRedeliverable` + depends on `IHttpOutbox.ack` incrementing `attempts` unconditionally, so that + `attempts === 0` on a terminal row still means "parked, never sent". +- b706af9: Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + + - `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). + - `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. + - `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). + - `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + + Forward-stamping only: existing org-less `sys_email` rows are not backfilled. + +### Patch Changes + +- e577445: The `notify` node's Studio form and the messaging registration log now state the locale the delivery path actually resolves — one per notification, not one per recipient + + `NotifyConfigSchema` was corrected in `packages/spec` to say that the `template` + path resolves `(name, locale)` with **one** locale for the whole notification. + The same retired promise survived outside the spec file, in the places an app + author is most likely to read it: + + - `service-automation/src/builtin/notify-node.ts` — the `template` field's + `configSchema` description, i.e. the text rendered in the **Studio form** the + author fills in. It said the row is "resolved by (name, recipient locale) at + delivery time and rendered per recipient". + - `content/docs/automation/email-templates.mdx` — the only site that stated the + conclusion outright rather than merely licensing it: "so one node mails each + person in their own language". + - `service-messaging/src/messaging-service-plugin.ts` — the channel-registration + log line, which advertised "resolve sys_email_template per recipient locale". + - Two internal comments in `notify-node.ts` and one in its test, describing the + payload the outbox snapshots as carrying a per-recipient-locale resolution. + + None of that is what the delivery path does. `payload.locale` is interpolated + **once, before fan-out**, so it is a single value for the whole notification, and + its fallback is the deployment default (`II18nService.getDefaultLocale()`). The + platform has no per-user locale to read — `sys_user` carries no locale column, + and request-scoped locale does not exist at async delivery time — so recipients + whose personal languages differ all receive the same template row. A per-user + locale is deferred until measured pull (maintainer ruling, 2026-08-13) and layers + in as an override at that same seam when it lands; the corrected wording dates + the deferral so it reads as a decision with provenance rather than an oversight. + + The gap was worth correcting because the wording licensed exactly one action — + convert `notify` nodes on the belief that non-English recipients get non-English + mail — and that action is a **net regression**: `TEMPLATE_*` failures classify + `permanent` and dead-letter, and the inbox channel starts requiring an email + service with `renderTemplate()` where inline text needed none. + + Text only: no schema accepts or refuses anything it did not before, no delivery + behaviour moves, and no wire value changes. A new pin in `notify-node.test.ts` + asserts the form description names `payload.locale` and the deployment default + and refuses a bare "recipient locale", so a later edit cannot quietly restore the + promise. +- 598b7ec: fix(i18n): re-translate the five leaves that served a superseded source revision (#12065) + + `os i18n extract` merges gaps only, so a revised source string leaves the previous + revision standing in every translated locale — in sync by key, green under + `check:i18n` and counted as translated by `check:i18n-coverage`. The five leaves + `check:i18n-stale-fill` froze in its baseline are re-translated here from the + **current** `en` source, and the baseline is ratcheted to empty in the same change. + + User-visible admin/Setup help text changes in `es-ES`, `ja-JP` and `zh-CN`: + + - `dataset.fields.measures.helpText` (metadata forms) — all three locales promised a + `"certified"` governance flag that was removed from the declaration in 16.0. + - `sys_webhook.fields.method.help` — all three locales served the pre-revision method + enumeration after the source became a prose description. + - `sys_webhook.pluralLabel` — `ja-JP` was an untranslated Latin fill and is now + Japanese; `zh-CN` keeps `Webhook`, which is the term this bundle's own Chinese prose + uses and which carries no plural inflection. + - `sys_http_delivery.fields.attempts.help` — `es-ES` / `ja-JP` held an English fill and + `zh-CN` a translation of the same superseded source; all three now carry the + PARKED / terminal-row clause the source documents. + - `sys_notification_subscription.fields.principal.help` — the selector list was missing + the `owner_of:object:id` and bare-email forms in all three locales. + + No schema, export or runtime behaviour changes: translated-locale leaf values only, + plus the shrink-only ratchet baseline. +- ffbb7a1: Stamp `organization_id` on flow-produced notifications and on `markRead` + receipts, so the notification family stops writing org-less rows + + An application project's read-only inventory found `sys_inbox_message`, + `sys_notification`, `sys_notification_receipt` and `sys_notification_delivery` + carrying `organization_id = NULL` on **100%** of their rows — existing rows and + same-day new ones alike, while `sys_approval_request` in the same database + carried an organization on every row. Ruled a gap, not a design choice. + + Everything below the messaging ingress was already threaded: `emit()` stamps the + `sys_notification` event, the inbox channel stamps `sys_inbox_message` and its + `delivered` receipt, and the outbox carries the value onto + `sys_notification_delivery`. Each of them reads `EmitInput.organizationId` — + and the `notify` flow node, the dominant producer, never supplied it. Its local + structural mirror of `emit()` did not even declare the field, so the value could + not have been passed. One missing argument, four tables at 100% null. + + The node now threads the organization from the run's own acting context + (`AutomationContext.tenantId`), the same source the `collab.mention` producer in + `@objectstack/plugin-audit` already uses, so the two notification producers agree + about whose organization a notification carries. + + A second producer of the same table is fixed alongside it: the `read` receipt + `markRead` inserts — written when a user reads a notification whose delivered + receipt never landed — named no organization at all. It now carries the + organization of the `sys_notification` row it is about. + + There is deliberately **no fallback limb** in either producer: not "the current + organization", not the install's first organization, not the recipient's first + membership. A run with no organization in scope still emits and still writes its + rows, and the `notify` node warns audibly naming the topic and the consequence. + A wrong `organization_id` is worse than a null — a null is visibly missing, + while a wrong value is silently authoritative to every report, export and + cleanup script that filters by organization. + + Forward-stamping only. Existing rows are not backfilled and no migration ships. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 31cedc75ce..654ea2f615 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.2.0", + "version": "17.3.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 f72fc91269..6aaa4b4bb8 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,124 @@ # @objectstack/service-package +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [1272f0a] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [5d16379] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/metadata-core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 1d2aa7aa42..d511879f45 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.2.0", + "version": "17.3.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 ac285d755a..d4481d66f5 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-queue +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 3aa1196fd2..3ff1daa298 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.2.0", + "version": "17.3.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 87ee91b6af..4c11c7213b 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-realtime +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 672c22a134..9bc4bc9f93 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.2.0", + "version": "17.3.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 f6dde33288..6d18f8182a 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,328 @@ # @objectstack/service-settings +## 17.3.0 + +### Minor Changes + +- 8e31083: feat(settings,auth): expose the audience posture in the `auth` settings namespace (#11768) + + The audience posture shipped by #11739 (`invite_only | email_domain | open`, + default `invite_only`) was switchable only from stack config at boot; a + self-host admin had no console channel. The `auth` settings namespace now + carries an `audience` group — three new authorable keys, which is what a host + sees and why this is `minor`: + + - `audience_posture` — a select over the closed vocabulary (the option table + is enforced on `setMany` and on the `OS_AUTH_AUDIENCE_POSTURE` env-override + door); + - `audience_allowed_email_domains` — newline- or comma-separated bare domains + (exact, case-insensitive matching; subdomains need their own entries); + - `audience_self_registration_permission_set` — the `sys_permission_set` name + each self-registrant receives. + + `bindAuthSettings` maps the three keys — one atomic declaration — to one + `AuthManager.applyConfigPatch({ audience })`, which replaces the whole + audience object and validates the MERGED result. Every #11739 invariant holds + through the new channel: a self-registration posture with verification + explicitly off, an empty domain list under `email_domain`, and a missing or + `admin_full_access` permission set are all refused loudly (the standing + config keeps ruling — fail closed), and off-vocabulary postures are refused, + never coerced, per the `membership_policy` precedent (#5152). Only EXPLICIT + settings values apply: the manifest defaults never mask a deployment's + boot-config declaration. Switching back to `invite_only` always applies — + leftover text in the posture-hidden sibling fields cannot make closing the + wall refusable. +- a58eac3: **Security:** `LocalCryptoProvider` selects its crypto posture from the deployment signal only. A test-runner variable inherited by a spawned server can no longer disarm the production key refusal (#11352). + + `detectMode` read `env.VITEST` as a vote for `'test'` posture: + + ```ts + if (env.VITEST || env.NODE_ENV === 'test') return 'test'; + ``` + + `'test'` is not a softer flavour of `'production'`. It is the branch that takes an ephemeral key, never touches disk, and **never refuses to boot** — and that refusal is the reason the class exists: minting a key at boot makes every previously-written `sys_secret` value (encrypted settings, `secret` fields, datasource credentials) undecryptable after the next restart or on another node, invisibly at encrypt time. So one runner variable decided whether a security gate ran at all. + + Runner variables are **inherited**. Vitest sets `TEST`, `VITEST`, `VITEST_MODE`, `VITEST_WORKER_ID` and `VITEST_POOL_ID` on its worker, and every process that worker spawns with `{ ...process.env }` receives them. Measured on this repo: a real `os serve` spawned that way booted with production auth and **test** crypto. `packages/cli/test/serve-node-env-production-default.e2e.test.ts` — a pin whose entire subject is *"unset `NODE_ENV` means production"* — ran that way for its whole life, and nothing said a word, because a gate that does not run prints nothing. It surfaced only incidentally, while closing the sibling `TEST` leak into better-auth's origin check one layer down. + + **What changes for you.** A process that boots with `NODE_ENV=production`, no `OS_SECRET_KEY`/`OS_DEV_CRYPTO_KEY`, no persisted key file, no `OS_CRYPTO_AUTOKEY` — and a runner variable in its environment — now **refuses to start** instead of running on an ephemeral key. That is the documented fail-loud guarantee arriving where it was previously skipped, not a new restriction: supply the key the refusal names. + + ``` + OS_SECRET_KEY=$(openssl rand -hex 32) + ``` + + **What does not change.** In-process unit tests still get `test` posture — ephemeral key, disk never touched. The `VITEST` read is deleted rather than narrowed because vitest sets both variables on the same worker (`prepareVitest()`: `process.env.VITEST = "true"; process.env.NODE_ENV ??= "test";`, repeated as `NODE_ENV: process.env.NODE_ENV || "test"` in each worker's env). In-process the two spellings are indistinguishable; they differ only for an **inheriting child**, which is precisely the defect. `NODE_ENV` remains the one signal, and a deployment that declares itself a test deployment still gets test posture. + + The whole class is now gated: `pnpm check:runner-env-posture` refuses `TEST`, `VITEST`/`VITEST_*` and `JEST_WORKER_ID` anywhere in product source, so the next author is told at authoring time rather than by an operator whose secrets stopped decrypting. `NODE_ENV` is deliberately not banned — it describes the deployment, and a deployment may declare itself a test deployment; a runner may not declare it on the deployment's behalf. + +### Patch Changes + +- 99ccbb9: Tell the operator in Settings → AI that `@objectstack/service-ai` has no + open-edition version to install, instead of three bare "Mount it" lines + + Configure any real LLM provider in **Settings → AI** and press *Test + connection*, and the built-in fallback handler answered — on all three + real-provider branches — "Mount `@objectstack/service-ai` to exercise live + calls." This platform's own capability roster says that cannot be done: + `PLATFORM_CAPABILITY_PROVIDERS.ai` in `@objectstack/spec/kernel` declares + `edition: 'cloud'`, which `CapabilityEdition` defines as "realized only by a + cloud runtime tier; there is **no installable version in the open edition**" — + the un-followable "add it to your dependencies" that framework#3366 exists to + make legible. The package is in no directory of this repo (0 path hits for + `/service-ai/` on `main`; `/service-settings/` returns 64 and + `/embedder-openai/` 8 under the identical command, so the zero is real). + + The instruction is **kept** — an operator may well have a cloud tier — and + gains the boundary it was missing, so one who does not can see the path is + closed to them. + + Worse than a 404, the install succeeds. Measured 2026-08-23 against the public + npm registry (unauthenticated, `@objectstack/spec` + `@objectstack/cli` as + positive controls, `@objectstack/service-ai-studio` — the sibling + `edition: 'cloud'` entry — as a negative control returning 404): + `@objectstack/service-ai` returns **200** with 57 versions, and the highest is + **10.3.0** (2026-06-23) — entirely below the 11.3.0 cut the roster note names, + i.e. the pre-cloud tail left behind on the registry. A determined operator + following the old sentence installs a seven-major-old AI runtime that + exact-pins `@objectstack/spec@10.3.0` against this repo's 17.2.0, resolving a + second spec beside this one. The new message says so, so nobody discovers it + from a dependency error. + + The boundary sentence is **read from the roster**, not hand-written a fourth + time: `note` is documented as "surfaced verbatim inside the preflight / boot + error so the message carries its own context", and `packages/cli`'s capability + preflight already interpolates it the same way. The three provider prefixes + stay distinct — only the shared trailing sentence converges. + + No behaviour change: `ok` and `severity` are untouched on every branch, and the + embedder hint at the fourth site is deliberately left alone (its package + **is** built here, so that instruction is followable as written) and pinned by + a contrast test. +- e7f56d6: Make the settings engine facade and the metadata database loader bind the row + they resolved, not a row the payload names + + Two ingresses resolved an authoritative row id and then folded it into the + write payload with the **losing** spread order — `{ id, ...data }` — so a + caller-supplied `data.id` spread over the id the ingress had just resolved and + silently retargeted the write: + + - `wrapEngineAsSettingsEngine`'s by-id `update` branch + (`@objectstack/service-settings`), whose id comes from the caller's + `where.id`. + - `DatabaseLoader._update` (`@objectstack/metadata`), whose id arrives as a + separate parameter every caller resolves first (`existing.id`, from the read + immediately above). + + Both now spell it `{ ...data, id }` — the operation's id **after** the spread, + so it wins. That is the convention the repo's other two ingresses already + document: `rest-server.ts`'s batch update arm ("the operation's id AFTER the + spread, so it wins") and `protocol.updateData`'s #6479 fix + (`{ ...request.data, id: request.id }`). + + **No wrong write is known to have been reachable.** Both sites' current callers + build fresh field literals and never put an `id` inside `data`, so this is + hardening a fragile pattern rather than repairing a measured defect. What makes + it worth the three characters is that neither site can be caught downstream: + both pass **no `where`** to the engine, so the payload is the only id the engine + ever sees, and the engine's conflicting-id refusal (`UPDATE_ID_MISMATCH`, 400) + needs two disagreeing declarations before it can fire. The fold is the entire + trust boundary at both sites, and it is one refactor — a caller handing back a + row copy, and rows carry `id` — from the #6479 shape. + + Both are pinned with a payload whose `id` names a **different** row than the + one the ingress resolved, asserting the resolved row is still the row bound. A + pin exercising a payload without an `id` would have passed against both + spellings. The doubles answer "which row does this bind?" with the producer's + own `assertEngineUpdateDispatch`, so they cannot be kinder about it than a + running server. +- 66bbb4c: Tell the operator where `@objectstack/knowledge-turso` comes from instead of + naming a package this repo does not build + + An operator who selects the `turso` knowledge adapter in **Settings → AI & + Embedder** and runs the connection test was told: "Mount + `@objectstack/knowledge-turso` to exercise live calls." That package is in no + directory of this repo (0 path hits on `main`; `knowledge-memory` and + `knowledge-ragflow` return 8 each under the identical command, so the zero is + real), and the message said nothing about where it does come from — leaving the + instruction un-followable at exactly the moment it is read. + + The prior question the card turned on — *is it published anywhere?* — is now + measured rather than assumed. Against the public npm registry on 2026-08-23, + with `@objectstack/spec` and `@objectstack/cli` as positive controls and + `@objectstack/security-enterprise` as a known-private negative control: + `@objectstack/knowledge-turso` **is published**, `latest` 6.9.0 (2026-05-27), + nine versions from 6.4.0. So the option stays — dropping it would have deleted a + working adapter. + + What it is *not* is co-installable with this platform version: 6.9.0 exact-pins + `@objectstack/spec@6.9.0` while this repo ships 17.2.0, so mounting it resolves a + second spec rather than reusing this one. The runtime message now names the + package, says this platform does not ship it, points at the ObjectStack Cloud + monorepo where it is built, and tells the operator to check for a release + matching their platform version — the framework#3366 discipline that an install + hint must carry its own edition/version boundary. + + The manifest's adapter-list comment loses the undated "mirrors the plugin + packages currently published" claim that stopped being true with nothing to + catch it, and gains the measurement with its date and method so the next reader + can re-run it. No behaviour change: `ok` and `severity` are untouched on every + branch of the test action. +- fc8627e: docs(service-settings): state `getMany`'s all-or-nothing key validation on the declaration that owns it (#11680) + + Documentation and a pin. **No behaviour change** — the accept set and every + resolved value are byte-identical. + + `SettingsService.getMany` validates **every** requested key against the + namespace manifest before it reads a single env override and before it loads a + single row, so one undeclared key rejects the whole call with + `UnknownKeyError` (`code: 'SETTINGS_UNKNOWN_KEY'`) and the caller receives + nothing — not the subset it was entitled to. N per-key `get()` calls behave + differently on exactly that input: each declared key still answers, and only + the undeclared one throws. + + The doc comment was otherwise detailed — it explained the grouped row load and + the env-override ordering, and claimed row-for-row equivalence with per-key + `get` "BY CONSTRUCTION" — but never drew this line. That equivalence claim + holds for every key that *resolves* and not for the refusal, so a batched + consumer had to rediscover the rule from a test. `resolveLocalizationContext` + was the first to inherit it and had to record the consequence locally: a host + registering a **partial** `localization` manifest loses all its keys at once + and drops to a shorter cascade, where the per-key path would still have + resolved the declared ones. + + `getMany`'s doc comment now states the rule, its blast radius, why validating + ahead of the grouped walk makes the refusal independent of key order and scope + grouping, and what a caller on a partial manifest should expect. + + The pre-existing pin asserted only `rejects.toThrow(/nope/)` — green whatever + the blast radius is. A sibling pin now asserts the property instead: the error + envelope (`code: 'SETTINGS_UNKNOWN_KEY'`), that **zero** rows were loaded + (the refusal is up-front, with the undeclared key last in the request), and + the contrast that per-key `get()` still answers each declared key. +- 050d8d8: `loadRows` user-keyed engine loads now include tenant/global rows (`$or` over `user_id`/upper scopes), mirroring the in-memory branch. Fixes the user→tenant→global read cascade dying at the user level and upper-scope locks never firing on user-scope writes, on engine-bound deployments (#11228). +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index 33383f8b49..a35142b228 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.2.0", + "version": "17.3.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 c6a141918c..7590521083 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,133 @@ # @objectstack/service-sms +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [983edf1] +- Updated dependencies [56d3c7a] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [911da5f] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] + - @objectstack/spec@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index b9fde3e2f0..afaea4ba6d 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.2.0", + "version": "17.3.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 b10e228950..3e0016b3cc 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,191 @@ # @objectstack/service-storage +## 17.3.0 + +### Patch Changes + +- c3c72a4: Record file-field hydration now answers the same question about a `sys_file` tombstone that the download path answers (#11427). `#10246` stopped `GET /api/v1/storage/files/:id` treating a tombstone (`status: 'deleted'` + `deleted_at`) as the last word — it asks the reap guard's own `findFileHolder` and serves the row for as long as something still holds it — but the record read kept the older `status === 'committed'` rule. One `sys_file` row therefore answered `200` at the download endpoint and a bare id inside a record payload, which UI and export render as "this record has no attachment". + + The population is narrow and unchanged in every other respect: `claimFile` already un-tombstones a field file synchronously when a record re-points at it, and attachments-scope files are never reached by field hydration, so what this closes is the residual the reap guard's sweep-time re-verification names — hook races, direct-driver writes, and future trash restore. A tombstone nothing holds still hydrates as a bare id, and a `pending` upload is untouched. + + The predicate is not re-derived in the engine. `ObjectQL` gains `registerHeldFileResolver` (type `HeldFileResolver`), which the storage plugin fills with `findHeldFiles` — the batched form of `findFileHolder`, asking the same union of `sys_attachment` join rows and the `ref_*` ownership columns. Batched because hydration runs over many rows per read: a read with no tombstone costs nothing, and the residual case costs one extra query for the whole read rather than one per file. Engines with no storage plugin keep tombstones un-hydrated exactly as before. +- a41069b: **Fix:** a tombstoned `sys_file` that something still holds is downloadable again — no 30-day 404 in between (#10246). + + Re-pointing a `sys_attachment` join row onto a file inside its 30-day grace-window tombstone has always been byte-safe: the reap guard re-verifies references at sweep time, finds the new holder, un-tombstones the row and vetoes the reap. But the sweep is the only thing that ever asked, and `sys_file`'s declared lifecycle (`ttl { field: 'deleted_at', expireAfter: '30d' }`) nominates a tombstone only **after** the window expires — measured candidates inside the window: `[]`. So the file simply sat at `status='deleted'` while `GET /api/v1/storage/files/:fileId` and `/files/:fileId/url` refused anything not `committed`. A live attachment could point at a file that 404s for up to 30 days and then silently starts working. + + **What changed:** the two download endpoints stop treating the tombstone as the last word. They now ask the reap guard's own `findFileHolder` — the single definition of "is anything still holding this file?", a union over `sys_attachment` join rows and the `ref_*` ownership columns — and serve the file for exactly as long as that answers yes. + + **What did not change**, deliberately: + + - **No lifecycle verb was added.** There is no un-tombstone, revive or resurrect on the read path; the download writes nothing to the row. Revival remains solely the sweep guard's, which is why the fix is a read-side predicate and not a second revival mechanism (the duplicate-mechanism hazard #10241 avoided). The tombstone stays, and the sweep still reaps when the last holder goes. + - **`pending` is still refused.** Only the `deleted` limb widened; an upload that was never completed has no bytes to promise. + - **Authorization is untouched.** A served tombstone goes through the same `authorizeFileRead` gate as any other file — `AUTH_REQUIRED` (401) and `ATTACHMENT_DOWNLOAD_DENIED` / `FILE_DOWNLOAD_DENIED` (403) are unaffected. Servability is not authorization. + - **Bare kernels are unaffected.** With no data engine there is no holder question to ask, so tombstones stay refused exactly as before. + + The read side and the sweep now answer the same question from the same code, so a file the download path serves is by construction a file the next sweep would veto rather than reap — and the instant the last holder goes, both flip together. That pair is what the new tests pin; the 404 text on the refusal changed from "File not found or not committed" to "File not found or not downloadable" to match (the `FILE_NOT_FOUND` code is unchanged). +- 96940b7: feat(storage): report-only inventory for stranded `sys_file` orphans, plus `os storage orphans` (#10950) + + The tombstone repairs in #10171 (update verb) and #10240 (delete verb) are forward-only: + they changed what the next write does and touched no row already written. Every + attachments-scope file orphaned before them still sits at `status='committed'` with + `deleted_at` NULL and no `sys_attachment` join row — and `sys_file`'s declared lifecycle + nominates a sweep candidate only via `ttl { field: 'deleted_at' }` or + `retention { onlyWhen: { status: 'pending' } }`, so such a row matches **neither**. It is + never a candidate, the reap guard is never asked about it, and its bytes are never + reclaimed. The leak is permanent rather than late. + + This ships the measurement half only, per the maintainer's ruling on #10950: + + - `inventoryStrandedFileOrphans()` — a read-only reconciliation pass that walks + attachments-scope committed `sys_file` rows and reports how many are stranded, their + byte magnitude, and why each excluded row was excluded. `formatStrandedOrphanInventory()` + renders it; those two plus the inventory's result types are what + `@objectstack/service-storage` publishes — the shared ownership predicate stays internal, + since nothing outside the package pulls on it. + - `os storage orphans` — the operator-invoked surface, with `--json` for a machine-readable + payload. There is no `--apply` and no write path, deliberately. + + **It writes nothing, tombstones nothing and deletes nothing.** Authorising the destructive + backfill is a separate decision that these numbers exist to inform; a tombstone written + here would start a 30-day clock ending in an irreversible byte delete. + + The ownership question is not reimplemented. `createSysFileReapGuard`'s "is anything still + holding this file?" test — zero `sys_attachment` join rows **and** empty `ref_*` ownership + columns — is extracted as `findFileHolder()` and called by both the guard and the + inventory, so "the same question, never a weaker one" is a property of the code rather + than a claim in a comment. A file with zero join rows that is `ref_*`-owned (ADR-0104 / + #3459) is a live file and is excluded from the count. + + Behaviour of the reap guard is unchanged — the extraction is a pure refactor, and the + guard's existing pins cover it. Both counts are labelled `attachments` scope: files in the + other scopes are governed by the field-reference seam and are reconciled by + `verifyFileReferences`, which skips attachments-scope files, so the two passes partition + the population rather than overlapping. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [9735662] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/observability@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index b3ad456897..4311fab1fb 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.2.0", + "version": "17.3.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 b5a6157e76..92972da8cf 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,2887 @@ # @objectstack/spec +## 17.3.0 + +### Minor Changes + +- 387e231: feat(spec,runtime): refuse the doubled post-success navigation channel on a `type: 'script'` action (#11519) + + **BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under + the repo's launch-window convention for breaking changes. + + Two independent channels could name a post-success destination for one + `type: 'script'` action: the declared `onSuccess` block (`{ navigate, openIn }`, + validated and visible in metadata) and the handler-returned `{ redirectUrl }` + convention (runtime-only). The spec ruled each surface's default in isolation + and said nothing about an action carrying both — so the renderer had to pick, + and the pick lived only in one renderer's implementation (declared `onSuccess` + wins, objectstack-ai/objectui#5933). Maintainer ruling 2026-08-24: refuse the + doubled channel; ⛔ no `precedence` contract field. + + The measured static-knowability partition: + + - **Authoring-time refine (spec):** "the handler can return `redirectUrl`" is + runtime-only in general (`target` names an opaque registry entry; + `HookBodySchema` declares no return contract) — but `opensInNewTab: true` is + a schema-visible declaration of the handler-redirect channel (its contract is + "pre-open a tab, then drive it to the handler's returned `redirectUrl`"). + A `type: 'script'` action declaring `onSuccess` beside `opensInNewTab: true` + is now **rejected at parse time**, with guidance naming both channels and the + remedy. Previously the pair parsed clean and one declaration was silently + dead at render. + - **Dispatch-seam diagnostic (runtime):** the runtime-only remainder — a + handler that actually returns `{ redirectUrl }` while the action declares + `onSuccess` — now logs a loud `[action-contract]` warning at both dispatch + surfaces (the REST `/actions` route and the MCP `run_action` bridge), naming + the action, both channels, the interim winner and the remedy. Observe-only: + the wire is untouched and the interim renderer precedence stands until the + author takes the remedy. + + Single-channel declarations are untouched and pinned byte-identically: only + `onSuccess`, only `opensInNewTab` (with or without `newTabUrl`), and + `opensInNewTab: false` beside `onSuccess` all parse exactly as before. The + corpus was measured at zero doubled producers (this repo's examples and + platform metadata, objectui metadata, and the cloud SSO handoff producers per + the #11519 measurement), so no shipped metadata is affected. + + **Migration.** An action refused by the new refine must pick its one + destination: keep `onSuccess` and drop `opensInNewTab` (and stop returning + `redirectUrl` from the handler), or keep `opensInNewTab` + the handler + redirect and drop `onSuccess`. Which channel is right is an authoring decision + the metadata cannot make for you, and zero such actions exist in any measured + corpus. + + +- 2d4fa75: feat(spec): enforce the documented `newTabUrl` / `opensInNewTab` co-constraint on `ActionSchema` (#11842) + + **BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under + the repo's launch-window convention for breaking changes. + + `newTabUrl`'s doc has always said "Only valid together with `opensInNewTab`", + and every renderer read point agrees: objectui's pre-opened-tab wrapper reads + the key only behind `action.opensInNewTab && newTabUrl`, and no other path + reads it at all. Nothing on the refine chain enforced the pairing, so an + action declaring `newTabUrl` without `opensInNewTab: true` parsed clean and + the key was silently inert — the ADR-0078 declared-but-unenforced shape, + arriving through a documented co-constraint rather than a missing key. + + `ActionSchema` now **rejects at parse time** an action declaring `newTabUrl` + whose `opensInNewTab` is not `true`, with guidance naming the pre-opened-tab + contract and both remedies (declare the flag if a pre-opened tab is intended; + otherwise delete the inert key — behavior is unchanged either way it was + already behaving, because the lone key was never read). An explicit + `opensInNewTab: false` beside `newTabUrl` is refused too, deliberately: + unlike the #11519 doubled-channel rule, `newTabUrl` has no meaning outside + the pre-opened-tab flow, so a declared-off channel leaves the key exactly as + dead as an undeclared one. + + The legal pairing is untouched and pinned byte-identically: `opensInNewTab: + true` + `newTabUrl`, `opensInNewTab` alone, and `opensInNewTab: false` alone + all parse exactly as before. The corpus was measured at zero lone-`newTabUrl` + producers (this repo's examples and platform metadata, objectui's fixtures + and renderer read points, and the cloud SSO producers, which declare the pair + correctly — re-measured at claim per the triage requirement), so no shipped + metadata is affected. + + +- 0e4e51b: feat(spec): `ActionParamSchema.carryOver` — the declared carry-over param: seeded from the row, rendered as a non-editable summary, submitted verbatim (#11753 ruling, spec half; #11992) + + + + The maintainer's 2026-08-25 ruling on #11753 (recommendation A) declares ONE + carry-over contract instead of a rendering convention: a param may state, in + metadata, that its value is carried through the action dialog rather than + collected from the user. + + - `carryOver: true` — seed from the current row (`defaultFromRow: true` is + required alongside, enforced at parse time), render as a NON-EDITABLE + summary, submit VERBATIM. Unlike `visible: false` — the measured non-answer, + which omits the param from the submission entirely — a carry-over param is + always sent. + - Aliases: `readonly` / `disabled` are refused with guidance naming + `carryOver` (a field's `readonly` means write-path strip, which is exactly + the wrong half here). + - Exemplar (`@objectstack/plugin-security`): the five `clone_permission_set` + JSON facet params (`object_permissions`, `field_permissions`, + `system_permissions`, `row_level_security`, `tab_permissions`) declare it, + so the sanctioned clone path stops offering five prefilled raw-JSON + textareas an admin could hand-mangle into a clone that grants MORE than its + base. `description` stays an ordinary editable param. The send-side contract + is unchanged (#11703 pin 6 stays green). + + The objectui renderer leg (honouring the declaration in `ActionParamDialog`) + is the downstream card tracked on #11753. +- 40a93b5: feat(spec): retire the authorable `AdvancedPluginLifecycleConfig` surface; the lifecycle classes stay as a host-driven library (#11825, ADR-0049) + + + + **BREAKING** export removal, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry + `advanced-plugin-lifecycle-config-retired` — where `os migrate meta` users + will look). + + `AdvancedPluginLifecycleConfigSchema` aggregated six lifecycle config groups — + `health`, `hotReload`, `degradation`, `updates`, `resources`, + `observability` — and NO group had a runtime reader, re-measured per group at + the retirement's base commit with positive controls: the kernel never + constructs `PluginHealthMonitor` or `HotReloadManager` (only their own unit + tests and `core/examples/phase2-integration.ts` do, passing config DIRECTLY + to the classes, never through this container); the `degradation` / `updates` + / `resources` / `observability` keys have no implementation body at all; and + no manifest, stack collection or metadata-type binding ever embedded the + container, so no authored document could carry it. An author declaring + `health: {...}` or `rollback: { automatic: true }` got a clean parse and + NOTHING — the #3950 shape at container scale, sharpened by production-safety + vocabulary (auto-restart, zero-downtime rolling updates, automatic rollback) + an AI author reads as proof the capability exists. + + FROM → TO: + + - `AdvancedPluginLifecycleConfigSchema` / `AdvancedPluginLifecycleConfig` / + `AdvancedPluginLifecycleConfigParsed` → *(removed — no replacement + container)*. The supported lifecycle surface is the host-driven library in + `@objectstack/core`: construct `PluginHealthMonitor` and pass a + `PluginHealthCheck`, construct `HotReloadManager` and pass a + `HotReloadConfig` (the `content/docs/protocol/kernel/lifecycle.mdx` + examples, #11811, are the supported usage). + - `GracefulDegradationSchema` / `GracefulDegradation` / + `GracefulDegradationParsed` and `PluginUpdateStrategySchema` / + `PluginUpdateStrategy` / `PluginUpdateStrategyParsed` → *(removed)* — value + schemas whose only consumer was the retired container; no implementation + body exists for any of their keys. They return only via the ENFORCE route + of ADR-0049 through a new ADR — the executor first, the vocabulary second. + + One-line fix: delete the config object (it configured nothing); if you drive + the library classes yourself, keep passing `PluginHealthCheck` / + `HotReloadConfig` directly — those vocabularies survive unchanged. + + The retirement kit: + + - whole-def deletion (route 3 — no carrier key, no authored document, so no + tombstone and no D2 conversion; the #8715 `identity/ApiKey` shape): + `kernel/AdvancedPluginLifecycleConfig`, `kernel/GracefulDegradation`, + `kernel/PluginUpdateStrategy` in `RETIRED_DEFS_BY_MAJOR[18]`, plus the D3 + semantic entry `advanced-plugin-lifecycle-config-retired` + - pin test (`kernel/plugin-lifecycle-advanced-retirement.test.ts`): zero + holders for all 9 retired names on every public entry, survivors pinned + (`PluginHealthStatus` / `PluginHealthCheck` / `PluginHealthReport`, + `HotReloadConfig` + `DistributedStateConfig`, `PluginStateSnapshot` — the + kept host-driven library vocabularies) + - zero authored occurrences in objectstack or objectui (measured at + dispatch, re-verified per group), so no in-repo source changes ride along; + `@objectstack/core` classes and tests are untouched and stay green +- 4f24e9d: feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` + + **BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. + + - New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. + - **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. + - `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. + - Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. + - `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. + - The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. + - `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). + + +- 7a25e7d: feat(spec): retire the orphan `CLICommandContributionSchema` export — the manifest surface it described is a tombstone (#12007, ADR-0049) + + + + **BREAKING** export removal, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_DEFS_BY_MAJOR[18]` + (`kernel/CLICommandContribution`) + the D3 semantic entry + `cli-command-contribution-retired` — where `os migrate meta` users will + look). + + `CLICommandContributionSchema` described a "CLI Command Contribution + declaration in the manifest" and claimed to be "retained for backward + compatibility and for describing command metadata in plugin manifests" — but + after #10724 tombstoned `manifest.contributes.commands`, no manifest surface + could legally carry these entries: the exported schema advertised a shape + whose only declared carrier rejects it. The manifest never referenced this + schema even before the tombstone (its inline `commands` item schema was an + independent duplicate), and it had zero consumers outside spec's own test and + generated artifacts, measured at the retirement's base commit with positive + controls in objectstack, objectui (pinned sha) and cloud — the exported + orphan-value-schema class (#3950: an exported schema with no consumer reads + as a capability). + + FROM → TO: + + - `CLICommandContributionSchema` / `CLICommandContribution` → *(removed — no + declarative replacement, because no declarative surface ever carried it)*. + CLI commands are registered through oclif's native plugin discovery: the + plugin package declares an `oclif` section in its own `package.json` — + `OclifPluginConfigSchema` / `OclifPluginConfig` (same module) describe that + live surface and survive unchanged. + + One-line fix: delete the import (nothing ever read the declaration); if you + describe a plugin's CLI commands, declare the `oclif` section in the plugin's + `package.json` — `OclifPluginConfigSchema` validates it. + + The retirement kit: + + - whole-def deletion (route 3 — no carrier key, no authored document, so no + tombstone and no D2 conversion; the #11825 / #8715 shape): + `kernel/CLICommandContribution` in `RETIRED_DEFS_BY_MAJOR[18]`, plus the D3 + semantic entry `cli-command-contribution-retired` + - pin test (`kernel/cli-command-contribution-retirement.test.ts`): zero + holders for both retired names on every public entry, survivors pinned + (`OclifPluginConfigSchema` / `OclifPluginConfig` — the live `package.json` + `oclif` surface) + - the module docblock's Commander.js migration prose is KEPT — it is cited by + the `contributes.commands` tombstone (full-file deletion was explicitly not + the shape) + - zero authored occurrences in objectstack, objectui or cloud (measured at + dispatch, re-verified at claim), so no in-repo source changes ride along +- daae7aa: Declare the `search` and `data.clone` route response contracts, and bind the SDK to them (#11924) + + Two of the four SDK routes #8140 had to leave as deliberate `Promise` holes now have real + contracts. Their shapes were always stable and server-produced — they were declared inline on the + implementation (`@objectstack/metadata-protocol`'s `searchAll` / `cloneData`), reachable from no + spec export — and per the maintainer ruling on #11924 they are now declared in `@objectstack/spec` + exactly as produced, with conformance coverage on both the producer and the mounted route (#3877: + no route-ledger `responseSchema` row is filled without conformance coverage; both rows are filled + as part of this change). + + **`@objectstack/spec` (additive):** + + - `SearchAllResponseSchema` / `SearchAllHitSchema` (+ `SearchAllResponse` / `SearchAllHit` types, + `@objectstack/spec/api`) — the WHOLE body of `GET /api/v1/search`, answered bare: + `{ query, hits, totalObjects, totalHits, truncated }` with hits of + `{ object, id, title, snippet?, record }`. ⚠️ Deliberately distinct from `SearchResult` / + `SearchHit` in `@objectstack/spec/contracts`, which type the per-object `ISearchService.search` + (hits of `score` / `document`) — reaching for that same-named neighbour was the near-miss trap + #8140 left a compile-time guard against, and the guard stands unchanged. + - `CloneDataResponseSchema` (+ `CloneDataResponse`, `@objectstack/spec/api`) — the whole 201 body + of `POST /data/:object/:id/clone`: `{ object, id, sourceId, record }`, `CreateDataResponse`'s + structural sibling plus `sourceId` (`id` is the NEW record's, `sourceId` the copied record's). + No `droppedFields` member — unlike `createData`, the clone producer emits none. + + **`@objectstack/client` (return-type narrowing, same nature as the #8140 batch):** `search` is + now `Promise` and `data.clone` is `clone(…): Promise>` + (a new exported interface mirroring `CloneDataResponseSchema`, beside `CreateDataResult`). Nothing + changes at runtime — no request, response, unwrapping or error path is touched — but code that + compiled against the previous `any` returns (arbitrary property reads, assignments to unrelated + types) can stop compiling; in particular a result assigned to the per-object `SearchResult` is now + refused at compile time, which is the trap the erasure used to hide. + + The `automation.create` / `automation.update` pair is explicitly NOT declared here — it returns to + the decision inbox with a consumer-survey reading per the same ruling. +- 7345308: feat(spec): retire the `element:form` element at element grain (#9249, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + `element:form` never had a renderer or reader anywhere — the #9220 + (`element:filter`) shape one element over, recorded by that card's own verdict + sweep. Measured at retirement (objectstack `dd84ddd796`, objectui `3ece13e33`; + cloud per the origin card's two recorded readings at `5f1bf23f` and `a11458b`, + positive controls passing): objectui registers no renderer for it — its + `renderers/basic/elements.tsx` header deferred the element to "owning plugins" + that never materialized — and Studio's designer palette carries it as a + no-renderer `PALETTE_EXCLUSIONS` entry that names the live replacement ("no + renderer — use the object-bound `object-form` block"). The 2026-06 + page-liveness audit already recorded it rendering "Unknown component type". So + the full form contract `ElementFormPropsSchema` declared — `object`, `fields`, + `mode`, `submitLabel`, `onSubmit` (CEL), `aria` — was a capability claim + nothing kept. + + **What is refused:** every authored `element:form` key. All six are + `retiredKey()` tombstones, so authoring one is a `tsc` error and a parse error + carrying the prescription (fully-qualified key, why the element is dead, the + replacement, the `os migrate meta` pointer), dispatched through the KEPT + `ComponentPropsMap` row — deleting the row would demote the type to an + unregistered custom string the #5068 props gate deliberately skips. + + **What stays accepted:** a bare `element:form` node with empty `properties` + (the migrated shape — the open `type` union accepts any string, and deleting + authored page nodes is a layout decision a mechanical conversion must not + make). It renders nothing, exactly as it always did. + + The retirement kit: + + - `retiredKey()` tombstones for all six keys at the schema + (`packages/spec/src/ui/component.zod.ts`); the `PageComponentType` enum + drops the value (de-advertisement — the open string arm still accepts it) + - ADR-0087 registration: six retired-key entries + `ui/ElementFormProps:{aria,fields,mode,object,onSubmit,submitLabel}` and the + D2 conversion `element-form-removed` (protocol 18), wired into the step-18 + chain — `os migrate meta --from 17` strips all six keys from authored + `element:form` blocks (pure lossless deletes; none ever had an effect to + lose) and leaves the bare node + - pin tests (`component.test.ts` — refusal pins assert the prescription per + key; a positive pin parses the bare migrated node clean and asserts nothing + materializes; the kept-map-row pin flips from parse to refusal) + - `packages/lint`'s `COMPONENT_FIELD_SPECS` entry drops (its own changeset); + the component-translation `submitLabel` describes stop naming `element:form` + as carrier (the orphaned-key decision is #10926, out of this card's scope) + - generated baselines/docs follow the schema (authorable surface/defaults, + spec-changes, upgrade guide, reference docs) + - objectui's `elements.tsx` comment and Studio palette-exclusion lines are + cross-repo and already queued under objectui#4935 + + ## FROM → TO + + ```ts + // before — parsed green; nothing anywhere rendered it, so the page showed + // "Unknown component type" where the author expected a form + { + type: 'element:form', + properties: { + object: 'lead', + fields: ['name', 'email'], + mode: 'create', + submitLabel: 'Create Lead', + }, + } + + // after — use the object-bound `object-form` block (#7751): rendered, + // designer-publishable, same intent (`objectName`, `fields`, `mode`, + // `submitText`) + { + type: 'object-form', + properties: { + objectName: 'lead', + fields: ['name', 'email'], + mode: 'create', + submitText: 'Create Lead', + }, + } + ``` +- 30d96ab: The engine-registration road into datasource introspection now meets the compiler (#11493, extending the #11123 ruling from the `DatasourceDriverHandle` seam): `IDataDriver` gains an optional `introspectSchema?(): Promise` member, and `IDataEngine` gains an optional `introspectDatasource?(datasource: string): Promise` member. Both are typed with the spec's one introspection shape (`IntrospectedSchema`, `@objectstack/spec/contracts`). Drivers and engines without introspection stay conformant — the members are optional — while a driver that DOES implement `introspectSchema` with a mis-shaped result (a column flag spelled `isPrimary`, a bare `{ tables }` with no `dialect`/`introspectedAt`) now fails compile at the offending field instead of surfacing at runtime as a federated table whose records cannot be located. +- f658793: Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须 + schema-free」) for `@objectstack/core`'s plural→singular store-key fold. + + `@objectstack/spec`: the `defineStack()` manifest-collection vocabulary + (`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`, + `singularToPlural`) moved to a schema-free module and is now ALSO exported + from the sanctioned schema-free entry `@objectstack/spec/meta-spelling` + (widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared` + keeps the same four symbols as re-exports — no consumer-visible removal. The + manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts + (#8424). + + `@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves + from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so + browser consumers of `@objectstack/core` (every `@objectstack/client` bundle) + no longer link the zod schema closure through the store-key fold. +- 5997207: feat(spec): materialize `deleteBehavior` only on reference field types (#9784) + + `FieldSchema` no longer materializes the `deleteBehavior: 'set_null'` default + onto non-reference field types (`text` / `datetime` / `number` / every other + non-relational type, `user` included). The key has no meaning there — the + engine's `cascadeDeleteRelations` reads it exclusively on `master_detail` / + `lookup` fields carrying a `reference` — yet the materialized default shipped + in every built app artifact, where a parse-time default becomes an apparent + explicit declaration downstream (the #4447 shadowing mechanism) and reads as + meaningful to AI authors browsing the artifact. + + What changes and what does not: + + - **Bare non-reference fields** parse to output that **omits** `deleteBehavior` + (previously: `deleteBehavior: 'set_null'` materialized on every type). Built + artifacts thin accordingly — measured on the showcase app: 210 fields, the + key drops from 206 fields to 16. + - **`lookup` and `tree`** keep materializing `set_null` byte-identically, at + shape position. + - **`master_detail`** keeps omitting it (the #9689 idempotent-materialization + ruling, unchanged). + - **The accept-set is untouched**: an authored `deleteBehavior` on any field + type parses exactly as before and round-trips verbatim, so artifacts built + by earlier versions (which carry the materialized key on every field) remain + fully legal inputs. `parse(parse(x))` holds across the boundary. + + No authored metadata needs any change: no key is removed, renamed or + re-shaped, and no authoring spelling that parsed before is refused now. +- f90e820: feat(spec): register `FLOW_INPUT_SCHEMA_INVALID` — the definition-level input-schema refusal becomes a never-dispatched exit with its own ADR-0112 code (#11504, the contract half of the #10025 ruling) + + `AutomationResult.code` gains `'FLOW_INPUT_SCHEMA_INVALID'`, and the code is + registered in the ADR-0112 error-code ledger under `@objectstack/runtime` + beside `FLOW_DISABLED` / `FLOW_NO_START_NODE`. Semantics: a node's static + `config` violates the `inputSchema` its own flow definition declares, so the + engine refuses to dispatch — nothing runs, nothing is written, the result + carries the code and NO `status` (the #9378 never-dispatched class), and a + transport maps it to **422** (unexecutable stored definition, exactly as + `FLOW_NO_START_NODE`). + + Ruled by #10025 (maintainer, 2026-08-20): the refusal is **non-retryable** — + the guard's verdict is a pure function of the flow definition, so re-running + it cannot change the answer. This release ships only the contract vocabulary; + the engine behaviour change is #10025's services half and lands separately. + + **Operator-visible consequence once that services half lands, stated + plainly:** retry accounting and run-log volume change for affected flows. A + `strategy: 'retry'` flow whose node `config` violates its declared + `inputSchema` today burns its whole retry budget (including configured + backoff delays) and writes `1 + maxRetries` identical failed run-log rows; + after the services half it refuses **once**, producing **one** run-log row + carrying `code: 'FLOW_INPUT_SCHEMA_INVALID'` and no `status`. Anything + watching retry counters or paging run history for this exit sees different + numbers for the same flow. +- e8bd715: **i18n:** the screen-flow copy vocabulary gains its resolver family — `translateFlow`, `resolveFlowScreenTitle`, and the `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS` shared key lists (#7646 recommendation B, the spec half of #11287). + + #7763 declared `TranslationData.flows` (`flows..label`, `.screens..title`, `.screens..fields..{label,placeholder}`) and deliberately left the resolver half unwritten because another change was in flight on `i18n-resolver.ts`. This lands that half in `packages/spec/src/system/i18n-resolver.ts`, mirroring the page family's conventions: + + - `translateFlow(flow, bundle, opts)` — the metadata-document overlay: translates the flow's own `label` and, for every `type: 'screen'` node with an id, the screen heading (`config.title` — written even when the author relied on the node-label fallback, since the executor builds the wire title as `config.title ?? node.label`) and per-field `label` / `placeholder`, key by key across the locale chain with the authored source strings as fallback. Input not mutated; off-spec bundle keys the schema refuses (`description`, `help`) are ignored, never overlaid. + - `resolveFlowScreenTitle(bundle, flowName, screen, opts)` — the piecemeal half for a caller already holding a `ScreenSpec` (`nodeId` addressing, `title` literal fallback). + - `FLOW_SCREEN_COPY_KEYS` (`['title']`) and `FLOW_SCREEN_FIELD_COPY_KEYS` (`['label', 'placeholder']`) — the one list the resolver overlay and the CLI's skeleton extractor (downstream card) both import, pinned against `TranslationDataSchema` so neither can drift. + - Supporting shapes: `FlowLike`, `FlowNodeLike`, `FlowScreenLike`, `FlowScreenFieldLike`, `FlowScreenCopyKey`, `FlowScreenFieldCopyKey`. + + Additive surface widening only — no existing shape changes meaning, and `translateFlow` is deliberately **not** registered in `translateMetadataDocument`'s dispatch table: that registration reaches the REST metadata boundary by itself (`TRANSLATABLE_METADATA_TYPES` drives `@objectstack/rest`), which would stand up a shipped reader of the `flows` group while its liveness ledger rows are `planned`. The runner application (server or client side), the CLI coverage bucket, and the ledger flip to `live` all ride the downstream cards of #11287. +- e914733: `FormFieldSchema`'s constraint keys converge on the value shapes the object-field surface landed (#12174): `maxLength`/`minLength` tighten from bare `z.number()` to `z.number().int().min(1)` (the #11566/#11949 template — a character length is a positive integer, and `minLength: 0` is a vacuous always-true declaration refused loudly; "no minimum" is expressed by omitting the key), and `precision`/`scale` tighten to `z.number().int().min(0)` (the #8321 template — digit counts). `min`/`max` are unchanged: they are values, not counts, exactly as on the object-field surface. + + The row keys are live, measured in objectui: the spec bridge (`form-view.ts` mapField, objectui#5898) and plugin-form (`sectionFields.ts`) copy all six onto the runtime field, the console FormPage merges `override.maxLength ?? def.maxLength` onto the rendered input (objectui#5595), the fields package builds validation rules from `minLength`/`maxLength`/`min`/`max`, and `precision`/`scale` drive the number widgets — so `maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and `GET /forms/:slug` serves the rows verbatim to anonymous renderers. What newly gets rejected: `0` (length pair only), negative and non-integer values on the four count-shaped keys. Unlike the object-field twins there is no type-conditional applicability gate: a form row references its object field by name and usually omits `type`, so the referenced field's type is invisible at parse time — value shape is checkable here, key placement stays the object field's own schema's job. Already-legal declarations round-trip byte-identically, and absence stays absence — no default materializes. + + +- 4635f3e: fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049) + + + + **BREAKING** accept-set narrowing + export removal, landing after the v17.0.0 + cut (the lockstep launch-window convention ships it as `minor`; the + prescription is registered under protocol major 18 — + `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry + `hot-reload-inert-state-strategies-retired` — where `os migrate meta` users + will look). + + This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825 + ruling deliberately kept. That ruling retired the authorable lifecycle-config + container and kept `HotReloadConfigSchema` as a host-driven library parameter + type; this change measures the kept vocabulary's own remainder and finds the + same defect in it. The keep itself stands — `HotReloadConfigSchema`, + `PluginStateSnapshotSchema` and the health vocabularies still export, and + `HotReloadManager` / `PluginHealthMonitor` are untouched. + + The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both + wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said + "memory fallback" — and announced the substitution at DEBUG level only. A host + that asked for durable or cluster-replicated state got process-local memory + and no error: state that does not survive the restart it was configured to + survive. `distributedConfig` had ZERO readers anywhere, so an author could + name a Redis endpoint, a TTL and a replication factor and nothing ever opened + a connection. + + FROM → TO: + + - `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime + behaviour, because `'disk'` already stored to memory. It is the spelling + that was false, not the behaviour. + - `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or + `'none'` to disable state preservation outright. + - `distributedConfig: { … }` → *(removed)* — delete the key. It left with the + `'distributed'` value its own doc comment called it "required" for. + - `DistributedStateConfigSchema` / `DistributedStateConfig` / + `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of + that one key. + + One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete + any `distributedConfig` — you were already getting in-memory state. There is + no in-tree replacement for durable or distributed plugin state; persist it in + the host, which owns the process lifetime these strategies pretended to + outlive. Real disk or distributed persistence returns only via the ENFORCE + route of ADR-0049 — the implementation first, the declaration with it. + + The retirement kit: + + - **enum-value narrowing** (`['memory','disk','distributed','none']` → + `['memory','none']`): invisible to all four ratchets by construction (the + def still emits), so the prescription hangs on the enum's own `error` map + dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'` + precedent. A value that was never legal still gets zod's own enum message, + so a typo is not told it "was removed". + - **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable + surface: no metadata-type binding, stack collection or manifest embed ever + carried it, and nothing in the tree parses `HotReloadConfigSchema` outside + its own unit test, so there is no authored document to rewrite and nobody + who could receive a parse-time tombstone): `kernel/DistributedStateConfig` + in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as + a def removal must — `api-surface` −3, `authorable-surface` −8, + `json-schema.manifest` −1. + - **runtime doors** in `@objectstack/core`, because route 3 leaves no + parse-time prescription: `HotReloadManager.registerPlugin` now refuses an + unhonoured `stateStrategy` and a leftover `distributedConfig` with an + ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the + prescription. Refused BEFORE the `enabled` check, so a disabled config + cannot smuggle the false declaration through. TypeScript hosts never reach + it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a + compile error at the call site. + - **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the + #11825 survivor list, so this reverses one line of that ruling on new + evidence — #11825 measured the container's six groups, never this key's own + readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts` + moves in the same commit with the reasoning recorded beside it, and asserts + the surrounding keep is intact. + - zero in-tree consumers passed `'disk'` or `'distributed'` (measured at + cdbd9204b6 with a firing positive control; every live caller passes + `'memory'` or `'none'`), so no in-repo source changes ride along. +- ee3595c: fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescription is registered + under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry + `hot-reload-watch-placeholder-retired` — where `os migrate meta` users will + look). Graded `minor` rather than `major` for the same reason #12340 was one + day earlier, in this same module. + + ADR-0049 applied one symbol over from #12340, in the same file and on the same + per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and + `PluginStateSnapshotSchema` still export, and `HotReloadManager` / + `PluginHealthMonitor` are untouched apart from the two doors below. + + `HotReloadManager.startWatching` contained **no watcher**. Its whole body was a + guard plus `logger.info('File watching started', { patterns })`, above an + in-source note saying real watching "would require chokidar or similar". Where + #12340's inert fallback at least announced itself at DEBUG, this claimed + success at **INFO**: an operator who set `enabled: true` with `watchPatterns` + and read that line had been told the opposite of the truth. `watchHandles` was + only ever read, deleted, iterated and cleared and **never set**, so + `stopWatching`'s cleanup branch and the teardown loop over its keys were + structurally unreachable rather than merely untaken. `watchPatterns` therefore + had no reader that acted on it — its only two uses were log lines. + + FROM → TO: + + - `watchPatterns: ['src/**/*.ts']` → *(removed)* — delete the key. Declare your + globs wherever your own watcher reads them. + - `manager.startWatching(name)` → `manager.scheduleReload(name, reloadFn)`, + called from your own watcher's change handler. That is the debounced + integration point this class does implement, and it is unchanged. + + One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own + file watcher instead of `startWatching` — nothing was ever watched, so nothing + that used to happen stops happening. File watching is the host's job in this + host-driven library; `chokidar` is already a dependency of + `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` — + never of `@objectstack/core` — so a host has a working model to copy. + + The retirement kit: + + - **key tombstone**, and the build is what chose it: the plain deletion was + tried first and `gen:schema` gate (a) refused it, because + `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a + silent strip (#3733, ADR-0104) — the very defect being retired, one layer + down. #12340 could take route 3 because what left there was a whole *def*; a + key leaving a *surviving* def has no such exit. So `watchPatterns` is + `retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and + `kernel/HotReloadConfig:watchPatterns` is registered by exact key in + `RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves + `authorable-surface` only — the def still emits, so `api-surface` and + `json-schema.manifest` do not. + - **no D2 conversion**, deliberately: the chain walks a normalized stack, and + `HotReloadConfig` is not an authorable surface — no metadata-type binding, + stack collection or manifest embed ever carried it — so a conversion would be + a transform with no seam that ever runs. For the same reason the prescription + carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling + in this module does not. + - **runtime doors** in `@objectstack/core`, because nothing in the tree parses + `HotReloadConfigSchema` outside its own unit test, so the tombstone alone + reaches nobody: `startWatching` now throws an ADR-0112 envelope + (`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and + `registerPlugin` refuses a leftover `watchPatterns` the same way — before the + `enabled` check, so a disabled config cannot smuggle the false declaration + through. `startWatching` is kept as a throwing door rather than deleted so + that caller meets a prescription instead of a bare `TypeError`. + - **dead code removed with a firing positive control**: `watchHandles` and both + of its unreachable readers are gone. The zero was pinned first — + `reloadTimers.set` resolves a real writer in the same file and the same scan, + while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the + half that always did something (it cancels a pending debounced reload), and + `shutdown` is unchanged in effect: the loop it lost iterated `watchHandles` + and therefore ran zero times. + - **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a + removal: no runtime composes `HotReloadManager`, so enforcing would build for + a caller that does not exist; and a scan of every planning doc returned zero + mentions of hot-reload file watching against 145 control hits in the same + files, so there is no roadmap for `experimental` to point at. +- d2619fd: `ApiEndpoint.target` is now **optional** in the vocabulary (#10338, maintainer ruling + 2026-08-23). The key was required on every endpoint but read only for `type: 'flow'` + (executor, OpenAPI enrichment and the publish gate all address an `object_operation` + via `objectParams.object` / `.operation`) — so an `object_operation` author was forced + to write a dead string nothing consumed or cross-checked. Authoring guidance: omit + `target` on `object_operation` endpoints. The publish gate still **requires** `target` + for `type: 'flow'` — a flow endpoint that names no target flow is refused at publish, + and the runtime's structural backstop answers `501 NOT_IMPLEMENTED` for one that + reached the store another way. No migration: this is a pure widening — every previously + valid declaration (all of which carry a string `target`) still parses unchanged. +- e5ce2ed: Packaged actions can be switched off, on the same activation ledger as flows + + A packaged action can now be disabled for an installation, generalizing the + packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on + the maintainer's amendment ruling 3). The flip writes an install-level row to + the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — + no new table, no new column, no schema change of any kind. Absence of a row + means the packaged default, active, so a deployment that never flips anything + behaves exactly as before, and an empty ledger changes nothing anywhere. + + The consult point is action DISPATCH, and it is present on every door that + dispatches a declared action: the REST `POST /actions/:object/:action` route and + the MCP `run_action` bridge. Both call one shared guard, and a disabled action + is refused `409 ACTION_DISABLED` before anything runs — before the handler body + (which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action + reaches the automation engine, before the param contract is enforced and before + the subject record is read. The refusal names the ledger and the remedies. The + code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and + answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, + which would tell an operator to go looking for a flow that does not exist. + + The consult reads a projection the ObjectQL engine holds and hydrates at boot, + so a disabled action stays disabled across a restart and across the handler + re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — + the ledger records the customer's choice, and nothing re-arms it silently). + + The write door is `POST /actions/_activation/:object/:action` with a + `{ enabled?: boolean }` body. Its first segment is reserved rather than deep in + the path because a machine name can never begin with `_`, so it cannot collide + with an object, an action or a record id. It carries the same two authority + tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture + rule — in the `group` and `isolated` postures the install-wide switch requires + the platform operator, while `single`, where install-level and org-level are the + same scope, is unchanged. That gate is now one implementation shared with + `POST /automation/:name/toggle`; the flow refusal text is unchanged. + + Two refusals are worth knowing about. The ledger addresses an action by its + machine name, so a name declared on more than one object is refused with + `409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off + silently. And a flip that cannot be made durable — no ledger table reachable — + is answered as a failure instead of a 200, because a switch reported as durable + that reverts on the next restart is the failure this whole family exists to + remove. + + Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so + disable is the only primitive here and authoring a new sibling action stays + exactly as it is today. +- be21955: feat(spec): retire the nine dead members of the plugin-manifest `contributes` block — `events` / `menus` / `themes` / `translations` / `actions` / `drivers` / `fieldTypes` / `functions` / `commands` (#10724, ADR-0049 enforce-or-remove) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + The census behind it (#10627, controlled and re-verified at claim time across + objectstack, objectui and cloud) measured that the ENTIRE monorepo contains + exactly one non-test read of `manifest.contributes`, and it reads `kinds` + (`packages/objectql/src/engine.ts` → `registry.registerKind`). The other nine + members parsed, entered the manifest, and changed nothing — while published + material kept teaching them: `commands` documented Commander.js runtime + resolution the CLI dropped for oclif auto-discovery, `fieldTypes` advertised a + registration seam that has never existed, and `events` was decorative even for + its only in-repo author, which already subscribes imperatively. + + **What is refused:** authoring any of the nine keys. Each is a `retiredKey()` + tombstone (the `manifest.loading` precedent — neither `ManifestSchema` nor the + `contributes` object is `.strict()`, so a plain deletion would have silently + stripped the keys), so authoring one is a `tsc` error and a parse error + carrying the per-key prescription. + + **FROM → TO, per member** (each tombstone carries its own one-line fix): + + - `contributes.events` → subscribe in plugin code (`ctx.hook('kernel:ready', …)` + from `init`/`start`); delete the key. + - `contributes.menus` → app `navigation` / `manifest.navigationContributions` + (ADR-0029 D7); delete the key. + - `contributes.themes` → the stack-level `themes` metadata collection (an + unrelated `ThemeSchema` surface); delete the key. + - `contributes.translations` → the `translation` metadata type: + `defineTranslationBundle` in `defineStack({ translations })`; delete the key. + - `contributes.actions` → the stack `actions` collection or + `engine.registerAction`; delete the key. + - `contributes.drivers` → register a kernel service named `driver.*`; delete + the key. + - `contributes.fieldTypes` → nothing (no registration seam exists; the + vocabulary is the spec `FieldType` enum); delete the key. + - `contributes.functions` → `defineStack({ functions })`; delete the key. + - `contributes.commands` → oclif native plugin auto-discovery (an `oclif` + section in the plugin's own `package.json`; see `cli-extension.zod.ts`); + delete the key. + + **What stays:** `contributes.kinds` (the block's one live member) and + `contributes.routes` (an open enforce-or-remove fork, #10726 — deliberately + untouched here). Runtime behaviour is unchanged: nothing ever read the nine + members, so removing them removes no behaviour; a stored manifest still + carrying one degrades to a single `[metadata_spec_invalid]` log line at + registration rather than a boot failure. + + D3 semantic entry `plugin-manifest-contributes-dead-members-retired`; no D2 + conversion, because a package manifest is not a stack collection member + (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry) and a conversion + would be a transform with no seam that ever runs. +- bc56e18: feat(spec): retire `contributes.routes` — the plugin-manifest block's last dead member (#10726, ADR-0049 enforce-or-remove; maintainer-ruled Option B 2026-08-22) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + `contributes.routes` was the one member #10724's nine-member retirement + deliberately excluded: removing it needed a ruling, not a tombstone, because + the key was the only *declared* channel for a real capability (serving a + code-handler endpoint) and four published surfaces — a customer-published + skill among them — taught it as working machinery. The measurement (#10627, + controlled, three repos, cloud leg closed clean by #10812) is that nothing + ever read it: the HttpDispatcher never registered a prefix from the + declaration, so an entry parsed cleanly and served nothing. The maintainer + ruled Option B (remove; redirect the author-facing materials to the + imperative mount). The doc corrections landed first (PR #11327); this change + is the removal half, plus the two remaining teaching sites (#11328): the + worked manifest example in `plugin-rest-api.zod.ts` and the `router` + delivered-form comments in `metadata-plugin.zod.ts`. + + **What is refused:** authoring `contributes.routes`. It is a `retiredKey()` + tombstone (neither `ManifestSchema` nor the `contributes` object is + `.strict()`, so a plain deletion would have silently stripped the key), so + authoring it is a `tsc` error and a parse error carrying the prescription. + + **FROM → TO:** + + - `contributes.routes: [{ prefix, service, methods? }]` → mount the route + imperatively: resolve the `http.server` service from the plugin context and + register the handler on `kernel:ready`; delete the key. A declarative + endpoint over a pipeline the platform already runs (query/return records, + trigger a flow) is `defineStack({ apis })`. + + **What stays:** `contributes.kinds`, now the block's sole live member + (engine → `registry.registerKind`). Runtime behaviour is unchanged: nothing + ever read the key, so removing it removes no behaviour; a stored manifest + still carrying one degrades to a single `[metadata_spec_invalid]` log line at + registration rather than a boot failure. + + D3 semantic entry `plugin-manifest-contributes-routes-retired`; no D2 + conversion, because a package manifest is not a stack collection member + (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry) and a conversion + would be a transform with no seam that ever runs. +- be21955: feat(spec): retire `contributes.kinds[].globs` — the declared file-type watch patterns nothing ever read (#11169, ADR-0049 enforce-or-remove; maintainer-ruled 2026-08-24) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + **Removed key:** `manifest.contributes.kinds[].globs`. + + The schema promised that declaring `globs` "enables the system to parse and + validate new file types" (its own example: a BI plugin handling + `*.report.ts`). The promise was never kept: real glob-driven artifact + discovery reads `filePatterns` off the metadata type registry — which + `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records + outright — so an authored `globs` was accepted, stored, served back through + `GET /metadata/kind`, and never consulted. Measured (PR #11168, re-verified + with positive control at claim): zero value reads anywhere; the only non-test + occurrences of the path were the schema declaration and two type positions. + + **FROM → TO:** `kinds: [{ id, globs: […], description? }]` → + `kinds: [{ id, description? }]` — delete the `globs` key; the kind's `id` and + `description` are unchanged and still register. The key is a `retiredKey()` + tombstone, so authoring it is a `tsc` error and a parse error carrying this + prescription. + + **What stays:** the `contributes.kinds` bucket itself and its `id` field + (live: engine → `registry.registerKind`, served via `GET /metadata/kind`). + File-type discovery remains single-channel on the metadata type registry's + `filePatterns`; if plugin-extensible discovery is ever wanted, it gets + designed against that registry, not revived here. The `registerKind` / + `getAllKinds` type positions drop `globs` (type-only; the parameter widens). + + D3 semantic entry `plugin-manifest-kind-globs-retired`; no D2 conversion (a + manifest is not a stack collection member — no seam would ever run it). +- 15d58db: feat(spec): retire the import mapping `lookup` transform's steering params (#10329, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + `ImportFieldMappingSchema.params` declared four keys whose only stated purpose + was to steer the `lookup` transform — `object` ("Lookup Object"), `fromField` + ("Match on"), `toField` ("Value to take"), `autoCreate` ("Create if missing") — + and the import path read none of them: `applyMappingToRows` handles `lookup` in + the same branch as `none` (the cell is copied through unchanged), and reference + resolution runs afterwards in `import-coerce.ts`, driven by the target field's + own metadata. Implementing them would be a second reference-resolution dialect + on the import path, which the code declines to build and the #10329 triage + ruling declined again. `autoCreate` was the one with teeth: it read as + "create the referenced record when nothing matches", and nothing was ever + created — with or without the key, an unresolved cell fails its row with + `import_reference_not_found`. + + FROM → TO, per key (all four are pure deletions — none ever had an effect to + lose, so removing them changes no import behaviour): + + - `fieldMapping[].params.object` → *(removed)*. The referenced object is the + target field's own declared `reference`. + - `fieldMapping[].params.fromField` → *(removed)*. The pipeline matches the + cell's display value against the referenced object itself. + - `fieldMapping[].params.toField` → *(removed)*. Resolution always writes the + referenced record's id. + - `fieldMapping[].params.autoCreate` → *(removed)*. Create or import the + referenced records first, then import the rows that point at them. + + One-line fix: delete the four keys (and any of their old alias spellings) from + `fieldMapping[].params`; `os migrate meta --from 17` lists the mechanical edits + for existing sources. + + The retirement kit: + + - `params` is a `strictObject`, so the keys are strict-deleted with a + `guidance` prescription per spelling — the four canonical keys and the + eleven ex-alias spellings (`lookupObject`/`targetObject`, + `match`/`matchOn`/`matchField`/`keyField`, `returnField`/`valueField`, + `create`/`createIfMissing`/`upsert`) all land on the full prescription + rather than a "did you mean" pointing at a key that is also gone (the + 17.0.0 #4509 treatment, one level down) + - ADR-0087 registration: the D2 conversion `mapping-lookup-params-removed` + (protocol 18), wired into the step-18 chain — `os migrate meta --from 17` + strips the four keys from stored `mappings[].fieldMapping[].params`. No + retired-key table entries: these keys sit one sub-walk level below the + authorable-surface drill (`data/ImportFieldMapping:params` is the walked + row, and it stays live), so there is no `defKey:name` row to register or + age out + - pin tests (`mapping.test.ts`): refusal pins per key asserting the + prescription (the `autoCreate` pin asserts the row-fails truth), alias + routing pins, and a surviving-surface pin (`value`/`valueMap`/`separator` + untouched) + - liveness ledger: `liveness/mapping.json`'s `fieldMapping` sub-walk boundary + note now records the retirement instead of parking the finding + - docs: the `import-mappings.mdx` warning about the inert params is deleted + along with the keys; the generated mapping reference no longer lists them +- d63b014: feat(spec)!: `ObjectMasterDetailFormPropsSchema.formType` narrows from bare `string` to the measured `simple | tabbed` (#11873 — the spec half of objectui#5939). + + **Newly rejected:** `wizard`, `split`, `drawer` and `modal` — each names an `object-form` renderer branch that breaks `object-master-detail-form`'s atomic parent+details contract (wizard mounts only the current step and turns the Save bar into Next; split persists via `dataSource.create` around the batch; drawer/modal move the parent half into a portal dialog the Save bar cannot submit). Each refuses with a per-value prescription; any other string (e.g. `wizzard`) now gets the plain enum refusal instead of parsing clean and rendering a silently sectionless parent form. + + **Write instead:** `simple` or `tabbed` — the two variants the renderer honours end-to-end for the parent half. For a wizard/split/drawer/modal presentation without inline details, author an `object-form`, whose `formType` keeps all six values. + + Breaking ships as minor per the launch-window convention (`scripts/check-changeset-no-major.mjs`). + + +- 9abe4e4: The #11566 `maxLength` narrowing (shipped in 17.x: `z.number().int().min(1)`, refused outside `BOUNDED_STRING_FIELD_TYPES`) is now registered in the ADR-0087 migration ledger (#11950) — the enforcement PR deliberately deferred the entry because the registry file was serialized behind an in-flight change. Following the #8321 `scale`/`precision` template, the major-18 semantic entry carries both halves: the mechanical one (delete the key where it was misplaced — inert by construction outside the write-time validator's bounded-string branch) and the judgment one (a malformed value on a bounded-string type WAS consumed by the validator's raw comparison — `maxLength: 0` accepted only empty strings, a negative value refused every write — so only the author knows the bound they meant; the entry tells them to re-declare it). `objectstack migrate meta`, `spec-changes.json` and the upgrade guide surface the entry at the major boundary; no accept/reject behaviour changes in this release. + + +- 2cc7122: `FieldSchema.maxLength` tightens on both axes (#11566, maintainer ruling 2026-08-24). Shape: the key is now `z.number().int().min(1)`, so `maxLength: 0`, negative and non-integer declarations are refused at parse — none of them is a character length, and `maxLength: 0` measurably sent the SQL schema-drift planner asking for `varchar(0)` DDL at severity error/destructive before #11431 taught that consumer to defend itself. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the write-time validator's ten — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code` — the only one of the three previously-disagreeing lists with a measured reader, exported as `BOUNDED_STRING_FIELD_TYPES`. + + What newly gets rejected: `maxLength: 0` / negative / non-integer on any type, and `maxLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms (`field.form.ts`, previously three types; `object.form.ts`, previously nine) converge on the same ten. Already-legal declarations (a positive-integer `maxLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. + + The ADR-0087 ledger entry for this narrowing is deferred to #11950 (the migration registry file was serialized behind an in-flight change when this landed), following the #8321 `scale`/`precision` template: a mechanical delete of malformed or misplaced values plus a semantic re-declare prescription at the next major boundary. +- 311433f: feat(spec,metadata-protocol): declare the metadata item-name grammar and refuse it loudly at the publish door (#12194, #12176 stage 1) + + **BREAKING** accept-set narrowing at the metadata write door, shipped as + `minor` under the repo's launch-window convention for breaking changes. + + Metadata item names — the `name` half of the `type`/`name` pair that keys + `sys_metadata` and the `/api/v1/meta` URL space — were entirely unconstrained: + the empty string, `//`, `'Views/All Leads'` and slash-compound spellings + (`views/all_leads`) were all accepted and stored, and a slash in the name + bypassed the unrecognised-metadata-type refusal entirely (`type=fieldz + name='a/b'` was accepted and stored while `type=fieldz name='a'` was 400). + Maintainer ruling 2026-08-25 (#12176): item names must not contain `/`. + + The grammar is now **declared in spec** (`MetadataItemNameSchema` / + `METADATA_ITEM_NAME_PATTERN`, `@objectstack/spec/shared`): lowercase + snake_case segments, optionally dot-qualified — the family + `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` — sourced from the existing + `ViewItemNameSchema` dotted declaration (same segment source, one grammar; the + view-item identity keeps requiring its dot). And it is **enforced at the + publish door** (`saveMetaItem` and `publishMetaItem` in + `@objectstack/metadata-protocol`): an off-grammar name is refused + `400 INVALID_REQUEST` with the grammar and the dotted prescription in the + message, and nothing is persisted. The slash bypass of + `refuseUnmintableMetaType` closes as a consequence. + + **What an author writes instead.** A flat snake_case name (`crm_lead`) and a + dotted qualified name (`crm_lead.pipeline`) both work exactly as before. A + name that spelled a sub-resource with a slash (`views/all_leads`) is + re-authored with a dot qualifier (`crm_lead.pipeline` — the qualified identity + whose prefix recovers the owner) or flattened with an underscore + (`views_all_leads`); containment is expressed by structure, never by a + separator inside the identity string. A translation item conventionally named + after its locale is named in snake_case (`zh_cn`) with the BCP-47 spelling in + its required `locale` field (`"zh-CN"`), which has been the item's real + identity key all along. + + Reads and `deleteMetaItem` deliberately stay open, so any pre-grammar residue + row remains listable and clearable. The in-repo stored corpus was measured at + **zero** slash-bearing item names (#12176 census, re-asserted at land time); + out-of-repo stored slash rows, if any exist, are reported by their deployment's + migrate run rather than rewritten silently. + + +- 9abe4e4: `FieldSchema.minLength` tightens on both axes (#11949, maintainer ruling 2026-08-25) — `maxLength`'s twin defect pair (#11566), closed with the same template. Shape: the key is now `z.number().int().min(1)`, so `minLength: 0`, negative and non-integer declarations are refused at parse. The lower bound is 1 by ruling: "no minimum" is expressed by omitting the key, not by declaring a vacuous truth — `minLength: 0` can never fail, and a permanently-true declaration is exactly the noise an AI metadata author mass-produces, so it is refused loudly at authoring time. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the `BOUNDED_STRING_FIELD_TYPES` set — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (twelve members since #11875) — the same set `maxLength` converged on. + + What newly gets rejected: `minLength: 0` / negative / non-integer on any type, and `minLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms converge on the same set (`field.form.ts` previously showed the key for three types; `object.form.ts` for nine). Already-legal declarations (a positive-integer `minLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. + + +- 9086761: `FieldSchema` now rejects an authored `deleteBehavior: 'set_null'` on a `master_detail` field at parse time (#9689). The engine has always resolved every value except `restrict` on that type to `cascade`, so the declaration asked for the child rows to be kept and got them deleted — silently, at the moment the parent went away. The rejection names the outcome and both legal re-declarations (`restrict` refuses the parent delete while children exist — no data loss; `cascade`, or omitting the key, accepts the cascade deliberately; a `lookup` is the type to use when children must survive the parent). + + Mechanism (the #7918 Option A shape, plus the 2026-08-24 idempotent-materialization ruling): the property-level `.default('set_null')` moved off `deleteBehavior` into a post-check `.overwrite()`, so the schema can tell an authored `set_null` from a defaulted one — and the `.overwrite()` never materializes a default the schema itself would refuse as authored. A bare `master_detail` now parses to output that OMITS `deleteBehavior` (previously the baked `set_null` was indistinguishable from an authored one by design, so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse — every app build with a bare `master_detail` failed). Built app artifacts stop carrying a value the schema itself refuses; the engine treats absent exactly as it treated the baked value (both cascade — measured, behavior unchanged). Every other field type keeps byte-identical output — non-reference types still carry the default at its shape position, and `set_null` on `lookup` stays legal. The inferred `Field` output type now declares `deleteBehavior` as optional (the same accepted cost as the currency `precision` relocation); at runtime a parsed field carries it on every type except `master_detail`, where absence is the honest spelling. + + There is deliberately no automatic conversion (`field-master-detail-set-null-refused` in the migration registry): only the author knows whether they meant `restrict` (keep-my-children, as a refusal) or `cascade`. Stored rows carrying the refused combination keep loading and serving — registry validation is a diagnostic, not a gate — and are refused on their next authoring-path save. + + `@objectstack/objectql`: the engine behavior is unchanged (an authored `set_null` on `master_detail` still cascades — the #9625 pin holds), but the coercion site now logs loudly (`error`, falling back to `warn`) when the combination reaches it via a raw registration or a pre-tightening stored row — the two populations parse-time rejection cannot catch. +- f11fc61: feat(spec): declare `editMode?: 'modal' | 'page'` on the object document (#11408) + + Accept-set **widening** — no existing document changes meaning and nothing is + removed. Maintainer ruling 2026-08-24 (declare, the #10144 declare-or-rule-out + family): objectui's shipped runtime reads `objectDef.editMode` (record-edit + routing: modal form vs a dedicated `/record/:id/edit` route) and its CHANGELOG + announces the key to authors, while the spec's strict parse rejected it with + `unrecognized_keys` — so an author following objectui's documentation was + refused by every spec-validating path and the key only worked through data + sources that skip validation. + + The object document now declares it beside the other display hints + (`nameField`, `highlightFields`, `stageField`): an optional cross-renderer + edit-interaction intent — `'modal'` opens the edit form as a dialog over the + current view, `'page'` navigates to a dedicated full-page edit route, absent + lets the renderer pick its own default (objectui defaults to modal). Values + outside the enum are rejected as a located value error at `editMode`. + + Consumer-side follow-up (not in this change): objectui retires its + `ObjectSchemaClientExtensions.editMode` client-extension member and lets the + spec derivation carry the key — its pinned rejection tests flip by design. + That retirement is **release-gated** on the `@objectstack/spec` release + containing this change (per the recorded ruling), not merely on this merge. +- 8f79379: feat(spec): `ComponentPropsMap['object-grid'].data` converges onto `ViewDataSchema` (#12039, objectui#6207 Option A) + + **BREAKING** accept-set change on one props-map entry, shipped as `minor` under + the repo's launch-window convention for breaking changes. + + Two spec authorities disagreed on the KIND of `object-grid`'s `data`: + `ViewDataSchema` — the authority objectui#5090 ruled the registry declaration + against, and what `ObjectGridSchema.data` resolves to — is an object + discriminated on `provider` (`object` / `api` / `value` / `schema`), while + `ComponentPropsMap['object-grid'].data` said `z.array(z.unknown())`. Measured + on `@objectstack/spec@17.2.0`: `{ provider: 'value', items: [] }` — the + pinned-legal form — was refused by the props-map entry while the bare array + parsed. Maintainer ruling (2026-08-25, objectui#6207, Option A): the props-map + entry converges onto `ViewDataSchema`; the bare-array form is the deprecated + `staticData` shortcut the objectui#4648 carve-out already refuses to publish. + + Migration — FROM → TO, one wrapping object: + + ```ts + // before (refused now) + data: [{ id: 1, title: 'Inline row' }] + // after + data: { provider: 'value', items: [{ id: 1, title: 'Inline row' }] } + ``` + + The ruled migration check ran with the change: the sweep of generated + artifacts, templates and first-party corpora (examples/, skills/, + create-objectstack, spec fixtures) found zero bare-array `data` authors, so no + rewrite ships. `staticData` (the legacy bare-array shortcut the renderer still + reads) keeps its shape but is not the prescription. + + `ComponentPropsMap['element:number'].filter` (the sibling key of #12039 / + objectui#6206) is NOT changed here: the ruling's binding measurement-first + precondition measured the pinned adapter/runtime refusing the raw + `ViewFilterRule[]` form on the element's primary (analytics) read path, which + forks that key back to triage. objectui#6206 remains open. + + +- e6ca40e: feat(spec): retire `object-grid`'s legacy `defaultSort` fallback (#11805, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`, per the maintainer's #11805 + ruling — 「不需要major」; the migration prescription is registered under + protocol major 18, where `os migrate meta` users will look). + + `ObjectGridPropsSchema.defaultSort` was the legacy second spelling of `sort`: + a single `{ field, order }` pair the renderer read only when `sort` was absent + — measured at the `.objectui-sha` pin (`190fbd01d`), + `plugin-grid/src/ObjectGrid.tsx:1244-1246` (the `$orderby` fetch fallback) and + `:2847`, where the header-arrow path wraps it `[schema.defaultSort]`, the + exact array shape `sort` carries. One intent, two spellings; objectui's mirror + schema is parity-test-only and parses nothing at runtime, so only this + strictObject can refuse the legacy spelling (objectui#5861 retires the + renderer's reads as the consumer half, on its own schedule). + + FROM → TO: + + - `defaultSort: { field, order }` (no `sort` beside it) → + `sort: [{ field, order }]` — the same pair, wrapped in the array shape every + read path honours. + - `defaultSort` beside an authored `sort` → *(removed)*. The renderer's own + precedence made the fallback unread there, so the deletion is lossless. + + One-line fix: rename the key to `sort` and wrap the value in an array; + `os migrate meta --from 17` lists the mechanical edits for existing sources. + + The retirement kit: + + - `retiredKey()` tombstone in `ObjectGridPropsSchema` — authoring the key is a + tsc error (`never`) and a parse error carrying the wrap-and-rename + prescription (the surface baseline line carries `[RETIRED]`) + - ADR-0087 registration: `ui/ObjectGridProps:defaultSort` in + `RETIRED_KEYS_BY_MAJOR[18]`, and the D2 conversion + `object-grid-default-sort-removed` (protocol 18) wired into the step-18 + chain — wrap-and-rename when `sort` is absent, a pure strip when `sort` is + present + - pin tests (`component.test.ts`): a refusal pin asserting the prescription, a + no-materialize pin, and a surviving-surface pin on `sort` + - zero authored occurrences in either repo's corpora (the card's measurement, + re-run at dispatch), so no in-repo source changes ride along +- 2a6122b: feat(spec): retire `page.components[].responsive` and the `ResponsiveConfig` layout vocabulary; repair every shipped text that prescribed it (#11027, ADR-0049 D2) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + `page.components[].responsive` was the LAST carrier of the per-breakpoint + LAYOUT block (`ResponsiveConfig`: grid columns / visibility / display order on + the Tailwind `xs…2xl` axis) — and the destination the + `dashboard.widgets[].responsive` tombstone (#4876) prescribed verbatim as the + live alternative ("which objectui `useResponsiveConfig` really does read"). + Measured across objectstack + objectui with the tsc-probe methodology + (positive and negative controls; objectui `3b147a367`, objectstack + `8d21f7a76`): that claim was false. objectui's two complete, published + implementations of the contract — `useResponsiveConfig` (`@object-ui/mobile`) + and `ResponsiveProtocol` (`@object-ui/core`) — had ZERO callers, nothing in + either repo read `.responsive` off a page component, objectui's own + `BaseSchema` node interface never declared the key, and zero authored + instances exist. An author following the shipped prescription moved an inert + key to an inert key and was told it now works. Identical disposition to + `view.responsive` (#3896) and `dashboard.widgets[].responsive` (#4876) on + identical evidence. + + **What is refused:** an authored `responsive` on a page component. The key is + a `retiredKey()` tombstone, so authoring it is a `tsc` error and a parse error + carrying the prescription. + + **What leaves with it:** `ResponsiveConfigSchema` / `ResponsiveConfig`, + `BreakpointColumnMapSchema` / `BreakpointColumnMap`, + `BreakpointOrderMapSchema` / `BreakpointOrderMap`, and the `BreakpointName` + enum — no other authorable carrier existed, and an exported value schema with + no consumer reads as a capability (#3950; the `PerformanceConfigSchema` + precedent). Importing any of them is TS2305 from this release. + + **What stays:** `responsiveStyles` (ADR-0065, `ResponsiveStylesSchema` / + `StyleMapSchema`) — the per-breakpoint channel objectui really compiles to + id-scoped CSS — is untouched and is what every repaired text now points at. + + The redirect repair (the reason this ships as one change): four author-facing + surfaces shipped the false redirect and are corrected together — the #4876 + tombstone prescription (`dashboard.zod.ts`), the generated widget reference + page, the protocol upgrade guide prose, and the protocol-17 migration + rationale that `os migrate meta --from 16` prints. The `dashboard.json` + liveness note and the #4876 conversion summary carried the same claim and are + corrected too; `responsive.zod.ts`'s `hidden → hiddenOn` alias curation, which + justified itself by `useResponsiveConfig`'s return shape, leaves with the + schema that hosted it. + + The retirement kit: + + - `retiredKey()` tombstone at the schema (`packages/spec/src/ui/page.zod.ts`), + prescription pointing at `responsiveStyles` with the CSS translations for + `columns` / `hiddenOn` / `order` + - ADR-0087 registration: retired-key entry `ui/PageComponent:responsive`, four + retired-def entries (`ui/ResponsiveConfig`, `ui/BreakpointColumnMap`, + `ui/BreakpointOrderMap`, `ui/BreakpointName`), and the D2 conversion + `page-component-responsive-removed` (protocol 18), wired into the step-18 + chain — `os migrate meta --from 17` strips the key from authored pages at + every component position, region, slot, or nested container (pure lossless + delete; it never had an effect to lose) + - pin tests (`page.test.ts` — refusal pin asserts the prescription; a positive + pin parses a component without the key and asserts `responsiveStyles` + survives; `dashboard.test.ts`'s #4876 pins now assert the corrected + prescription instead of the false redirect) + - `ResponsiveStylesSchema` guidance for `columns` / `hiddenOn` / `order` now + names the retirement and the CSS that IS applied, instead of prescribing the + dead sibling key + - generated baselines/docs follow the schema (authorable surface, JSON-schema + manifest, api-surface, export-origins, spec-changes, upgrade guide, + reference docs, skill references) + - objectui's two dead consumer implementations are the other half of this + measurement and are queued under objectui#4773 (this package's texts no + longer point authors at them) + + ## FROM → TO + + ```ts + // before — parsed green; no renderer ever applied any of it + { + type: 'element:text', + id: 'kpi_label', + responsive: { + columns: { xs: 12, lg: 4 }, + order: { xs: 2, lg: 1 }, + hiddenOn: ['xs'], + }, + } + + // after — express per-breakpoint behaviour as scoped CSS (ADR-0065), which + // objectui compiles and applies (desktop-first buckets) + { + type: 'element:text', + id: 'kpi_label', + responsiveStyles: { + large: { gridColumn: 'span 4', order: '1' }, + small: { gridColumn: 'span 12', order: '2' }, + xsmall: { display: 'none' }, + }, + } + ``` +- 8af88dd: feat(spec): retire the `allowRestore` / `allowPurge` object-permission bits — declared gates on operations that do not exist (#12497, ADR-0049) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's + recommendation B; **the keys return with the M2 lifecycle initiative** (feature + + RBAC in one batch) — anchor card #1883 stays open. + + `allowRestore` and `allowPurge` claimed to gate `restore` (undelete) and + `purge` (hard-delete / GDPR erase) ObjectQL operations that have never + existed: no destructive lifecycle verb is in the engine's dispatch vocabulary + (pinned by objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106). + Authoring the bits granted nothing — and in the `allowPurge: false` direction + the failure was ADR-0049's worst false-compliance shape: an admin believed a + lock on permanent deletion existed when the operation itself did not. The + sibling `allowTransfer` is **enforced** (#3004, the insert/update `owner_id` + door) and is untouched. + + **What is refused:** authoring either key, with any value — both are + `retiredKey()` tombstones (`ObjectPermissionSchema` is reachable from the + `permission` metadata root, so the tombstone route keeps the removal audible: + a tsc `never` on the input type plus a parse-time prescription). The former + `restore` / `purge` bare-verb aliases now answer with the same prescription + instead of a rename onto a tombstone. The tombstone rides the `.extend()` + clone into `EffectiveObjectPermissionSchema`, so the response-side def carries + the same `[RETIRED]` rows. + + **What stays accepted:** every other object-permission bit parses + byte-identically (`allow*` CRUD, `allowExport`, `allowTransfer`, + `viewAllRecords`, `modifyAllRecords`, `readScope` / `writeScope`). + + **Runtime (plugin-security):** the evaluator's pre-mapping rows + (`OPERATION_TO_PERMISSION` restore→allowRestore / purge→allowPurge) retired in + the same batch — with the bits unwritable, a mapping onto them was a claim + about a surface that rejects authoring. Behaviour is deny-before and + deny-after: a dispatched `restore` / `purge` is refused fail-closed by the + `DESTRUCTIVE_OPERATIONS` backstop, now unconditionally (not even + `modifyAllRecords` reaches an unmapped destructive op — the bypass re-covers + them only when the M2 batch re-adds the rows). `transfer` keeps its row and + its bypass. `describeHighPrivilegeBits` stopped reading `allowPurge` (a legacy + stored value grants nothing, so flagging it guarded nothing real); the + delete/purge/transfer class message is unchanged. + + The retirement kit: + + - `retiredKey()` tombstones + former-alias `guidance` prescriptions at the + schema (`packages/spec/src/security/permission.zod.ts`) + - ADR-0087 registration: retired-key entries + `security/ObjectPermission:allowRestore` / `:allowPurge` (and the + `security/EffectiveObjectPermission` pair for the cloned rows) and the D2 + conversion `permission-allow-restore-purge-removed` (protocol 18), wired + into the step-18 chain — `os migrate meta --from 17` strips the keys from + every object grant in `permissions[].objects` (pure lossless delete; they + never had an effect to lose) + - liveness ledger: both entries flipped to `dead` with the retiredKey evidence + (entries stay — the tombstone keeps the keys in the walked shape, the + `rls.priority` precedent) + - pin tests (`permission.test.ts` — refusal pins asserting the prescription; + `security-plugin.test.ts` — fail-closed pins incl. the legacy-stored-grant + and modifyAllRecords directions; `audience-anchors.test.ts` — the predicate + no longer reads the retired bit) + - generated baselines/docs follow the schema (`authorable-surface/`, + `authorable-defaults/`, spec-changes, upgrade guide, reference docs) + + ## FROM → TO + + ```ts + // before — parsed green; nothing ever read the bits, no operation existed + definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { + allowRead: true, allowEdit: true, + allowRestore: true, // claimed: can undelete — nothing enforced it + allowPurge: false, // claimed: GDPR erase locked — no lock existed + }, + }, + }); + + // after — delete the keys; restore/purge dispatches are denied fail-closed + // until the M2 lifecycle batch ships the operations WITH their RBAC bits + definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { allowRead: true, allowEdit: true }, + }, + }); + ``` + + +- b72db01: fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the prescriptions are registered + under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 + semantic entry `plugin-auto-restart-never-reinitialised` — where + `os migrate meta` users will look). Graded `minor` rather than `major` for the + same reason #12340 and #12428 were, the day before, in this same module. + + ## What was measured + + `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped + there. The comment above the call read *"Call destroy and init to restart"*, + and `init` appeared in `health-monitor.ts` **only inside that comment**. So a + plugin whose health checks crossed `failureThreshold` with `autoRestart: true` + got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`, + and periodic health checks that carried on running against the destroyed + instance. The default check when no `checkMethod` resolves is + `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes + indefinitely — so the **terminal** report on a torn-down, never-re-initialised + plugin was `healthy`. + + Reproduced at `ee3595cefd` before anything was changed, with + `successThreshold: 3`: + + ``` + round 1 (failing): status=failed destroyed=0 alive=true + after backoff: status=recovering destroyed=1 alive=false + recovery round 1: status=recovering destroyed=1 alive=false + recovery round 2: status=recovering destroyed=1 alive=false + recovery round 3: status=healthy destroyed=1 alive=false + ``` + + #11955 made that report *more* convincing rather than less: reaching `healthy` + now costs `successThreshold` consecutive passing rounds, so a destroyed plugin + has to earn a declared number of passes before it is misreported. + `restartAttempts` was incremented as though a restart had occurred, and + `maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin + that was never brought back up. + + ## Why REMOVE and not the other two ADR-0049 states + + **ENFORCE** would have to build the restart, and the class cannot host one. + `Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call + sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`, + `kernel.ts:607`), both over the full plugin list, with a context that is + `private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain + one, so a host-provided re-init hook would have had nothing to call. (Positive + control for that scan: the same pass resolves five real non-test + `plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a + per-plugin re-init API for a caller that does not exist — no runtime constructs + `PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision + names as the wrong default at this milestone, where the shippable liability is + the false promise and not the missing feature. + + **EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR + corpus returned **zero** mentions of plugin auto-restart, against 118 control + hits for "health" and 13 for "hot reload" in the same corpus. + + `maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than + as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and + *"Backoff strategy for restart delays"* have nothing left to be the vocabulary + **of** — the test that took `distributedConfig` out with the `stateStrategy` + value it was documented as requiring (#12340). + + ## What changes for a host + + All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not + `.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a + milder form of the defect being retired. A TypeScript host gets a `tsc` error + (the keys are typed `never`); a parse raises the prescription; and + `PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of + them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown + before any state is stored so a refused config leaves no half-registered plugin + behind. + + `PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that + crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and + left running; acting on that is the host's job in this host-driven library + (#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` + and restart at the level that owns the plugin's lifetime. + + Everything else in the monitor is unchanged: registration, periodic checks, the + `timeout` race and its refd-timer guard (#4875), both failure routes sharing the + counters (#11852), and `successThreshold` binding from every status that records + a failure (#11955). `recovering` is now written only by the success branch — + the one writer that ever meant it. +- 177ebdc: Add `PLATFORM_PLUGIN_WIRED_RUNTIMES` (and its row type `PlatformPluginWiredRuntime`) to the kernel platform-capability module: a companion provenance roster, keyed by npm package name, for the out-of-repo runtimes that reach the kernel through app `plugins[]` wiring rather than through a `requires` capability token — today `@objectstack/organizations` (loaded by `serve` off the resolved tenancy posture) and `@objectstack/security-enterprise` (which also backs the `hierarchy-security` token). The token-keyed `PLATFORM_CAPABILITY_PROVIDERS` map structurally cannot describe a package that backs no token; this roster makes "is this out-of-repo package real, and where does it ship from?" machine-readable for that population. Provenance only — it adds no capability token, changes no `requires` resolution, and encodes no posture-to-token semantics; drift tests pin the two rosters to agree wherever they name the same package. +- 8d237b4: Seeds can now address an ActivityPointer (#11339, ADR-0052 §5): a `text` field may declare `referenceVia: ''`, marking it as the id half of a polymorphic pointer pair whose target object the sibling column names per row (`sys_activity.record_id` via `object_name`, `source_id` via `source_object` — both now declared). The seed loader resolves such pointers as natural keys against the object each row names — the same externalId probes, in-memory map and pass-2 deferral static lookup references use — so a packaged app's seed can ship timeline rows that actually attach to their records, and the shipped console filter `{ object_name, record_id }` matches them. + + The accept/reject contract changes with it, deliberately: an unresolvable pointer on a DECLARED pair is now a loud, counted failure (`success: false`, `error`-level log, record dropped when no pass 2 can heal it) instead of the old silent verbatim store — a row that rendered on no timeline and matched no filter. Undeclared text columns are untouched: only `referenceVia` opts a pair in. Authoring contradictions are refused at parse time (`referenceVia` is text-only and mutually exclusive with `reference`) and at `ObjectSchema.create` (the sibling must be a declared field). Internal-id-shaped values still pass through verbatim, so seeds wiring real ids keep working. +- 2d8dd8d: Publish the built-in `sys_activity.type` vocabulary from `@objectstack/spec` (#11807). + + `SYS_ACTIVITY_BUILTIN_TYPES` (and the derived `SysActivityBuiltinType` union) is now exported from `@objectstack/spec/data` (`feed.zod.ts`, alongside `FeedItemType`), and the `sys_activity` object declaration in `@objectstack/plugin-audit` derives its `type` options from it — one source instead of a hand-copied list per consumer. + + The constant is the platform's **built-in set, not the column's value domain**: `sys_activity.type` stays an open, author-extensible vocabulary (#11507 ruling — an app may contribute values via `activityMilestones[].type`, ADR-0052 §5b.2, or its own inserts, and undeclared values are stored verbatim). It is deliberately a plain `as const` tuple rather than a `z.enum`, so it cannot be used as a validator; consumers must render unknown values, never drop them. UI packages that hand-copied the list (objectui's feed-kind census, which drifted the day #11522 added `scheduled`) can now read this export instead. +- 348860c: feat(spec): refuse an authored `radio` + `multiple: true` at the schema layer (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C) + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's + launch-window convention for breaking changes. + + An author could declare `{ type: 'radio', multiple: true }` and the producer + honoured it everywhere the widget could not: the data layer stored an array, + validated it as multi, split it on import and inferred multi arity for action + params, while the one renderer `radio` has draws a single-value radio group + with zero diagnostics. Declared multi, rendered single — the contradiction sat + inside `packages/spec` itself, where `SINGLE_OPTION_TYPES` calls `radio` + single-choice on one line and `MULTI_CAPABLE_TYPES` carries it on another + because it "shares the select branch". + + Per the maintainer ruling recorded 2026-08-22 on objectui#4015 (Option C, + 「接受所有」), `FieldSchema` now refuses the authored combination at parse + time — the seam every publish crosses, both for a standalone `field` document + and for fields embedded in an `ObjectSchema` — with a diagnostic that names + the field, names the illegal pair, and prescribes the correctly-named + multi-choice types: `checkboxes` (all options visible, radio-like layout), + `multiselect` (dropdown) and `tags` (free-form values). + + **What stays accepted, byte-identically:** `radio` without `multiple` + (including its materialized `multiple: false`), `radio` with an authored + `multiple: false`, `select`/`lookup`/`user`/`file`/`image` with + `multiple: true`, and the inherently-multi types with or without the redundant + flag. Because `multiple` materializes `.default(false)`, the refusal can only + ever fire on an authored `true` — a defaulted value never trips it, and + `parse(parse(x))` stays stable (pinned). + + **What is deliberately untouched, per the same ruling:** `MULTI_CAPABLE_TYPES` + and `isMultiValueField` in `field-value.zod.ts` keep `radio`, so data at rest + that was written under the old contract keeps its read path and no + stored-shape migration is paid — that is the whole reason Option C was + preferred over narrowing the sets themselves. A test pins `radio`'s membership + so a future cleanup trips loudly. `packages/objectql`'s record-validator + select/radio branch likewise stays, as a data-safety fallback for stock. + + Measured before landing (both repos, examples/docs/fixtures/tests included): + 21 `type: 'radio'` declarations, none carrying `multiple: true` — the refused + combination has zero occurrences, so no existing metadata is invalidated. The + ruling attaches an explicit flip condition: if deployed tenant metadata + carrying the combination with stored data is ever found, entrance rejection + alone would strand it and the set-narrowing option becomes required. + + +- 5383fa6: React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). ``'s canonical bindings are now the spec ListView schema's own props: `data={{ provider: 'object', object: '…' }}` for the object binding (objectui#2890 A6) and `type` for the visualization kind. `objectName` and `viewType` remain published and accepted as deprecated aliases for the whole deprecation window — nothing is removed in this release — with the deprecation visible at authoring time: `[DEPRECATED → …]` markers in the generated react-blocks contract, and a new `react-prop-deprecated` lint warning (never an error) on every use of a deprecated spelling. The lint accepts either spelling as satisfying ``'s required binding and resolves field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are. `` / `` `objectName` are unchanged: the form's spec counterpart is explicitly not 1:1 (objectui#2890 Scope B), and the chart has no metadata-tier object binding to converge on (charts bind through a dashboard `dataset` there — see chart.zod.ts guidance). Removal of the deprecated aliases is a later card after the deprecation window. +- 1a6a19c: feat(spec): open `RecordActivityProps.types` to author-contributed activity kinds (#11658) + + Accept-set **widening** on a published authorable prop, executing the + 2026-08-24 maintainer ruling on #11507: `sys_activity.type` is an OPEN, + author-extensible vocabulary — the declared options are the platform built-in + set, ADR-0052 §5b.2 stays a sanctioned author write path, and (verbatim) + "every closed map over this vocabulary is now the bug". + + `RecordActivityProps.types` (the `record:activity` filter, also embedded as + `RecordChatterProps.feed.types`) was that closed map on the authoring surface: + `z.array(FeedItemType)`, a closed enum of the 13 built-in UI kinds, so an + author who contributes an activity type (e.g. `scheduled`, which hotcrm writes + today through the sanctioned ADR-0052 §5b.2 channel) could not name it in the + filter. The element is now `z.union([FeedItemType, z.string().min(1)])`: the + enum branch keeps the built-in kinds visible as guidance (editor autocomplete, + and an `anyOf` member of the generated JSON Schema) while the open-string + branch accepts any author-contributed kind — the union accepts exactly what a + bare non-empty string accepts, so nothing is validated against the built-in + set. A typo'd built-in consequently no longer gets a named rejection; the + ruling accepted that cost rather than re-close the vocabulary. + + Every previously-legal value still parses byte-identically; non-string and + empty entries are still rejected. The `feed.zod.ts` module docblock (and the + generated `feed.mdx` reference page) no longer claims these enums have "no + backend dependency": `FeedItemType` is the target of the map UI consumers + apply to the open `sys_activity.type` column — a backend coupling, not a + backend import. + + +- 527e050: Declare `headerColor` on the strict `record:details` section schema as a closed enum (#12126, maintainer ruling A 2026-08-26). The key was deliberately refused by #11661 because the renderer's only read was a template-literal Tailwind class that generated no CSS; objectui#6294 (merged 2026-08-25) replaced that read with a lookup of complete class literals, so the refusal outlived its recorded reason. The vocabulary is exactly the six tokens that lookup ships — `muted` | `muted/50` | `accent` | `primary/10` | `secondary/10` | `destructive/10` — tints only (`CardHeader` sets no foreground, so solids would need a paired `text-*-foreground`). Declared = enforced: `objectstack validate` refuses anything outside the enum at authoring time — including the renderer's `bg-*` pass-through spellings, which render only when the host app's Tailwind build happens to generate the class — instead of shipping a header that silently does not paint. Optional with NO schema default; the `title` withhold from #11661 stands unchanged. +- dd33bf9: Declare three more `record:details` section keys the renderer has honoured all along (#11661, inheriting the #11289 ruling): `defaultCollapsed` (start a `collapsible: true` section collapsed; renderer default expanded), `icon` (heading icon, lucide name; non-identifier values render as literal text) and `description` (plain-string sub-heading under the section heading). All three were refused by the strict section schema, so `objectstack validate` warned an authored key "did nothing" while the renderer read it. All three are optional with NO schema defaults — the fallbacks stay the renderer's. The same measurement's `title` and `headerColor` deliberately remain refused: `title` is a second spelling of the heading slot `label` declares (held for a declare-vs-converge ruling), and `headerColor` only reaches the DOM as a template-literal Tailwind class that generates no CSS (dead-in-practice; reported as an objectui finding). +- 4cb2a90: Declare `hideEmpty` / `collapsible` / `showBorder` on `record:details` + sections — the three keys objectui's renderer has honoured all along + + `RecordDetailsRenderer` spreads every authored section through to + `DetailSection`, which reads all three — but the strict section schema + declared only `name` / `label` / `columns` / `fields`, so + `objectstack validate` warned that an authored key "did nothing". For + `hideEmpty` the warning hid the one key that decides whether a section + exists at all: the renderer forces `hideEmpty ?? true`, and a section whose + fields are all empty then renders nothing — no heading, no skeleton — with + no declarable spelling to ask the skeleton back (a freshly created record + losing two of its three authored sections is how this surfaced). + + Accept-set widening only; the renderer is unchanged (maintainer ruling + 2026-08-23, direction 1). All three keys are optional with **no schema + default** — the fallbacks are the renderer's, and the describe() texts + state them as measured at the `.objectui-sha` pin: `hideEmpty` on; + `collapsible` off; `showBorder` derived (on for a titled section, off for + an untitled one). `hideEmpty: false` now keeps a section's label skeleton + on an all-empty record, and schema and runtime finally say the same thing. +- 5ae4303: feat(spec): make `BaseValidationRuleShape` and `FilterCondition` nameable from the root entry (#11709) + + Additive follow-up to the #11350 root-entry nameability fix, same invariant + (a type that appears structurally in an entry's public declarations must be + nameable from that same entry): a minimal consumer program with an + un-annotated `export default defineStack(...)` and **no** `@objectstack/spec/data` + import anywhere in its program still failed declaration emit with exactly two + TS2883 diagnostics — `BaseValidationRuleShape` and `FilterCondition`, both + mentioned structurally by `defineStack`'s return type but reachable only + through hash-named internal dist chunks. The root entry now re-exports both + types from their declaring `/data` modules (`data/validation.zod`, + `data/filter.zod`), exactly as #11350 did for `FormFieldInput` / + `NavigationItemInput` / `StateNodeConfig`. No runtime change; existing + consumers that already imported these names from `@objectstack/spec/data` are + unaffected. +- ece4dad: Re-export `FormFieldInput`, `NavigationItemInput` and `StateNodeConfig` from the package root entry (#11350). These types appear structurally in the root entry's own public declarations — `defineStack` returns `ObjectStackDefinition`, declared `z.input`, which the declaration emitter expands structurally rather than preserving as an alias — but they were previously nameable only via the `/ui` and `/automation` subpaths. Any consumer letting TypeScript infer a type through a root-entry function (an un-annotated `export default defineStack(...)`) therefore hit TS2883 naming a hash-named internal dist chunk. With the re-exports, that consumer shape declaration-emits cleanly, with no annotation required. Invariant recorded: a type that appears structurally in an entry's public declarations must be nameable from that same entry. +- 735f5c7: **Federation:** `SchemaDiffEntry` gains a distinct `unreachable` kind — "the remote could not be read" is no longer reported as `missing_table`, and a transient outage no longer aborts boot under the default `onMismatch: 'fail'` (#11166, maintainer ruling 2026-08-23). + + `ExternalDatasourceService.validateEach` used to convert **any** per-object validation throw — including `connect ECONNREFUSED` from remote introspection — into a `{ kind: 'missing_table', severity: 'error' }` row, indistinguishable from a genuinely dropped table. Downstream, that shape meant: the boot gate (`ExternalValidationPlugin.runValidation`) aborted startup for a 30-second network blip, and the background drift checker raised `external.schema.drift` events claiming the schema changed on every tick the remote stayed down. + + Now: + + - **`@objectstack/spec`** (minor): `SchemaDiffEntryKind` adds `'unreachable'` — the one kind that asserts *nothing about the remote schema*; it states that validation was indeterminate because the remote (or the object definition) could not be read. The throwing error's text is carried in `actual`. Every other kind remains a measured fact about a schema that was successfully read. Additive: existing entries and their meanings are unchanged. Consumers that exhaustively switch on the kind union (e.g. a `Record`) will get a compile-time prompt to label the new member; non-exhaustive consumers see a new string value at runtime and should render it as-is. + - **`@objectstack/service-datasource`** (patch): the per-object catch in `validateEach` classifies every throw as `unreachable` (rows stay `ok: false`, `severity: 'error'`). `missing_table` is still reported — but only from its measured branch: a table absent from an introspection that returned. + - **`@objectstack/runtime`** (patch): the boot gate no longer feeds `unreachable` rows to the `onMismatch` policy — no abort under `fail`; instead it logs a loud `warn` naming the datasource, the object, the underlying error, and that the object's schema is unverified for this boot, under every `onMismatch` value. Measured mismatches keep the existing policy behavior, including sitting beside an unreachable row in the same report. The drift checker still emits `external.schema.drift` for unreachable rows (consumers discriminate on `kind`), but its operator-facing summary now says "could not read the remote", never "drift detected", for them. +- 8619f95: feat(spec): retire `SendTemplateInput.org` — the declared org-overlay resolution tenant id nothing ever read (#11832, ADR-0049 enforce-or-remove) + + + + **BREAKING** accept-set narrowing (compile-time), landing after the v17.0.0 cut + (the lockstep launch-window convention ships it as `minor`; the migration + prescription is registered under protocol major 18, where `os migrate meta` + users will look). + + **Removed member:** `SendTemplateInput.org` (`packages/spec/src/contracts/email-service.ts`). + + The member was declared as "Tenant id for org-overlay resolution (when + supported)" and no implementation ever read it: `@objectstack/plugin-email` — + the only `IEmailService` implementation — resolves templates on + `(name, locale)` only, so a caller passing `org` got no org-overlay resolution + and no error; the "(when supported)" hedge was the declaration admitting the + gap. After #11741 landed `organizationId` beside it, `SendTemplateInput` + carried two org-shaped keys of which one did nothing — exactly the shape that + invites an AI author to pick the wrong one. + + **FROM → TO:** `sendTemplate({ template, to, org: tenantId, … })` → + `sendTemplate({ template, to, … })` — delete the `org` key; it never changed + behaviour, so removing it changes none either. It is **NOT** replaced by + `organizationId`: that member is the delivery row's tenant stamp + (`sys_email.organization_id` pass-through, #11741) and opts into no template + overlay resolution. + + **What is refused:** authoring `org` in TypeScript is now an excess-property + `tsc` error (`SendTemplateInput` is a programmatic contracts interface with no + Zod surface, so the compiler is the enforcement channel — pinned in + `email-service.test.ts`). Runtime behaviour is unchanged: nothing ever read + the member, so a JavaScript caller still passing `org` keeps its exact + pre-removal outcome (the key is carried inert and ignored). + + **What stays:** `SendTemplateInput.organizationId` and + `SendEmailInput.organizationId` (#11741, Decision 2 of #11303) are untouched, + semantics included. `RenderTemplateInput` never carried `org`. D3 semantic + entry `send-template-input-org-retired`; no D2 conversion, because the key + only ever appeared in a call-time input bag — no metadata seam ever runs on it + (the `data.engine.update options.upsert` precedent). +- b706af9: Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + + - `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). + - `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. + - `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). + - `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + + Forward-stamping only: existing org-less `sys_email` rows are not backfilled. +- a11c1a5: `signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: + + - **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. + - **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). + - **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. + + Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. +- a933ed7: **`MetadataProtocol` declares the optional `auditMetaItem` member, and the audit door's request/response schemas join the spec** (#11678 — the #11006 maintainer-ruled pattern, 2026-08-22 option B, carried one door over). + + `GET /api/v1/meta/:type/:name/audit` — the ADR-0010 §3.6 compliance trail behind Studio's 审计日志 / Audit log tab — was a step behind the half-declared publish door #11006 adjudicated: **neither** side was declared (`auditMetaItem` appeared nowhere in `packages/spec`), so the REST door reached the verb through `(p as any)` twice (feature-detection guard + call) and its request literal was compiled against nothing. + + Additive, not breaking: + + - `AuditMetaItemRequestSchema` / `AuditMetaItemRequest` — `{ type, name, organizationId?: string | null, limit? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `organizationId` is nullable because the REST door always sends it, possibly `null` (#8747's fail-closed tenant scoping: `null`/absent = env-wide rows only, never every tenant's). `limit` declares no bounds because the implementation clamps to [1, 500] rather than refusing. `environmentId` stays out by the #9741 ruling (transport-level routing key) — and on this door it is not even on the wire any more (#8747 removed it; the implementation never read it). + - `AuditMetaItemResponseSchema` / `AuditMetaItemResponse` — the `{ events: [...] }` body, newest first, with the closed `operation` (save/publish/rollback/delete/reset) and `outcome` (allowed/denied/forced) vocabularies and the ADR-0010 §3.3 `lockState`. The #9426 miss-vs-fault honesty is recorded in the declared types: `{ events: [] }` is the honest answer for a clean trail, a find-less host engine, or an unprovisioned audit table — never for a missing capability (501 before the call) and never for a failed read (propagated, not invented into an empty trail). + - `MetadataProtocol.auditMetaItem?(request: AuditMetaItemRequest): Promise` — optional like its `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error. +- a933ed7: **`DeleteMetaItemRequestSchema` declares the contract members the REST reset door sends** (#11679 — the #11006 maintainer-ruled pattern on the request-shape half). + + `MetadataProtocol.deleteMetaItem` was declared all along, but its request schema declared 2 of the 8 members `DELETE /api/v1/meta/:type/:name` sends — so the door's call site had to stay behind an `(p as any)` cast (removing it surfaced `TS2353` on six keys, the opposite half of the publish door's `TS2339`), and the one member most worth having a contract — `organizationId`, which selects WHICH overlay row a reset destroys (ADR-0005 org partition; an org-less delete reaches the environment-wide row) — was on the wire with no declaration behind it. + + Additive, not breaking — the five contract-level members join the schema, mirroring the implementation's parameter type in `@objectstack/metadata-protocol`: + + - `organizationId?` — tenant scope for the reset (#8805); load-bearing, decides which row the delete destroys. + - `parentVersion?` — the ADR-0008 optimistic-concurrency pin (REST: the `If-Match` header); absent = last-write-wins. + - `actor?` — identity recorded on the history tombstone row (one producer, #7749); absent = recorded actor-less, never "system" (#4556). + - `state?` — `'active' | 'draft'`; `draft` discards the pending draft overlay only. + - `dropStorage?` — destructive opt-in (default false): also drop the object's physical table (`object` + `active` only; never `sys_`). + + Two wire members stay out, by ruling rather than omission: `environmentId` (transport-level routing key per #9741, layered on by `packages/rest`'s `TransportScopedMetaRequest`) — and there are no internal coordination keys on this door (`_skipSeedApply` is publish-batch-only). +- cccbe51: **`MetadataProtocol` declares the optional `publishMetaItem` member, and `PublishMetaItemRequest` joins the spec** (#11006, maintainer ruling 2026-08-22, option B). + + `POST /api/v1/meta/:type/:name/publish` — the promotion half of Studio's designer save-then-publish loop — was a half-declared door: #7294 declared the response (`PublishMetaItemResponseSchema`) while the request shape and the interface member stayed undeclared, so the one HTTP call site reached the verb through a cast and its request literal was checked by nothing (measured: deleting the cast answers `TS2339` — the cast carried member-existence weight, not request-shape weight). + + Additive, not breaking: + + - `PublishMetaItemRequestSchema` / `PublishMetaItemRequest` — `{ type, name, organizationId?, actor?, message?, packageId? }`, mirroring the implementation's parameter type in `@objectstack/metadata-protocol` member for member. `packageId` is `string | null` with the absent-vs-`null` distinction documented (absent = match any package; `null` = pin to the package-unbound row). `environmentId` stays out by the #9741 ruling (transport-level routing key; `packages/rest`'s `TransportScopedMetaRequest` layers it on), and the internal `_skipSeedApply` batch-coordination key is deliberately not part of the wire contract. + - `MetadataProtocol.publishMetaItem?(request: PublishMetaItemRequest): Promise` — optional like its `deleteMetaItem` / `getMetaItemLayered` siblings: additive to a shipped contract, implementation predating declaration. An undeclared key in a request literal at the member's call shape is now a compile error. + + The follow-up that removes the `(p as any)` cast at the REST call site rides the engine lane; this change only declares the contract. +- dfebfc8: feat(driver-sql,spec): one emission-identity source — `redshift`/`cockroachdb` DDL is refused by name, `pgnative` joins the Postgres family (#11991, landing the #11756 ruling) + + **BREAKING** accept-set narrowing on `SqlDriver`'s DDL path, shipped as `minor` + under the repo's launch-window convention for breaking changes — and a widening + in the same edit, so read both directions. + + Maintainer ruling, 2026-08-25 (#11756, verbatim 「同意」 on 「C,但 pgnative + 归入 Postgres 家族」). Three knex clients speak the PostgreSQL wire protocol + without being the PostgreSQL this driver emits DDL for, and the driver had no + opinion about any of them — it simply let knex compile whatever it compiles. + Measured on `origin/main` before the change, one `CREATE TABLE` per client: + + ``` + pg / pgnative / cockroachdb "body" text primary key inline + redshift "body" varchar(max) primary key in a separate ALTER TABLE + ``` + + So on Redshift the pre-ruling behaviour was not a failure — it was a table of a + different shape, built quietly, with the deployment finding out when it wrote + data into it. + + **Refused (narrowing).** A `redshift` or `cockroachdb` datasource that reaches + schema DDL — `initObjects` / `syncSchema`, `dropTable`, `rotateShards`, + `reconcileManagedSchema` — now gets an immediate + `UnsupportedDialectEmissionError`: code `SQL_DIALECT_EMISSION_UNSUPPORTED` + (newly registered under `@objectstack/driver-sql` in `ERROR_CODE_LEDGER`), + HTTP status `501`, and a message naming the client, every client the driver + DOES emit for, and the supported way to keep the database — manage its schema + out-of-band and boot with `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`. It throws + before any statement is issued, so nothing is half-built. Connection, the + connect bound and the #11389 calendar-day parser are untouched: the boundary is + DDL only, drawn where behaviour was actually verified. + + **Recognised (widening).** `pgnative` is now a member of the Postgres emission + family — knex resolves it to the same `postgresql` dialect and the same query + compiler as `pg`, differing only in which npm binding carries the bytes. It was + previously in neither the emission set nor the wire table, so a `date` column + got a bare `CURRENT_TIMESTAMP` default (the server's calendar day, the exact + #11550 defect) and no calendar-day parser. It now behaves identically to `pg` + and carries the #11389 pin. + + **One source of truth.** The pair `cockroachdb, redshift` used to be + hand-written into the connect-timeout table and again into the wire table. It + is now declared once, as `POSTGRES_WIRE_ONLY_CLIENTS`, and both tables extend + the emission sets through it — as does the refusal, which reads the same set. + Adding a future pg-wire client is one edit, and the three answers cannot drift + apart. `mariadb` is explicitly out of the ruling's scope and keeps its third + state: neither recognised nor refused. + + +- 122ef38: `ToolExecutionContext.surfaceContext` (cloud#1610): an optional, advisory description of what the user is currently discussing — Studio pillar, the selected artifact WITH its type discriminator (page/object/dashboard/report), an optional finer selection, and the canvas mode. Strictly additive beside `currentObjectName`/`currentViewName`; consumers must treat every field as optional and never use it for access decisions. +- 428f9b2: feat(platform-objects): declare `sys_metadata_activation`, the packaged-metadata activation ledger (#12155) + + Additive platform surface implementing **ADR-0126 §4 (D2)**: the disable+clone + family gets **one** data-plane platform object, declared beside its siblings so + it needs **zero `packages/spec` schema or contract surface** — it is an ordinary + platform object, not a metadata type. (The one spec file touched is the + mechanical name census described below, not protocol surface.) + + The whole schema, per §4: `metadata_type` · `name` · `package_id` · + `organization_id` (nullable, **reserved** — NULL on this entire line; the + per-org dimension is an additive column later, never a redesign) · `active`. + An earlier ADR draft carried designation columns (`replaced_by`, `cloned_from`); + amendment ruling 2 removed them — there is **no recorded linkage** between a + clone and its base, matching the landed #11513 posture ("an ordinary org-owned + set with no upgrade linkage"). The pin test asserts the column set by EQUALITY + and names both removed columns separately, so re-growing the linkage is loud. + + Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled + as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That + spelling is load-bearing, and the two obvious alternatives are both wrong here: + + - bare `unique: true` on a declared index is the positional spelling of + `'global'` — installation-wide over exactly the listed columns — and is + already warned by lint `unique/unscoped-declared-index` in 17.x; + - a hand-written `['metadata_type', 'name', 'organization_id']` composite is + NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row + by construction, so that index would enforce **nothing at all** (#5030, + measured) and one artifact could carry two contradictory `active` rows. + + `'organization'` is the arm that closes exactly that hole: the driver prepends + `COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which + is what §4's "NULL-collapsed" names. + + The name is also registered in `@objectstack/spec`'s platform-object name census + (`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a + curated set of REAL names, not a `sys_`-prefix pattern, precisely so a + cross-reference check can tell `sys_user` (real) from a fictional + platform-prefixed name; its module contract is explicit that "adding an object + to a platform package means adding its name here", and the owning package's + conformance pin fails otherwise. This is a one-name roster registration, **not** + protocol or schema surface — the ledger remains an ordinary platform object with + no zod/contract surface of its own, exactly as ADR-0126 §4 requires. Its + user-visible effect is that `isPlatformProvidedObjectName('sys_metadata_activation')` + now answers `true`, so lint stops reading a reference to the ledger as a typo. + + **No behavior change.** This leg ships the declaration only — the enable/disable + actions that write the ledger and the per-runtime consult points that read it + are separate legs, and nothing in the tree reads the object yet. Absence of a + row means the packaged default (**active**), so an empty ledger changes nothing + anywhere; there is no seeding mechanism, so a stock boot leaves the table empty. + The object deliberately declares **no `lifecycle` block** — unlike its telemetry + siblings `sys_flow_dispatch` / `sys_automation_run`, a row here is durable + configuration, and reaping one would silently re-arm an artifact an + administrator disabled. +- c4db311: `CurrencyConfigSchema` no longer materializes the `precision` default onto a configuration the schema itself would refuse as authored (#11423). A bare `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }` used to parse to `precision: 2` — and the #7918 rule rejects an authored `precision: 2` against JPY's 0 fraction digits, with the materialized and authored spellings indistinguishable by design — so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse: `parse(parse(x))` threw for an input `parse(x)` accepts. + + Mechanism (the #9689 idempotent-materialization ruling, applied to its recorded currency twin): one conditional in the `.overwrite()` — when `currencyMode` is `fixed`, no `precision` was authored, and the currency's ISO 4217 / CLDR fraction digits contradict the default `2` (the JPY/KRW/KWD class), the parsed output OMITS `precision` instead of baking a value the schema refuses. Renderers already derive display width from the currency when the key is absent (objectui#4361), so absent is the honest spelling. Every other combination keeps byte-identical output: an authored `precision` is untouched, a bare fixed 2-fraction-digit config (USD/EUR/CNY…) still materializes `precision: 2` at its shape position, and `dynamic` mode and non-CLDR codes (crypto/custom, fail-open) keep materializing — none of those can be refused. The #7918 rejection of an authored contradictory `precision` is unchanged, message and path included. +- d173125: feat(spec): retire the component-translation `submitLabel` copy key (#10926, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + The `pages..components.` copy face is measured, not mirrored: each + key exists because some component in `ComponentPropsMap` declares it. + `submitLabel`'s only declarer was `element:form`, and #9249 retired that + element whole — so the key had no declared component left to translate, and + the resolver overlay was its only reader. The maintainer ruled retire over + re-anchor (#10926): the live form surface (`object-form`) speaks `submitText` + (`I18nLabelSchema`), localizable at its own authoring site, so re-anchoring + would have widened the face for one word. The acknowledged cost is that the + bespoke-component route loses that one word. + + **What is refused:** `submitLabel` in any `pages..components.` + translation entry, and its `submit` alias spelling — both now land on a + `guidance` prescription in the strict unknown-key rejection (the face is + `.strict()`, so the strict-delete route applies: no `retiredKey()` tombstone, + the shape simply no longer declares the key). + + **What stays:** the other five copy keys (`title`, `description`, `label`, + `placeholder`, `emptyText`), the bespoke-component route for them, and the + shared `PAGE_COMPONENT_COPY_KEYS` list (now five entries) that drives both + `translatePage`'s overlay and the CLI `i18n-extract` skeleton — one list, both + sides import it, so extractor and resolver narrow together. + + The retirement kit: + + - strict-delete at the schema (`packages/spec/src/system/translation.zod.ts`): + key and `submit` alias dropped; `guidance` tombstones carry the prescription + - `PAGE_COMPONENT_COPY_KEYS` drops the slot + (`packages/spec/src/system/i18n-resolver.ts`) — the resolver no longer + overlays the key and the extractor no longer offers it + - ADR-0087 registration: D2 conversion + `translation-component-submit-label-removed` (protocol 18), wired into the + step-18 chain — `os migrate meta --from 17` strips the key from stored + translation bundles and items (pure lossless delete; nothing read it since + #9249) + - pin tests flipped, not deleted (`translation.test.ts` refusal pins assert + the prescription; `i18n-resolver.test.ts` pins that an off-spec bundle entry + carrying the retired key is ignored, not overlaid) + - generated baselines/docs follow the schema (json-schema manifest, + spec-changes, upgrade guide, api-surface signatures, reference docs) + + ## FROM → TO + + ```ts + // before — a component-translation entry could carry a submit label + translations: [{ + 'zh-CN': { + pages: { + sales_home_page: { + components: { new_lead_form: { submitLabel: '创建' } }, + }, + }, + }, + }] + + // after — delete the key (nothing has read it since #9249); submit copy for + // the live form surface is authored on the component itself, where it is + // localizable inline + { + type: 'object-form', + properties: { + objectName: 'lead', + submitText: { en: 'Create', 'zh-CN': '创建' }, + }, + } + ``` +- 8425c17: The five engine members the #11833 sweep measured as "real on ObjectQL, consumed cross-package, recoverable only through consumer-local structural re-declarations" are now on the contract, per the 2026-08-25 maintainer ruling (#12248). `IDataEngine` gains five optional members: `resolveEffectiveDatasource?(objectName)` (the #5288 effective-datasource name, `undefined` = rides the deployment default), `getDriverForObject?(objectName)` (the public driver-routing read, `IDataDriver | undefined`), and the datasource-lifecycle trio `registerDatasourceDef?` / `markDatasourceUnavailable?` (`kind: 'blocked' | 'failed'`, framework#3828) / `clearDatasourceUnavailable?` (#12010's inventory, adjudicated per the ruling's item 4). Engines without datasource routing stay conformant — every member is optional, preserving each graceful-degradation seam. And `IObjectQLEngine.getObject` / `EngineSchemaRegistryView.getObject` now return `ServiceObject | undefined` — the spec's own registered-object type (authored state, ADR-0122) — instead of `unknown`, so consumers reading `fields` / `external` off a registered object no longer need a private structural re-declaration to do it; an engine or registry fake answering a non-conforming shape now fails compile at the member instead of drifting silently (the #4251 gap, closed at this seam). +- 772d5de: feat(spec): declare `cloud-connection:panel` / `marketplace:installed-list` in `ComponentPropsMap` — undeclared keys on the two are refused (#11575) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + These were two more instances of the #8691/#8744 silent no-op class: + console-registered widgets on `@objectstack/cloud-connection`'s published + Setup pages, reachable through the component type union's open string arm, + with registered renderers but no `ComponentPropsMap` row — so the #5068 + component-props gate's dispatch skipped them as unregistered and any authored + key rode through every validator in silence. + + The new rows are strict and **empty**, measured from the renderers' actual + read points at the objectui pin, not from the registrations' declared-input + lists (#8691/#8744 record where those diverge — here the two happen to + agree): both registrations discard the schema node entirely + (`() => `, `() => `) and neither + component function takes a prop, so the widgets accept **no configuration at + all**, and an authored key is now a publish-time refusal naming the surface + instead of a silent no-op. + + **What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the + shape both plugin-shipped pages (`cloud_connection_settings`, + `marketplace_installed`) author today, byte-identically. Node-level keys + (`visibleWhen`, `id`, `style`, …) are unaffected: they live on the component + node, and the refusal's guidance says so. + + ## FROM → TO + + ```ts + // before — parsed green everywhere; the panel polls on its own schedule anyway + { + type: 'cloud-connection:panel', + properties: { pollInterval: 5 }, // silent no-op: the widget reads nothing + } + + // after — any key is a publish-time refusal naming the zero-prop surface; + // write the measured shape + { + type: 'cloud-connection:panel', + properties: {}, + } + ``` + + There is deliberately no automatic rewrite: a key authored on either widget + configures nothing and is removed, not renamed — behaviour that seems to need + one is a renderer capability request against objectui, not a metadata key. + `os migrate meta` surfaces the change as a structured TODO (semantic entry + `ui-cloud-connection-widgets-unknown-keys-refused`, protocol major 18 — this + refusal is not part of the v17.0.0 cut). + + +- ce80ec2: feat(spec): declare `mcp:connect-agent` in `ComponentPropsMap` — undeclared keys on the widget are refused (#12344) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + This was a third instance of the #8691/#8744 silent no-op class (#11575 closed + the previous two): a console-registered widget on `@objectstack/mcp`'s + plugin-shipped Setup page (`CONNECT_AGENT_PAGE`), reachable through the + component type union's open string arm, with a registered renderer but no + `ComponentPropsMap` row — so the #5068 component-props gate's dispatch skipped + it as unregistered, any authored key rode through every validator in silence, + and door 3 of the mcp canonical-envelope gate (#12269) had to carry a standing + exemption for the type (deleted here, with its two guard pins). + + The new row is strict and **empty**, measured from the renderer's actual read + points at the objectui pin, not from the registration's declared-input list + (#8691/#8744 record where those diverge — here the two happen to agree): the + registration discards the schema node entirely (`() => `) and + the component function takes no parameters — every value it renders comes from + `/discovery`, i18n and its own state — so the widget accepts **no + configuration at all**, and an authored key is now a publish-time refusal + naming the surface instead of a silent no-op. + + **What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the + shape the plugin-shipped page (`connect_agent`) authors today, + byte-identically. Node-level keys (`visibleWhen`, `id`, `style`, …) are + unaffected: they live on the component node, and the refusal's guidance says + so. + + ## FROM → TO + + ```ts + // before — parsed green everywhere; the widget reads /discovery on its own + { + type: 'mcp:connect-agent', + properties: { serverUrl: 'https://example.test/mcp' }, // silent no-op: the widget reads nothing + } + + // after — any key is a publish-time refusal naming the zero-prop surface; + // write the measured shape + { + type: 'mcp:connect-agent', + properties: {}, + } + ``` + + There is deliberately no automatic rewrite: a key authored on the widget + configures nothing and is removed, not renamed — behaviour that seems to need + one is a renderer capability request against objectui, not a metadata key. + `os migrate meta` surfaces the change as a structured TODO (semantic entry + `ui-mcp-connect-agent-unknown-keys-refused`, protocol major 18 — this refusal + is not part of the v17.0.0 cut). + + +- b372318: `UserActionsConfigSchema` adopts `group`, `hideFields` and `rowColor` (ruled Option A on objectui#5435): the three toolbar affordances ListView already honours become authorable in a spec-valid document, so the runtime fold of legacy `showGroup`/`showHideFields`/`showColor` flags now passes the save gate instead of being rejected by name. All three are booleans; the defaults copy the renderer's reads — `group` defaults on, `hideFields`/`rowColor` default off. Accept-set widening only: no existing document changes meaning, and the object-level `userActions` block (create/import/edit/delete/exportCsv) still rejects all view-vocabulary keys by name. + +### Patch Changes + +- 8bdd955: **Docs:** `APPROVER_ORG_SCOPED`'s docblock stops justifying `team: false` with "the engine never scoped it", which the team organization screen made false (#10548). + + The docblock over `APPROVER_ORG_SCOPED` carried two clauses about `team`, and after #10230 they no longer agreed. "`sys_team_member` carries no organization column" is still true — it is still why a team's *members* are not individually placed. "the engine never scoped it" no longer described the engine: `expandTeamUsers` (`packages/plugins/plugin-approvals/src/approval-service.ts`) opens with `teamIsProvablyOutsideOrg`, which reads `sys_team`'s `organization_id` and drops the team when it names an organization other than the request's, at both of its call sites. + + The **flag value is unchanged and deliberately so**. The table answers ADR-0105 D9 *retargetability* — "does an `organization:` declaration apply to this type" — and `team` still consults no org-scoped directory, so a declaration on it still has nothing to redirect and is still rightly refused by `resolveApproverDirectoryOrg`. Only the justification had drifted, by resting on an engine behaviour that has since changed. The replacement text says the flag is about **targeting, not tenancy**: org-agnostic for retargeting because no directory is consulted, and screened to the request's organization on the team's own `organization_id` regardless. + + The risk repaired is the ordinary one for a load-bearing comment: the next reader deciding whether `team` needs an organization screen would find a spec docblock asserting the engine has none and conclude the work is outstanding when it has landed — or read the `team: false` / `manager: false` pairing as still marking "the unscreened types", which after #10153 and #10230 it does not. + + Prose only: no schema shape, no `.describe()` text, no runtime behaviour, and no authorable-surface movement. It is graded rather than skipped because the text ships to consumers on **two** surfaces — `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts`, so the docblock travels in the npm tarball as source, and unlike a property-level comment inside a `z.object({ … })` literal, a docblock over a top-level `export const` survives declaration emit: the stale sentence is present in the built `dist/automation/index.d.ts` and `dist/automation/index.d.mts`, which is what a consumer's editor surfaces on hover. +- 1fa05a6: fix(client-react): correct two broken TSDoc `@example` blocks the SDK docs shipped verbatim (#10969) + + TSDoc `@example` blocks are preserved into the published `dist/*.d.ts` (confirmed by + building and reading the emitted declarations), so they reach consumers directly in their + editor's hover tooltip — copying one is the intended usage. Two were actively wrong: + + - `useAutoRefresh`'s example read `data.map(...)`. `data` is a `PaginatedResult` (or + `null`), which has no `.map` — copied verbatim, this throws once the query resolves. + Fixed to `data?.records.map(...)`. + - `useMetadata`'s example called `client.meta.getObject(...)`, a method that does not + exist on the client (only `getItem`/`getItems`/`getView` do). Fixed to the real + `client.meta.getItem('object', ...)`, matching `useObject`'s own implementation. + + While in there: every other `@example` on this surface (18 more, across + `client-react`'s `data-hooks.tsx`/`metadata-hooks.tsx`/`realtime-hooks.tsx`/`context.tsx` + and `client`'s `index.ts`) is now genuinely self-contained and copy-paste-able — each + previously omitted the `import` for the hook or type it demonstrated, and three + `realtime-hooks.tsx` examples wrote a literal `useQuery(...)` (three dots) as a prose + placeholder, a syntax error once copied. + + **`@objectstack/spec`: dev tooling only, nothing published changes.** The gate that now + type-checks the surface above (`check:skill-examples`, `packages/spec/scripts/`) lives in + this package but is not part of it — `scripts/` is outside `@objectstack/spec`'s publish + `files` allowlist (confirmed via `check:published-files`), so no consumer-visible surface + moves. Named here only because the fixed-version group requires every package with a + source diff to be covered by a changeset; the actual version bump is a byproduct of the + group moving together, not a claim that spec shipped something new. + + No exported type, function signature, or runtime behaviour changed on any of the three — patch. +- dcb10a5: fix(spec): `COMPONENT_NODE_VISIBILITY_GUIDANCE` no longer claims a hoisted `properties` visibility key is evaluated by nothing (#11033) + + The `COMPONENT_NODE_VISIBILITY_KEYS` key-set guard's `prescription` — the text + emitted to an author when a visibility key (`visible` / `visibleWhen` / …) is + written inside `properties` instead of on the component node — closed with: + + > Inside `properties` it is hoisted onto the node by the renderer but evaluated + > by nothing — the component renders unconditionally, which is a visibility + > gate that silently does not gate. + + That was true when it was written and is false since objectui#5505 + (`c86185eb5`, merged 2026-08-21): `SchemaRenderer`'s node-level `visibleWhen` + evaluator now binds `record`, so the hoisted value IS evaluated by the + node-level gate. Post-#5505 the props-level and node-level forms evaluate the + same value over the same `RecordContext` and compose as an idempotent AND — + there is no gate that silently fails to gate. + + The prescription now states that truth instead, and keeps its move-it-up + advice resting on the reason that still holds: `visibleWhen` at the node, + beside `type` and `id`, is the ADR-0089 canonical spelling — a layer-discipline + argument, not an inertness one. + + Message text only. No accept/reject verdict changes, no schema shape changes, + and no runtime behaviour changes — both gates already evaluated the value + identically before and after this change. +- 7c0d0c3: fix(spec,rest): give `api.enableSearch` a declared seat, and stop reading runtime-honoured config keys through `as any` (#11983) + + `api.enableSearch` was a live REST config key with no declared seat: + `RestServer.normalizeConfig` read it through `(api as any)` and honoured it + (`enableSearch: false` really unmounted the search endpoints), but no schema in + `packages/spec` declared it. Because `RestApiConfigSchema` is not `.strict()`, + its own parse **stripped** the key — measured: + `RestApiConfigSchema.parse({ version: 'v1', enableSearch: false })` returned an + object with no `enableSearch` property at all — so any consumer of the parsed + config silently got search turned back on for a deployment that turned it off + (the ADR-0104 silent-strip class). It also forced #11637's construction-time + parse to be validation-only, discarding the parsed value. + + - `RestApiConfigSchema` now declares + `enableSearch: z.boolean().default(true)` beside `enableOpenApi`, with the + runtime's existing default. The opt-out now survives the key's own + contract's parse (pinned), and a TypeScript author can write + `api: { enableSearch: false }` without a cast. + - `packages/rest`'s `normalizeConfig` drops all three `as any` reads: the + newly declared `enableSearch`, the already-declared + `metadata.maskObjectFields` (its declared seat landed separately; the cast + was stale), and the long-declared `enableOpenApi` (stale residue from + before its declaration). `NormalizedRestServerConfig.api.enableSearch` is + now a required boolean like its siblings. + + No runtime behavior changes: defaults are identical (`enableSearch` on, + masking on per ADR-0106 D8, OpenAPI on); this change moves the keys from + cast-reachable to declared = enforced. +- e25403c: `DELIVERY_NOT_ELIGIBLE`'s ledger gloss now describes every surface that raises it, not just `redeliver` + + The `ERROR_CODE_LEDGER` entry for `DELIVERY_NOT_ELIGIBLE` glossed the code as + *"delivery row is in a non-terminal state"*. That named one refusal on one + surface, and the code has since been reused on a second: `INotificationOutbox.ack` + refuses any row that is not `in_flight`, which covers an unclaimed `pending` row + **and** an already-terminal one. So the old wording was not merely incomplete — + it was backwards for half the code's uses, describing terminal rows as the + acceptable ones when `ack` refuses exactly those. + + The reuse itself is the ruled shape, not a defect: one concept — *this delivery + row's state does not permit the requested operation* — on two delivery surfaces, + with a second near-synonym code rejected for the vocabulary sprawl ADR-0112 + exists to prevent. Only the comment lagged. + + The gloss is now stated per surface, because the two refuse opposite halves of + the state space and no single status predicate covers both: + + - **`IHttpOutbox.redeliver`** (`HttpRedeliverError`) refuses a row that is NOT + terminal — `redeliver` means send this again, so it wants + `success`/`failed`/`dead`. It also raises the same code when the producer's + `RedeliverGuard` refuses or itself throws (fail-closed: "we could not check" + must never read as "allowed"), and when the terminal re-check at the write + misses because a dispatcher tick re-claimed the row mid-call. + - **`INotificationOutbox.ack`** (`NotificationAckError`) refuses a row that is + not `in_flight` — both the unclaimed `pending` row (the ack-as-cancel trap) + and the already-terminal one — plus the `SqlNotificationOutbox` compare-and-set + read-back that shows the claim was lost mid-ack. + + Comment only. No code is registered or removed, no wire value changes, and no + acceptance or refusal behaviour moves — `packages/spec` publishes + `src/**/*.zod.ts`, so the corrected gloss ships to consumers reading the ledger. +- 64baa68: fix(spec): the dist-freshness refusal derives its package label and build remedy from `pkgDir` (#11250) + + `inspectDistFreshness()` / `inspectBundleFreshness()` in `packages/spec/scripts/lib/dist-freshness.ts` + already take an arbitrary `pkgDir`, but their refusal `cause` strings and the `pnpm --filter build` + remedy line hardcoded `packages/spec` / `@objectstack/spec` regardless of it. Every real caller passed + `SPEC_DIR` until #10969 gave `check:skill-examples` a second surface (`packages/client-react` / + `packages/client`) — confirmed live: a stale-dist refusal on that surface named `packages/spec` while + `packages/spec` was freshly built and `client`/`client-react` were the actually-unbuilt packages, so + following the printed remedy verbatim rebuilt an already-fresh package and re-red identically. + + Both cause strings now interpolate a `packages/` label derived from `pkgDir`'s own path (falling + back to the raw path when the shape doesn't match), and the build-remedy line now reads + `package.json#name` from `pkgDir` (falling back to the same label when it's missing or unparsable). The + freshness verdict itself, and every caller's own `rerun` argument, are unchanged — this is diagnostic + text only. +- 9fa70d7: fix(spec): the generated docs category index cards the pages its `meta.json` declares (#11260) + + `content/docs/references/security/index.mdx` carded four of the five pages the + `meta.json` beside it declares. The fifth, `misc`, was generated and routed in + the sidebar — and unreachable from the one page whose job is to reach it. + + Both files come out of one `gen:docs` run, from two enumerations of "the pages + of this category" that disagreed about exactly one bucket: + + - `meta.json` was built from the pages the run **emitted**, which is where a + published schema that no `.zod.ts` accounts for lands (`security/` declares + two in plain `.ts` files, so they fall to the `misc` catch-all); + - the card grid was built from the `.zod.ts` files **on disk**. + + `misc` has no `.zod.ts` behind it by definition — the generator says so twice, + and `sourcePathFor` returns nothing for it precisely so the page prints no + invented "Source:" line — so it was *structurally* absent from the second + enumeration. The card loop never considered it, which also means the + `wasEmitted` guard that the loop's own comment leaned on ("This aligns the + index with `meta.json`") never ran for it. The comment was wrong in the shape + that reads as verified: it named the invariant while the code held it by + coincidence, for the 13 categories where two independent enumerations happen to + agree. + + The grid now iterates the list `meta.json` was built from, and keeps the + `wasEmitted` guard — a `.zod.ts` whose schemas are all unrepresentable in JSON + Schema still cannot be carded into a dangling 404. Because both files now read + one list, that guard can no longer thin the grid silently: a declared page the + run did not emit stops the build naming it. The stated invariant is true by + construction rather than by coincidence, which closes the class instead of + special-casing `misc`. + + The regenerated output is one line: `security/index.mdx` gains its `misc` card + (with no "Source:" line, correctly). The other 13 category grids are + byte-identical. + + An all-`misc` category — every published schema in the catch-all — would have + rendered an **empty** `` grid under the old loop, with every zod-derived + slug filtered out and `misc` never considered. No such category exists in the + repo, so no emitted file can pin it; the rule moved into + `scripts/lib/category-index.ts` so it can be asserted directly, and an empty + grid is now unreachable from a non-empty declaration. +- 09db64a: **Docs:** removes stale GraphQL references and stale hand-typed header provenance from generated and hand-kept protocol docs (#10834, #10833). + + GraphQL was retired as a product surface some time ago: `packages/spec/src/api/` has zero GraphQL sources, the `/graphql` HTTP route was removed from the dispatcher (out of the product plan, #2462 follow-on), and `graphql` was never actually a `CoreServiceName` — it only ever existed as a stray entry in this table and in metadata-protocol's discovery table (see the comment above `SERVICE_PROVIDER_TABLE` in `core-services.zod.ts`). Two places in the package still asserted otherwise: + + - The generated `content/docs/references/index.mdx` API Protocol blurb read "REST/GraphQL contracts, …". The source is `CATEGORY_BLURBS.api` in `packages/spec/scripts/build-docs.ts`; fixed there and regenerated with `gen:docs` — no hand-edit to the generated `.mdx`. + - The hand-kept `packages/spec/llms.txt` (no generator; ships in the npm tarball per `files`) listed an `IGraphQLService` contract (execute, subscribe) under Service Contracts. `IGraphQLService` is declared nowhere in `packages/**/src` — verified before removal. Deleted the row rather than marking it `**DEPRECATED**` like the neighbouring `IUIService` row: that precedent fits a contract that has a replacement to point readers at; GraphQL has none — it's out of the product plan, not superseded by another contract — so a deprecation note would invent a migration path that doesn't exist. + + Also dropped this file's hand-typed `Schema Count` / `Last Updated` header lines (`171 Zod schemas, 191 test files, 5,157 tests`, `2026-02-12`) rather than refreshing them. Measured against the current tree: `packages/spec` now publishes 1,585 schemas (per the freshly generated `content/docs/references/index.mdx` root index) across 418 `*.test.ts` files — both roughly an order of magnitude past what the header claimed. Since this file has no generator (confirmed by the filer) and nothing re-verifies these numbers on change, a refreshed count would start drifting again on the very next PR that touches the package; removing the assertion is more honest than restating a number this file has no mechanism to keep true. Whether `llms.txt` should be generated at all is a larger follow-up left to the PM, not decided here. + + Graded rather than skipped: `llms.txt` ships in the `@objectstack/spec` npm tarball (`files`, enforced by `check:published-files`), so this prose change reaches consumers the same way the precedent in #10669 (`skill.tools` docblock) did. +- 92916e7: fix(spec): reference pages carry every variant's `.describe()` text when a property opens more than one object shape (#12316) + + The designed-and-measured remainder of #11601. #12309 gave a property that + opens exactly ONE nested object shape a `### Nested Shape:` table, and refused + a property whose type is a union of TWO OR MORE object shapes — there was no + single "the shape of this property" for a heading to name, and naming one + would have meant a variant index, i.e. a second addressing notation. Those rows + kept the collapsed signature cell, and the cell has no description column, so + their nested `.describe()` text stayed exactly as unreachable as #11601 found + it. + + **The population, re-measured on `origin/main@7bd6447`.** 28 of the 8604 + rendered property rows open two or more shapes, and **all 28** carry describe + text on at least one variant: `ui/App.navigation`, + `ui/NavigationArea.navigation` and `ui/NavigationContribution.items` with nine + variants each, `data/ConditionalValidation.then` / `.otherwise` and + `system/CRDTMergeResult.state` with five, `system/ChangeSet.operations` / + `.rollback` with seven, down to four two-variant rows. The census is unchanged + from the one #11601 recorded when it deferred them. + + **What is rendered now.** One sub-table per variant, each under the same + `### Nested Shape:` heading in the same position — no new grammar, and no new + heading level, so the single-h1 invariant and the module-header numbering are + untouched. The accessor gains a SELECTOR segment, spliced in where the union + sits in the wrapper stack rather than appended to the finished path, so + `StateMachine.on[string][option 3][number]` reads left to right as *the record + value, its third option, an element of it* — the reading `[string][number]` + already had. + + Two spellings, in preference order: + + - **`[type='sidebar']`** where the union has a discriminant — a key every + shape-bearing variant pins to a *different* literal. It states what the + author writes to select that variant, in the same `formatLiteral` spelling + the Type cell prints two lines above, and it is stable: reordering the union + or adding a tenth variant moves no existing heading and breaks no existing + anchor. 22 of the 28 rows have one. + - **`[option 2]`** for the six that do not — deliberately the word the union + branch has printed under `### Union Options` since long before this, rather + than a bare `[2]`, which in a stack of `[number]`/`[string]` segments would + read as a tuple index into the property's own type. It counts position in the + union *including* arms that open no shape, so the number is checkable against + the `string | { … } | { … }[]` cell the table sits under. + + The distinctness half of the discriminant test is load-bearing: a union whose + arms pin the same `const` has no discriminant by this rule, because answering + one would emit two identical headings — two identical anchors on one page, the + defect the `Schema.key` qualification exists to prevent. + + Every bound #11601 set still holds. Depth stays at `SHAPE_DEPTH_LIMIT = 1` — + what was lifted is the multi-shape *refusal* at level 1, not the budget, so a + shape nested inside a variant is exactly as unreachable as it was inside a lone + shape. "Only where there is text to publish" is now decided **per variant**, + which is the same rule one level finer: `ui/FormView.submitBehavior` opens four + shapes and one carries prose, so it gets one table, not four. And a variant + table still relocates no vocabulary — it is a third position for those keys. + + A property opening exactly one shape gains no selector segment, because the + stamp is conditioned on the union's own yield and not on the row's: all 1469 + single-shape headings #11601 published are byte-identical. The regenerated tree + is **purely additive** — 15 files, **+1397 / -0** lines, no reordering. +- a84f3ea: fix(spec): reference pages carry a nested item shape's `.describe()` text instead of collapsing it into a signature cell (#11601) + + `build-docs.ts` renders a property whose type is an inline object as a + one-line signature — `{ label: string; icon?: string; visibleWhen?: string | + object; value?: string; … }[]` — into a table cell that has **no description + column**. Every `.describe()` an author wrote on a key of that shape was + therefore unreachable from the reference page: not truncated, not marked, + absent. `page:tabs`'s item-level `visibleWhen` carries a ~600-character + contract note whose whole point is that its evaluation environment is **not** + the page-component `visibleWhen` of the same name, and + `content/docs/references/ui/component.mdx` rendered that row with an empty + Description cell. + + The loss was invisible from both sides. `check:docs` compares generated output + with committed output, so it is green forever on prose neither side contains — + measured on the tree before this change, adding a `.describe()` to a nested + item key produced a **zero-line** `gen:docs` diff. + + **The population, measured on the emitted tree.** 1293 property rows across + 566 published schemas and 13 of 14 categories open a nested shape; 1208 of them + have at least one key carrying describe text, 7502 described keys in total, + ~473 KB of authored prose that reached no page. + + **What is rendered now.** A property that opens exactly one shape, and whose + shape has at least one described key, gets a `### Nested Shape:` table directly + under the Properties table — the same position, addressing and heading level + the `### Allowed Values:` relocation has used since #6225, so the page gains no + second grammar. The heading names the shape with a TypeScript indexed accessor + (`PageTabsProps.items[number]`, `Object.fields[string]`), which is a real + spelling rather than a sigil invented for the docs. + + Four bounds, each measured rather than chosen: + + - **One level**, matching the `SHAPE_DEPTH_LIMIT` budget a cell already spends. + A nested table opens no table of its own. + - **Only where there is text to publish.** A shape whose keys carry no + describe text keeps its cell; a table there would restate the cell in more + space. + - **A union of two or more object shapes keeps its cell.** There is no single + "the shape of this property" to name — the same reason `formatPropertyType` + refuses to relocate a vocabulary out of `Enum<…>[]`. + - **A nested table does not relocate vocabularies.** It is a second position + for those keys, so it elides them the way a `{ … }` summary does. Without + this rule the 288-member `ApiError.code` vocabulary was re-listed under every + nested `error` shape — 20,260 bullet lines across the tree, `api/metadata.mdx` + alone +6097. + + Tombstoned keys are rendered in a nested table, unlike in the cell above it: + `retiredKey()` puts the whole `[REMOVED]` migration prescription in + `description`, and a signature has no column to carry it. + + The regenerated tree is **purely additive** — 143 files, +14195 / -118 lines, + and every one of the 38177 pre-existing lines is still present byte for byte + (the 118 are re-ordering around the inserted sections, not removal). +- f2eaae8: fix(spec): the generated docs root sidebar lists categories from the same declared page list `meta.json` and the category index already agree on (#11482) + + `build-docs.ts` writes the docs tree from what should be one answer to "which + categories/pages exist", but the ROOT `content/docs/references/meta.json` (§3 + — the sidebar's top-level category list) was still answering it a third way: + + - a category's own `meta.json` (§2) is built from the pages the run + **emitted**; + - that category's `index.mdx` card grid (§2.5) reads the SAME declared list + (#11260) — no longer a second, independently-derived enumeration; + - the root `meta.json` (§3), until now, filtered on `categoryZodFiles` — the + `.zod.ts` files found **on disk** — a third, independent enumeration. + + A category whose published pages all come from plain `.ts` files rather than + `.zod.ts` ones (the `misc` catch-all class `security/misc` proves is real) has + zero `.zod.ts` files while still publishing a page, a `meta.json` and an + `index.mdx`. The old filter would drop such a category from the sidebar even + though it is fully generated and routed everywhere else — a folder complete on + disk and unreachable from the nav. + + **No category is in that state today** — all 14 have at least one `.zod.ts` + file — so this was a latent defect with no live instance, and the regenerated + root `meta.json` is byte-identical. The filter now reads `categoryMetaPages`, + the same map §2.5 already reads, so all three files answer from one list + instead of three that happen to agree today. The rule moved into + `scripts/lib/root-meta.ts` (`rootCategoryDirs`), pinned directly with the + all-`misc`-category shape that has no instance in the repo — the same move + #11260 made for the category card grid, for the same reason: the defect's + output is an ABSENT sidebar entry, which `check:docs` cannot see any more than + it could see an absent card, and the edge that has no live instance cannot be + pinned from emitted output at all. +- 8b13cc8: The field designer no longer offers a `master_detail` a `deleteBehavior` the schema refuses + + #9689 made `deleteBehavior: 'set_null'` authored on a `master_detail` field a + named parse-time rejection. The two shipped metadata forms that let a Studio + author pick that value did not move with it, so the designer kept offering + "Set null" for a `master_detail` — the author picked it and learned only at + publish, from a 422, that the choice was never legal. Declared-vs-enforced, one + seam earlier than the parse door. + + Both forms failed the same way through **different option sources**, which is + why the repair is not one edit twice: + + - `object.form.ts` declared an inline `options` array carrying all three values + behind a single `visibleWhen` that named both `lookup` and `master_detail`. + - `field.form.ts` declared **no** `options` at all. That is not a narrower + offer — it is the renderer's derived source: with no inline list the + metadata-admin form falls through to the JSON Schema `enum`, which is + `['set_null','cascade','restrict']` and additionally advertises + `default: 'set_null'`. A Zod enum has no per-type narrowing to give, so the + derived path re-offers the refused value by construction, and `master_detail` + can only be served by an explicit list. + + Both now declare the control **twice, with disjoint `visibleWhen`** — `lookup` + keeps all three outcomes, `master_detail` is offered `cascade` and `restrict` + only. Nothing is taken away from `lookup`, where all three remain legal: + `object.form.ts` keeps its three-value list unchanged, and `field.form.ts`'s + `lookup` branch keeps deriving from the enum exactly as before, so its labels + and their translation are untouched. The two branches are mutually exclusive, so + no author ever sees both. + + **Per-option `visibleWhen` was the tighter-looking spelling and was measured and + rejected.** `SelectOptionSchema` does declare it (ADR-0068), so it reads as + existing vocabulary, but it is not reachable from a metadata form in either + direction: the metadata-admin renderer maps `fieldSpec.options` straight to + select items and never consults it, so writing one would ship an ADR-0049 + declared-but-unenforced key; and on the runtime surface that *does* honor it, + the per-option evaluator binds `record` and never `data`, so a `data.`-rooted + predicate there is an unbound identifier whose visibility fails **open**, + keeping the option. Either way the author would still have been offered + "Set null" — a fix that silently does nothing. Field-level `visibleWhen` is the + predicate the renderer already evaluates for every other type-conditional + control in these files, so the split uses it and the form DSL is unchanged. + + The pinning test asserts more than the absent value: it requires **exactly one** + visible control per type, because two declarations of one key can fail by + overlapping (two selects writing one key) as well as by leaving a gap (the + control vanishes for that type). +- 3bfa1e6: Fix four self-contained TSDoc `@example` blocks in `packages/spec/src` that did + not compile against the package's own built declarations, and add `os:check` + markers so `check:skill-examples` keeps them honest going forward. + + The package's front-door header (`src/index.ts`) taught a root namespace-import + style (`import { Data, UI, System, Auth, AI, API } from '@objectstack/spec'`) + the root has never exported, and two subpath-import styles that named + `@objectstack/spec/auth` — a subpath that does not exist (the real one is + `@objectstack/spec/identity`). The header now documents three styles that + actually compile: root-level `defineX` factory imports (`defineStack`, + `defineView`, `defineApp`, `defineFlow`, `defineAgent`, `defineTool`, + `defineSkill`, …, which the root really does export), namespace imports via + subpath, and direct subpath imports — all against `@objectstack/spec/identity` + instead of the non-existent `/auth`. + + `src/shared/branded-types.zod.ts`'s `@example` imported `ObjectNameSchema` / + `FieldNameSchema` from the package root; both are exported only from + `@objectstack/spec/shared`. Fixed the specifier. + + No behavior change — TSDoc examples only. +- 3a04b01: docs(spec): state that identifier length ceilings are storage-owned (#12144) + + The shared identifier schemas (`SystemIdentifierSchema`, + `SnakeCaseIdentifierSchema`, `EventNameSchema`) declare `.min()` plus a + grammar and no `.max()`. That absence is now a documented contract decision + rather than an accident: the enforced ceiling on an identifier is the + `maxLength` of the column that stores it (refused at the write seam by + ObjectQL's record validator), and the storing columns disagree — 100 for + `sys_permission_set.name` / `sys_position.name` / `sys_capability.name`, + 255 for `sys_metadata.name` — so no single shared `.max()` can equal every + consumer's enforced ceiling. No accepted value changes. + + A pin test in `@objectstack/plugin-security` + (`identifier-storage-ceiling-pin.test.ts`) links the spec schemas to the + storage columns that bound them, reading the widths off the registration + surface (the PR #12143 idiom), so the two cannot drift silently: a `.max()` + landing below a storing column's width, or a column width change, turns a + named test red with re-derivation instructions. +- b9e9227: Declare `mappingName` on `ImportRequestSchema` (and therefore on the aliased + `CreateImportJobRequestSchema`). Both import routes already accepted it on the + wire — `prepareImportRequest` resolves the named `mapping` artifact and refuses + `mappingName` plus an inline `mapping` with `400 CONFLICTING_MAPPING` — but the + published contract could not express it, so the typed SDK call + `client.data.import(object, { mappingName: '…' })` was a TS2353 compile error + (#10330). The key is now declared with the same mutual exclusion as a schema + `.refine()`, so a conflicting pair is rejected at authoring time as well as by + the route. Additive only: the schema is a plain `z.object` that strips unknown + keys, so no existing caller changes behavior. +- d395692: Withdraw the never-honored `IntrospectedTable.indexes` promise and widen two + introspection declarations to the measured emitted types (#11122, maintainer + ruling 2026-08-23, option B — 「其他同意你的意见」). + + The spec's introspection contract (`schema-diff-service.ts`) declared + `indexes: IntrospectedIndex[]` as REQUIRED, yet no producer has ever emitted + it — a consumer typed against the promise read `undefined` with no compiler + complaint. It also declared `defaultValue?: string` while the in-tree SQL + driver passes `knex.columnInfo().defaultValue` through raw (measured on live + SQLite: `null` for a column with no default, dialect-quoted strings such as + `'abc'` otherwise; other producers report native values such as `true`). + + - `IntrospectedTable.indexes` is now **optional**, and absence is meaningful: + an absent key means the producer did not read indexes; an empty array is a + positive claim the table HAS none. Producers that did not look must omit + the key rather than emit `[]`. Wiring the index read into + `introspectSchema()` is explicitly NOT part of this change. + - `IntrospectedColumn.defaultValue` is now `unknown` — consumers narrow + before use instead of trusting a string promise no producer kept. + - The SQL layer's extra `maxLength` fact (driver-sql / objectql + `IntrospectedColumn`, driver-sql `PhysicalColumn`) widens from `number` to + `number | string` — SQLite reports the string `"255"` where other dialects + report a number. + + With the spec now telling the truth, the deliberate `Omit` workarounds in + `@objectstack/driver-sql` and `@objectstack/objectql` (which carved + `defaultValue` and `indexes` out of the spec types to keep the divergence + visible) are retired: both packages' introspection types now extend the spec + contract directly. + + Consumers that read `table.indexes` must guard for absence (none exist + in-tree — the requirement was never honored, so today's readers would have + crashed on `undefined` anyway); consumers of `defaultValue` must narrow from + `unknown` before string operations. +- 5894d30: Surface the email-invite entry on the organization record page's default + Members tab, and stop it rendering as a twin of "Add Member" + + The in-shell Team surface (`sys_organization` record page, ADR-0081) opens on + tab-0 **Members**, whose related-list toolbar carried exactly one action — + `add_member`, which attaches an **already-registered** user by id. The + email-invite entry, `invite_user`, was declared only on `sys_invitation` and + `sys_user`, so it appeared only on tab-1 Invitations. An admin looking to + "invite a teammate by email" landed on Members, found no invite affordance and + concluded the product had none. The delivery half worked the whole time + (`sendInvitationEmail`, template `auth.invitation`) — only the door was in + another room. + + `sys_member` now declares its own `invite_user` on `list_toolbar`, ahead of + `add_member`: same endpoint (`/api/v1/auth/organization/invite-member`), same + email + role inputs, and the same `requiresFeature: 'organization'` capability + gate as the other two mirrors. Declaration order is render order in the + related-list toolbar bridge, so the invite button sits left of the attach one. + + **The `email` param names `objectOverride: 'sys_invitation'`, and must.** + `sys_member` has no `email` field, so a verbatim copy of the `sys_invitation` + declaration would leave the param unresolvable — the renderer answers that with + a `type: 'text'` fallback labelled by the raw field name, which still submits + and still looks fine (the ADR-0078 valid-but-inert class). `role` needs no + override: `sys_member` declares it, from the same + `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` constant `sys_invitation` reads. A test now + holds this over **all three** mirrors, so the next copy of any action cannot + reintroduce the shape. + + `add_member` keeps its behaviour and its label and is differentiated only in + chrome — `variant: 'secondary'` and `icon: 'link-2'` (the "attach an existing + record" icon `sys_account`'s `link_social` already uses) — so the two buttons + no longer render as identical primary `user-plus` twins. Both halves are + honoured by the renderer: it draws `primary` filled and every other variant + outlined. + + The `@objectstack/spec` half is one line of registry bookkeeping: + `PUBLIC_AUTH_FEATURES.organization.gatedInputs` books the new gated action, as + it already books the other twelve. No schema, export or authorable key changes. +- 6acb11a: **Liveness-ledger verdict:** `view.list.map` (`ListViewSchema`'s view-level `map` block) moves `planned` → `live` (#11442). + + objectui#5908 landed the missing half of the forward: `packages/plugin-list/src/ListView.tsx`'s `resolveListMapConfig` now merges the view-level `map` block over the legacy `options.map` bag before `case 'map'` builds the `object-map` schema, and the same merged config also feeds the visualization-switcher's capability gate — so a view that binds its coordinates only in the spec's `map` block both renders on the map surface and is no longer filtered out of `allowedVisualizations`. Nothing about the schema shape or the authoring-time validation changed; only the runtime consequence of authoring the key does. + + Pinned by objectui `packages/plugin-list/src/__tests__/ListView.mapViewLevelConfig.test.tsx` (the forward, against a spy) and `packages/plugin-map/src/ObjectMap.listViewMapConfigReach.test.tsx` (the end-to-end read through a real `ObjectMap` — markers, titles, camera). Re-measured against objectui `origin/main@08ca73f8` (squash commit `e2e8e68` for #5908 confirmed an ancestor). +- 33c5fd3: liveness ledger: repoint the `action` `type` / `body` / `method` anchors at their real consumers + + All three cited `packages/runtime/src/http-dispatcher.ts`, and none of them is read + there. The actions domain was extracted out of that file — it now only delegates + (`handleActions` → `handleActionsRequest` at :1969-1970) — and the reads live in + `action-execution.ts`, `domains/actions.ts`, `sandbox/body-runner.ts` and, for the + client-dispatched `method`, in the renderer repo. + + This is the residue of the same extraction that rotted `action.target` and + `action.requiredPermissions`, but it survived the sweep that repaired those, and the + reason is the interesting part. The key-mention check added with that sweep asks + whether the cited file names the key at all — which caught `target` and + `requiredPermissions` because `http-dispatcher.ts` contains **0** occurrences of + either. It cannot catch these three, because they are common English and HTTP words + that the file is full of for unrelated reasons: + + - `type` — 9 occurrences, four of them the TypeScript `import type` keyword, the rest + other domains' data (`error.type`, `details.type`, a field-type→JSON-Schema mapper). + - `body` — 68 occurrences: the inbound HTTP request body threaded through every + domain delegate, plus the file's own "body extracted to ./domains/…" comments, + where `body` means a *function* body. + - `method` — 41 occurrences: the inbound HTTP verb (15 `method: string` parameter + declarations, route matching such as `method === 'GET'`) and the ordinary + object-oriented sense in prose. + + So the word-bounded check anchors on the coincidence and passes. That is the designed, + honest limit of the signal rather than a defect in it — the census that shipped it said + so — and it means this class is invisible to tooling and only a hand call-graph read + can settle it. Each repointed entry now records which spelling misled the gate, so the + next reader does not have to re-derive it. + + `method` additionally CHANGES REALM, joining its siblings `bodyShape` and `bodyExtra`: + `type: 'api'` actions are client-dispatched by design, so the server never read the + verb. Its one in-repo appearance is a diagnostic that interpolates the verb into the + refusal explaining the server does *not* dispatch it — evidence of non-consumption, + and deliberately not cited as a consumer. + + No verdict was re-graded: all three were `live` and remain `live`, with the consumer + proven rather than asserted. Citation repair only. +- 20b0fdb: liveness gate: a citation must name the property it is evidence for + + Two checks already bounded a `live` entry's citation, and both bounded it from the + outside — the cited file must exist (#5623), and a cited line must be inside it + (#11210). Between them sat a gap neither could see: a consumer that moves *within* + the file it is cited to, or a citation written with no line at all, leaves the file + present and every named line in range. The pointer is wrong and the gate is green. + + Measured over the whole ledger before anything was switched on: 403 (entry, cited + local file) pairs, **11** where the cited file never mentions the property's own key, + and **7 of those 11 were real rot** — repaired here: + + - `permission.objects.allowExport` — `annotateEffectiveApiOperations` moved to + `current-user-endpoints.ts`; the same repos-internal movement that had already + rotted `permission.systemPermissions` and `permission.tabPermissions`. + - `object.tenancy.organizationField` — the resolver was promoted into + `@objectstack/metadata-core`; the cited `audit-writers.ts` says so itself, in the + re-export comment left behind. + - `action.target` / `action.requiredPermissions` — the actions domain was extracted + out of `http-dispatcher.ts`, which retains 0 occurrences of either key. + - `action.bodyShape` / `action.bodyExtra` — client-dispatched keys whose only + consumer has always been the renderer; the in-repo citation could not have been + right at any point. Now attributed to `objectui` with the commit pinned. + - `field.requiredWhen` — cited its *sibling* `record-validator.ts`, which enforces the + static `required` contract; the CEL predicate is evaluated one file over in + `rule-validator.ts`. Both files exist, so nothing could see it. + + The remaining 4 are the `camelCase` → `snake_case` convention this platform mandates + (Prime Directive #3): a property persisted as a column is read as `body_html`, + `managed_by`, never as the authoring key. Three are handled **structurally** — the + matcher folds the key across the naming convention rather than exempting them — and + the match is word-bounded so a prefix cannot satisfy the key (`required` is not + `requiredWhen`, which is precisely how that rot stayed hidden). The one residual is a + compound *child*-key remap (`fromOverride.address` → `from_address`) that no fold of + the parent key reaches, and it is a single explicit row in the shrink-only + `scripts/liveness/key-mention.baseline.json`, which fails in **both** directions: a + row whose pair later anchors must be deleted. + + So the check ships red-capable at zero unexplained hits, which is the whole reason the + census came first — `evidence.mts`'s header records what the alternative costs, when + 48 of 227 entries were flagged, every one was a false positive, and the single genuine + rot inside that list sat unread. + + The check asks `evidence` only, never `producer`: a producer cites *who supplies a + second input* (#4837), which is by definition a call site and need not name the key at + all. +- 905019b: liveness gate: bound an evidence citation's LINE, not just its file + + `check:liveness` resolved a `path/to/file.ts:NNN` pointer with `existsSync` on the + path alone — the parser did not even retain the `:NNN` — so a consumer that moved + out of a file which still exists kept a passing pointer, was counted under the word + "resolved", and left its ledger entry reading as freshly verified. A citation that is + dead but precise-looking is worse than a missing one: it survives review, and the next + agent re-verifying the entry follows it, finds nothing, and rebuilds the call graph + from scratch. + + Citations are now bounded by the cited file's length, for `evidence` and `producer` + alike (they already share one resolver). A range `:12-34` is bounded by its END. Every + citation in a `+`-joined multi-consumer entry is bounded, not just the first. + Cross-repo attributions (`objectui: …`, `cloud: …`) are still counted and never + resolved. The run prints how many citations it checked beside how many are in range, + so a parser that degraded to extracting nothing cannot read as a pass. + + Two shipped instances, both repaired here and both real: + `permission.tabPermissions` cited `hono-plugin.ts:1200` in a 717-line file that no + longer mentions the property (all three of its pointers were dead — one past EOF, two + within bounds), and `mapping.fieldMapping` cited a range ending three lines past the + end of `import-mapping.ts`. +- 6269a55: Gate the shipped `llms.txt` against the real package, and repair the claims that had rotted. + + `packages/spec/llms.txt` ships in the npm tarball as context for AI consumers and is + hand-kept with no generator, so nothing ever re-derived what it asserts. It had drifted + badly: eleven advertised symbols existed in no entry point (`IUIService` — removed in + v11 — plus `ThemeSchema`, `IdentitySchema`, `PolicySchema`, `ContractSchema`, + `EndpointSchema`, `RAGPipelineSchema`, `MCPSchema`, `FilterSchema`, `AnalyticsSchema`, + `FormSchema`), two advertised packages did not exist (`@objectstack/nextjs`, + `@objectstack/nestjs`), the schema-inventory heading disagreed with the sum of its own + table (171 vs 170) and with the tree (207), and the package heading claimed 19 against a + real 68. An agent reading the file wrote imports that do not resolve. + + New gate `check:llms-txt` re-derives every checkable claim on every PR: advertised + symbols against the checked-in `api-surface/` shards, `@objectstack/spec/x` subpaths + against the manifest `exports`, the per-domain schema counts against + `src//**/*.zod.ts`, and the package table against the workspace. Symbol claims + are resolved at the strictness their position earns — namespace bullets and fenced + imports name an entry point and must resolve from it, while the architecture overview + resolves against the union. Prose, code-fence bodies and `N+` lower-bound figures are + out of population and the script header says why. + + There is deliberately no `gen:llms-txt`: the numbers are not the claim, the prose beside + them is, and restamping a count without re-reading its row would turn a loud staleness + into a silent lie. +- 42a117b: Document why `NoSQLIndexSchema.unique` stays a bare boolean instead of the ADR-0120 unique-scope vocabulary carried by `FieldSchema.unique` and `IndexSchema.unique`: the schema is a raw NoSQL driver-configuration descriptor below the tenancy seam — nothing materializes indexes from it, and the one NoSQL driver that creates indexes (driver-mongodb) consumes the object-level `indexes[]` surface (which already carries the vocabulary) and is explicitly single-tenant (#3724) — so a scope word here would be declarable-but-inert vocabulary (ADR-0078). The `describe()` and docblock now state the deliberate omission and the condition under which `UniqueScopeSchema` should be adopted, so the asymmetry with the other two `unique` surfaces is not mistaken for drift (#11215). +- 4297fe7: `NotifyConfigSchema.template` now states the locale semantics the delivery path actually enforces — the deployment default, not a per-recipient locale + + The `notify` node's localizable path (`template` → a `sys_email_template` bundle) + was documented in `packages/spec/src/automation/io-node-config.zod.ts` as + resolving `(name, recipient locale)` **per recipient** at delivery time, and the + `template` `.describe()` added that it "renders subject/body per recipient". + Read plainly — and it is the text a consuming app's author reads — that says the + recipient's own language selects the template row. + + It does not, and deliberately does not. The delivery path + (`service-messaging/src/email-channel.ts`) has said so honestly at its own + `getDefaultTemplateLocale` all along: the platform has no per-user locale + (`sys_user` carries no locale column), and request-scoped locale + (`Accept-Language` → `ExecutionContext.requestLocale`) does not exist at async + delivery time, so "recipient locale" resolves to the **deployment default**, + `II18nService.getDefaultLocale()` — the same ruled source the auth emails use. + The one lever is `payload.locale`, and that is interpolated **once, before + fan-out**, so it is a single value for the whole notification at all three + `channel.send` call sites (`fanOut`, the outbox single-delivery path, and + `processDigestGroup`). + + The gap mattered because the wording licensed exactly one conclusion — "convert + the nodes and non-English users get non-English notifications" — which is false, + and acting on it is a **net regression**: `TEMPLATE_*` failures classify + `permanent` and dead-letter, and the inbox channel starts requiring an email + service with `renderTemplate()` where inline text needed none. So the drift was + not a cosmetic imprecision; it was an instruction to make a change that loses + deliveries. + + Per the maintainer ruling of **2026-08-13**, the behaviour is the settled side — + a per-user locale is deferred until measured pull — so the prose is the side that + moves. All five "recipient locale" sites in the file now name the resolved value: + the schema doc block, the `template` field's JSDoc and `.describe()`, and both + `superRefine` refusal messages. Each says the locale is `payload.locale` if the + producer set one, else the deployment default, and that it is **one value per + notification, not one per recipient**, with the 2026-08-13 deferral dated in + place so the limitation reads as a decision with provenance rather than a + permanent property of the design — a per-user locale layers in as an override at + that same seam when it lands. + + Text only. No schema accepts or refuses anything it did not before, no delivery + behaviour moves, and no wire value changes — `packages/spec` publishes + `src/**/*.zod.ts` and the generated reference page, so the corrected wording + ships to consumers reading either. The pins in + `io-node-config.test.ts` that asserted the old `/recipient locale/` string now + assert the qualification itself, and refuse a bare "recipient locale", so a + future edit cannot quietly restore the promise. +- 5691b07: Refresh the `tenancy.organizationField` scope-pin annotation and its + `.describe()` in `packages/spec/src/data/object.zod.ts` — prose accuracy + only, no schema or behaviour change. + + The #8778 scope-pin annotation (landed by #10999) said consumer 1 (audit + stamping) was "as of this annotation, still the only one wired up" and that + consumers 2 and 3 (the approval-row writer and the automation-run recorder) + were "sanctioned but not yet implemented". #10101's PR #11311 (merged + 2026-08-23) landed both: `resolveRecordOrganizationField` was promoted to a + shared resolver in `@objectstack/metadata-core` (plugin-audit re-exports it + from its original path) and all three sanctioned platform-row writers now + call it. The annotation and the `.describe()` string are updated to name all + three live consumers; the pin's load-bearing property — "The ruling sanctions + exactly THREE consumers of this key, and no others", a fourth consumer needs + its own ruling — is unchanged and still stated verbatim. + + No accept/reject behaviour change, no schema shape change, no new keys. +- fb5fbb8: Hook body sandbox context now carries the per-row dispatch signal and the D2 options projection (#11552). A shipped (L2 sandboxed) hook body observes `ctx.dispatch` — a frozen `{ mode: 'record' | 'per-row', index }` copy of the engine's #6966 dispatch marker (`scope` deliberately does not cross: a JSON copy cannot keep its shared-identity contract) — and `ctx.input.options` — a frozen, non-enumerable `{ multi?, where? }` projection of the caller's bag, the two members ADR-0058 Addendum II D2 declares visible to the `before*` phase. This closes the declared≠observable gap that made D3's routes 1 (batch-scoped throw) and 2 (`ctx.api` per row) inexpressible from a body-only hook: a guard written `ctx.dispatch?.mode === 'per-row'` previously evaluated `false` on every production dispatch. `Object.keys(ctx.input)` still enumerates payload fields only, `ctx.input.id` stays absent (read `ctx.previous.id`), and the post-run input write-back cannot carry the grafted keys back to the engine. The spec change is documentation-only: `HookContextSchema`'s `input`/`dispatch` TSDoc now states the body-face visibility. +- d7b3963: Export the kernel platform-admin capability declaration from `@objectstack/spec` (`ADMIN_FULL_ACCESS_CAPABILITIES`) and import it in plugin-security's `admin_full_access` permission-set declaration, so exactly one copy of the capability list exists (#11663 Choice 6A, leg L1). Behaviour-neutral: the declared capability set is byte-for-byte unchanged, pinned by test. +- 033a34c: **Fix:** `sys_user`'s **`set_user_role`** action ("Set Platform Role") is retired — removed from the object's declared actions, not re-implemented (#9968). + + The action's only effect was `POST /api/v1/auth/admin/set-role`, which better-auth's `admin` plugin lowers to `internalAdapter.updateUser(userId, { role })` — a gated, UI-driven writer for the legacy `sys_user.role` scalar that ADR-0068 D2 stopped synthesizing. Platform-admin membership is granted through `sys_user_permission_set` / `admin_full_access`; a working "Set Platform Role" button was a supported, one-user-at-a-time channel for resurrecting the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3). + + **What an operator will now observe.** The "Set Platform Role" button is gone from the Users list row menu and the user detail header. It was already dead for every platform admin before this change — better-auth's vendor `adminMiddleware` gates on the same retired scalar, so the button 403'd with `YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE` for platform admins and plain members alike. Removing it removes a byte-identical-refusal dead affordance, not a working capability. + + **Unchanged.** The vendor's `POST /api/v1/auth/admin/set-role` route itself stays mounted and vendor-gated exactly as before — this change touches only the `sys_user` console action pointing at it. Every other `sys_user` admin action (`ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`) is unaffected. + + `@objectstack/spec`'s `PUBLIC_AUTH_FEATURES.admin.gatedInputs` registry drops the corresponding `sys_user.actions.set_user_role` entry in the same change (`packages/spec/src/kernel/public-auth-features.ts`) — internal completeness-guard bookkeeping only, no public export shape change. +- 146f448: fix(spec): the bundled JSON Schema's `x-schema-count` counts the definitions it carries (#12588) + + `json-schema/objectstack.json` ships in the tarball (`json-schema` is in the + package's `files`), and `content/docs/deployment/troubleshooting.mdx` publishes + what the field means: "its `x-schema-count` field reports the total number of + definitions". It did not. The generator took the number from `count` — a + counter incremented once per emitted schema — while the bundle's `$defs` is + assembled from a map keyed by `/`. Every def key written more + than once therefore widened a gap nothing reconciled: the published bundle + declared **1596** definitions while carrying **1585**. + + `$defs` is now assembled before the envelope and the field is taken from its + size, so the artifact describes itself. The per-schema files on disk already + agreed with `$defs` (1585) — the same key collapses the file writes — so this + brings the one disagreeing number into line with both of the others, and the + docs sentence is true as written without changing it. + + **The collapsed emits are now named rather than implied.** The 11 def keys + written twice are all **benign self-aliases** — `export const X = XSchema` + spelled as `Object.assign(XSchema, …)`, one schema object reached by two export + names, so the second write cannot change what is published. Eight in `api` + (`ApiEndpoint`, `RestApiConfig`, `RestServerConfig`, `ApiDocumentationConfig`, + `ApiTestCollection`, `OpenApiSpec`, `RestApiPluginConfig`, + `RestApiRouteRegistration`) and three in `system` (`MiddlewareConfig`, + `QueueConfig`, `Task`). No schema is being silently dropped: the existing + `findDefKeyCollisions` guard exits the build on any def key claimed by two + *different* schemas, so a build that produces a bundle at all has only exempt + ones — and `gen:schema` now prints that population instead of leaving it + visible only as a subtraction between two summary lines. + + No schema content changes; only the bundle's self-description and the + generator's console output. +- 71f9cd1: docs(spec): strip the internal issue-id references that were projected into the published skill catalog + + The 2026-08-23 ruling stripped internal `#NNNN` citations from the published + skill corpus, but 14 of them were not authored in `skills/**` at all — they were + projected there from `.describe()` / TSDoc text in `packages/spec/src/**` by + `gen:skill-refs` and `gen:react-blocks`, so a hand-edit of the corpus could not + reach them and a regeneration would have put them straight back. + + Six source sites are rewritten to say the same thing without the citation, and + the artifacts are regenerated: the module summaries of `data/driver/common`, + `data/driver/config-registry`, `data/driver/turso`, `shared/retry-policy` and + `system/translation`, plus the `ListView.objectName` / `ListView.viewType` + deprecation notes and the `` summary in `ui/react-blocks`. The teaching in + each is kept, per the standing ruling of 2026-08-12, verbatim and untranslated: + 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」. + + Customer-facing text changes in three places from the one source edit: the + published catalog (`skills/*/references/_index.md`, + `skills/objectstack-ui/references/react-blocks.md` and its sibling + `contracts/react-blocks.contract.json`), and the docs site + (`content/docs/references/data/driver-common.mdx`, `driver-turso.mdx`). No + schema shape, no `.describe()` used for validation, and no accept/reject + behaviour changes — the edits are comment and documentation text only. + + The doc-authoring gate's path exemption for the generated artifacts is removed + in the same change: it existed only because those files still carried projected + ids, and an exemption over a surface that no longer needs one is where the next + regeneration would smuggle one back in. +- ee17d86: `check:skill-examples`: make the marked-block EXTRACTION loop fence-aware, not just the orphan scan + + The orphan-marker scan learned to ignore an `os:check` marker shown as example text inside a + wrapping fence, so this gate's own convention could be documented in the very roots it governs. + The extraction loop was deliberately left out of that fix, and kept recognising a bare ` ```ts ` / + ` ```tsx ` / ` ```typescript ` fence-open line wherever it appeared, with no notion of sitting + inside another fence. A marker *alone* nested in an illustration was therefore handled correctly, + while a **fully worked** one — the marker AND a real ts fence, both written as example text inside + a wrapper — was extracted and handed to `tsc` as a genuine example: compiling by luck, or failing + the whole gate with a diagnostic pointing at documentation prose. + + Both loops now read one `fenceOwners()` walk. It records, per line, which top-level fence owns it, + so `owners[i] >= 0` answers the orphan scan's question ("is this marker inside a fence?") and + `owners[i] === i` answers extraction's ("does this ts fence open at top level?"). Because the + extraction loop's fence-open pattern is a strict subset of the CommonMark one the walk uses, a + genuine top-level block always owns itself and the new guard cannot suppress one. + + No occurrence in the corpus tripped this, so the counts are unchanged either side of the fix (257 + marked examples across 99 files, three surfaces). The self-test is where the defect is measurable: + a nested worked illustration whose payloads are deliberately uncompilable now extracts nothing, + while the identical payloads with the wrapper removed all extract — and the same pair is pinned + inside a JSDoc-gutter-wrapped docblock, where fence ownership has to be judged on the + gutter-stripped lines. +- cdbd920: fix(spec): keep machine constants off the skill-reference `Exports:` fallback + + When a `.zod.ts` has no module doc block, `build-skill-references.ts` falls back + to listing its exports. That line is TRUE — an accurate list of what the module + exports, which is why #12094 kept the fallback rather than refusing. What was + wrong is the RANKING: the list was whichever five exports happened to be + DECLARED FIRST, and the extraction had no notion of authorable surface, so any + `export const` qualified — including constants whose own names say they are not + for authoring. + + Three of the eleven modules that reach this fallback declare their machine + constants near the top, so three published rows headlined them: + + - `automation/approval.zod.ts` named `DEPRECATED_APPROVER_TYPES`, + `NON_AUTHORABLE_APPROVER_TYPES`, `ORG_MEMBERSHIP_LEVELS` and + `APPROVER_EXPRESSION_ROOTS` — four of its five slots + - `kernel/plugin.zod.ts` named `CORE_PLUGIN_TYPES`, `CONSUMER_INSTALLABLE_TYPES` + - `system/translation.zod.ts` named `LEGACY_OBJECT_FIRST_KEYS` + + `skills/**` is loaded whole into a customer agent's context window and its job + is to teach that agent what it may author, so a row headlining + `DEPRECATED_APPROVER_TYPES` and `NON_AUTHORABLE_APPROVER_TYPES` pointed an + authoring agent at exactly the vocabulary it must not use, with nothing on the + line marking them as such. No gate could see it: `check:skill-refs` compares the + artifact against the generator, and the generator ranked faithfully. + + `SCREAMING_SNAKE` exports are now dropped and source order is kept for what + remains, with the cap of five applied AFTER filtering so the authorable names + waiting behind the constants are promoted rather than the row merely shortened. + A module whose entire export surface is machine constants falls through to no + description at all rather than printing a bare `Exports:`. + + Sorting `*Schema` exports first was considered and NOT taken: on the very row + that motivated this it demotes `ApproverType` — the approver-type enum an author + actually writes — below four schema objects, which is worse by this surface's + own standard. The rule moves to `scripts/lib/export-list.ts` so it can be pinned + without running the generator, and `scripts/export-list.test.ts` enforces it + both as unit cases and as a corpus gate over the checked-in artifacts. +- 18c432e: fix(spec): publish each skill reference's own module doc block, not the first doc block in the file + + `build-skill-references.ts` derived every `_index.md` pointer description from + the first doc block anywhere in the source file. That is a rule about ORDERING, + not about descriptions: whichever declaration happened to sit nearest the top of + a `.zod.ts` donated its comment to a customer-facing page, and moving a helper + up a file silently rewrote published text. + + This is the defect the docs-site generator fixed by converging on + `findModuleDocBlock()` — the block must start at column 0, precede the first + declaration, and document no symbol. The skill-references generator was never + converted, so the two generators disagreed about the same sources. It now + imports the same selector rather than restating the rule, and `skills/**` is + loaded whole into customer agent context windows, so it was paying the higher + price for the same defect. + + Thirteen pointer rows across seven skills change. The live victim named on the + issue is `system/translation.zod.ts`, whose `objectstack-i18n` entry opened with + "Shared history sentence for every shape in this file." — the comment on a + private `TRANSLATION_HISTORY` string constant, meaningless to the reader and not + a description of the Translation protocol. Twelve rows in that class now fall + through to the existing `Exports: …` line, and one gains a real module + description (`shared/metadata-types.zod.ts`), which also brings it into + agreement with the docs page for the same file. + + Falling back rather than refusing is deliberate: an export list states a true + fact about the file, where the wrong block asserted a false one about its + subject. Whether a `.zod.ts` on this surface should be required to carry a + module doc block at all is a separate authoring question, left open here. +- 3c418c4: **Docs:** `skill.zod.ts`'s two `@example` blocks stop handing the author a retired key that throws on parse (#11026). + + Both TSDoc examples in `packages/spec/src/ai/skill.zod.ts` — the one over `SkillSchema` and the one over `defineSkill` — passed `triggerPhrases`, removed in `@objectstack/spec` 17.0.0 (#3896 audit close-out) and carried as a `retiredKey()` tombstone ~120 lines below the first of them. `defineSkill` calls `SkillSchema.parse()`, so both documented blocks **threw** when run: `invalid_type` at path `triggerPhrases`, expected `never`. A reader copying either block got a refusal on their first move. + + The blocks now demonstrate the tombstone's own prescription instead of contradicting it. That prescription is a **split**, not a rename — routing intent belongs in `triggerConditions` (an AND of context field/operator/value), natural-language intent in `description` / `instructions`, the strings actually put in front of the model — and the `defineSkill` block shows both halves with each named. `tools` is required with no default, so both blocks carry it. Each block also opens with its own `import { defineSkill } from '@objectstack/spec';`, matching the convention the marked SDK examples in `packages/client-react/src` already use, so the block is self-contained as a consumer would resolve it. + + The tombstone and every line of its guidance prose are **unchanged**, verbatim — they were always correct, and they are what the repaired examples now agree with. + + Prose only: no schema shape, no `.describe()` text, no runtime behaviour, no authorable-surface movement, and `check:generated` moves nothing (the generated reference page `content/docs/references/ai/skill.mdx` renders the tombstoned row as `[REMOVED]` and carries neither example). It is graded rather than skipped because the text ships to consumers: the rewritten blocks are present in the built `dist/skill.zod-*.d.ts`, which is what an editor surfaces on hover, and `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts` so the docblock also travels in the npm tarball as source. +- 424f73c: Route the stored-envelope refusal to a command that exists — `os package publish`, not the retired `objectstack publish` (#12223) + + An author who hand-writes one of the seven `STORED_ENVELOPE_KEYS` onto an `api` + declaration is refused, and the refusal tells them where publication state actually + comes from. It named a command that resolves to nothing: + + ```text + before: Remove it — publication state is managed by `objectstack publish`, not authored. + after: Remove it — publication state is managed by `os package publish`, not authored. + ``` + + `os publish` was the legacy direct-to-environment command, retired with the path that + wrote `sys_environment_revision`. Re-measured on this tree against the **built oclif + `Config`** rather than against docs — loading the CLI's plugin and reading the command + table oclif derives from `dist/commands/**`: **61** ids, of which the only two containing + `publish` are `package publish` and `plugin publish`. There is no bare `publish` id and no + `publish` topic, so the old spelling exits as an unknown command. The message's own + neighbouring sentence already names `publishPackage` as the writer, and + `packages/cli/src/commands/package/publish.ts` is the command that runs it. + + This is the shape #12177 deliberately left alone elsewhere inverted: those sentences are + *about* the removal and are correct as history, while this one is **present tense and + prescriptive** — text an AI author obeys at the moment its write is refused. + + Text only. No accept/reject behaviour changes: the same seven keys are refused on the same + declarations, with the same `unrecognized_keys` upgrade path; only the sentence an author + reads is corrected. The same stale spelling is fixed in the `publisher` doc comment of + `packages/spec/src/cloud/package.zod.ts`, which ships to consumers in the package's type + declarations. +- a8d6b1d: Strip internal tracker ids from the refusal messages an author actually reads (#12124) + + Fifteen zod refusal messages across nine `packages/spec/src` files ended a sentence with + an internal issue id. Those strings are printed **at** the author, verbatim, the moment + their metadata is refused — by `os validate`, by a publish gate, by a parse — and the + reader has no tracker to open. A `#NNNN` there is a citation-shaped token that resolves to + nothing, in the one place the sentence most needs to be actionable. + + ```text + before: A field condition's keys are field names, never $-prefixed operators (#7711). + after: A field condition's keys are field names, never $-prefixed operators. + + before: ... refused at authoring time because the query path refuses it too + (400 INVALID_FILTER, #5869). + after: ... refused at authoring time because the query path refuses it too + (400 INVALID_FILTER). + ``` + + Where a customer-resolvable anchor already carried the meaning it was kept and the id + dropped beside it: the second example above keeps `400 INVALID_FILTER`, which is the token + an author can actually match their query-path error against. Where the reference is + load-bearing for an *internal* reader only, it moved to an adjacent `//` comment (four + sites: the endpoint publish gate's two `#5040` section pointers, the summary-field rule's + founding incident, and the interim renderer precedence behind the doubled-redirect + refusal). Elsewhere it is simply gone — git history keeps the anchor. + + Text only. **No accept/reject behaviour changes**: the same inputs are refused on the same + schemas with the same issue `code`, `path` and error shape; only the sentence changes. + Test twins that pinned the old wording now pin the new text plus a negative assertion that + the message carries no issue id at all. + + The convention is held mechanically from here — `check:doc-authoring` gained a third rule + that parses `packages/spec/src` and reds on an id in any refusal-message string. It parses + rather than scanning lines because refusal prose here is written as multi-line string + concatenation: a single-line `message:.*#[0-9]{3,5}` grep sees 1 of the 16 literals. +- e4a7695: docs(spec): stop recommending `contributes.routes` for code-handler endpoints — redirect the author-facing materials to the imperative `http.server` mount (#10726) + + `contributes.routes` (`packages/spec/src/kernel/manifest.zod.ts`) has **zero readers** + monorepo-wide: the only non-test read of `manifest.contributes` anywhere reads `kinds`, not + `routes` (`packages/objectql/src/engine.ts:4499`). An author following the shipped guidance + writes a `contributes.routes` entry, gets a clean parse, and serves nothing — ADR-0049's + silent no-op with a published recommendation attached. + + Per the maintainer ruling (2026-08-22, Option B), the four author-facing materials that + recommended the key are corrected **now**, ahead of and independent of the key's removal: + + - `skills/objectstack-api/SKILL.md` — the `apis:` decision table's second row now names the + imperative `http.server` mount and states explicitly that `contributes.routes` parses and + serves nothing. + - `packages/spec/src/api/dispatcher.zod.ts` — the HttpDispatcher protocol doc no longer + claims it "supports dynamic route registration from plugins via contributes.routes". + - `docs/adr/0088-metadata-kind-admission-and-retirement.md` — the `router` retirement row no + longer credits `contributes.routes` as a delivered form. The `router` KIND's retirement is + unaffected: its delivered forms are the imperative mount and, since #5040, declarative + `apis:`. + - `packages/spec/src/ui/app.zod.ts` — the `App.apis` removal message no longer sends + migrators to `contributes.routes`. + + The replacement recommendation was verified live on `main` before it was written, so this is + not a redirect to a second dead form: `http.server` is registered by + `packages/plugins/plugin-hono-server/src/hono-plugin.ts:271` (`providesServices` at :228) and + mounted by real in-tree consumers — `examples/app-showcase/src/system/server/recalc-endpoint.ts` + resolves it on `kernel:ready` and mounts `POST /api/v1/showcase/recalc`, and + `plugin-approvals`, `plugin-sharing`, `cloud-connection` and the CLI's `serve` all resolve the + same service. + + Schema-only change to prose: no key is added, removed or re-typed here. The + `contributes.routes` tombstone itself is #10724's, which is blocked on the `cloud` census + (#10812) — this changeset carries only the doc corrections the ruling ordered not to wait. + The two `content/docs/references/**` pages are the regenerated projection of the two `.zod.ts` + edits (`pnpm --filter @objectstack/spec gen:docs`), not hand edits. +- 14cfc00: Type-check the TSDoc code examples in `packages/spec/src` — the ADR-0033 authoring channel + + `check:skill-examples` now scans a third surface: `packages/spec/src/**` TSDoc blocks, + alongside `skills/` + `content/docs` and the client SDK sources. A schema's `@example` + is what an AI author copies, and it sits inches from the tombstone written for that same + reader — but nothing compiled it, so an example could name a retired key, a renamed + export or a tightened union and stay green indefinitely. (`check:doc-formula-expressions` + walks the same blocks but judges *formula expressions*, never TypeScript.) + + This is a new `SURFACES` entry, not a second extractor: the existing marker/tsc pipeline + was already surface-parameterised. Compilation stays **opt-in** via the `os:check` + marker, which matters more on this root than anywhere else — of its 146 fenced ts blocks, + 128 carry no imports of their own and three more are ellipsis-placeholder prose + (`defineStack({ ... })`) that is correct as documentation and can never compile. Six + self-contained blocks are marked and now compile against the built declarations. + + The marker is an inert HTML comment, as on the other surfaces, and is stripped from + generated reference pages rather than published to them. +- a5ef1d8: `isCompatible` now reports a TEXT column as exactly compatible with `signature` and `qrcode` fields (#12117). The SQL ⇆ field-type matrix in `type-compat.ts` had drifted from the platform's own emitted physical shape: after #11875 (maintainer ruling 2026-08-25) `sql-driver.ts` emits both types in its TEXT family alongside `richtext` / `code`, and their stored value is a string — routinely a data-URI — per `STRING_VALUE_TYPES` and `valueSchemaFor`. The `text` row's exact set never listed them, so `IExternalDatasourceService.validateObject` diffing a table the driver itself had created reported a `type_mismatch` against the very field type that wrote the column, and `generateObjectDraft` would not offer either type for a TEXT column. + + `binary` is unchanged, and `signature` stays in its exact set. The matrix is many-to-many by design — each row answers "which field types can THIS column serve?" and `isCompatible` is keyed on the column, so `text` is already exact under `text` / `uuid` / `enum` and `tags` / `multiselect` under `json` / `array`; a type joining one row does not leave another. Measured across the monorepo when the `text` row moved: no path stores a `signature` field value as a binary payload (the driver's only `binary()` column emission is the remote-column introspection mapping, never a field-type mapping), so the `binary` entry is import-side reach into foreign schemas — the only kind of schema this matrix is asked about — on the same content-class reading that puts `file` and `image` on that row, neither of which round-trips as bytes either (both are `FILE_REFERENCE_TYPES`, stored as reference-id strings). +- 29d0676: `validateAll`/`validateDatasource` now read each datasource's live schema once per sweep instead of once per federated object: the sweep threads a per-call introspection memo through the validation body, so M objects on one datasource cost one remote introspection round-trip (a rejected read is shared the same way — one connection attempt, M failure rows). The memo lives and dies inside a single call, so a long-lived service never serves a stale schema to a later sweep, and direct `validateObject` calls still read live every time. The `IExternalDatasourceService.validateAll` docstring, which promised "parallelised per datasource" while the implementation parallelised per object, now states the actual behaviour. +- 6bd3231: test(spec,drivers): add the `VALUE_ROUNDTRIP` conformance case-set — "what you wrote is what you read back", enforced per driver per dialect (#12393) + + The driver-conformance census was green at 9 of 9 dialect-scored cells after + #12136 promoted `MATRIXED`, and **none of its nine case-sets was about value + storage**. All nine ask *which rows come back*; none asks *what is in them*. So + that green was not weak evidence about a round-trip defect — it was no evidence + at all, and it would have stayed green forever with the defect in place. That is + why this family kept arriving one card at a time: #12380 (SQLite's `Field.json` + codec was not injective), #11535 (a multi-value field read back as the string + `'["x","y"]'`), #11782 (MySQL answering `1`/`0` for a declared boolean), #10995 + (PG json values bound without `JSON.stringify`). + + `VALUE_ROUNDTRIP_CASES` closes it as a class rather than as a tenth instance. It + is 41 cases over five declared value classes — `json`, `multiple: true`, + `string`, `number`, `boolean` — and every value in it is one some driver was + **measured** to change, or a control that stayed faithful in the same + measurement. Assertions pin **type as well as value**: the before-state of every + card above was a wrong type carrying a right-looking value, which survives + `toEqual`-style coercion and every truthiness check. `VALUE_ROUNDTRIP_COLLISION_PAIRS` + adds the injectivity half a per-value check cannot see — a string and the native + value whose encoding it resembles must stay distinguishable. + + Enrolled through the census's existing machinery rather than as a bespoke suite, + which is the whole argument for this route: `CLASSIFIED` obliges the new fixture + to be named in `CASE_SETS`, `CONSUMED` obliges every driver to run it, and + `MATRIXED` obliges `driver-sql`'s cell to be answered on **every dialect it + speaks** rather than on SQLite alone — the coverage shape that let #12380 survive + in the first place. The census now reads **50 covered cells across 5 drivers × + 10 case-sets, 10 of 10 dialect-scored cells matrix-routed, 0 DEBT, 0 exempt**. + + **No shipped behaviour and no public surface changes.** This is `@objectstack/spec`'s + `data` export gaining one conformance fixture, six new test files, and one + `CASE_SETS` row in the census script. No Zod schema, no runtime, no driver + source, no API. Graded `patch` for that reason: the package's published surface + grows by a test fixture that only conformance suites consume, and nothing an + existing consumer resolves changes shape. + + The one non-test change is a **test-double fidelity fix** the new case-set + surfaced: `driver-turso`'s `makeLibsqlSqliteStub` did not model `@libsql/client`'s + client-side boolean → `1`/`0` conversion, so a declared `boolean` written through + the REMOTE transport could not be bound at all. Verified against the dependency's + own source rather than the transport's comment; the transport is correct and + unchanged. +- 644ad50: `visibleWhen`'s describe now states the roots it actually binds, split by what is contract and what is renderer behaviour. + + `PageComponentSchema.visibleWhen` named three roots (`record`, `current_user`, `page.`) while the shipping renderer binds nine. The describe now separates the two claims instead of widening one into the other: + + - **Contract-bound**: `record`, `current_user` — with ADR-0068's aliases `user` and `ctx.user`, one object under three spellings — and `page.`. These are transcribed from a ruling, not from the renderer: ADR-0068 D1 rules a predicate "evaluates identically in a formula, an RLS policy, and a client `visible` gate", and `EvalUser`'s docblock already states the same alias set for "client UI gates". The platform's own `sys_user` alert gate uses `ctx.user`, which the old describe implied was unavailable. + - **Renderer-provided, not guaranteed**: `app`, `features`, `os.user` and `data`. ADR-0068's Non-goals fence its ruling to the user object, so nothing rules these on this surface; they are recorded as measured behaviour rather than promised. + - **`data` is surface-dependent** and is now called out as such: the data-source **adapter** on a component node, the record **row** on a `page:tabs` item-level `visibleWhen`. + + The `page:tabs` item `visibleWhen` (`ComponentSchema`) carried the identical three-root sentence plus a "binds the same environment as page-component `visibleWhen`" claim that measurement disproves; it is corrected the same way and now names its two real divergences (row-bound `data`, bare-field spread). + + Describe/prose only — no accept/reject change, no shape change, no new keys. + ## 17.2.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 7e5cc35e5f..16b2d820c4 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.2.0", + "version": "17.3.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 0d4fdca919..83cbb6be37 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/trigger-api +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 01f5a0696a..96de46f7b9 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.2.0", + "version": "17.3.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 7e742d5a68..c0f2644bf1 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/plugin-trigger-record-change +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index 3e7fc6669b..74ea8b3a37 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.2.0", + "version": "17.3.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 a7addcaf33..62f1fe12ef 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/plugin-trigger-schedule +## 17.3.0 + +### Patch Changes + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [983edf1] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [7131f12] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + - @objectstack/core@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index a91d47bc5e..ebfe7200d7 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.2.0", + "version": "17.3.0", "license": "Apache-2.0", "description": "Schedule flow trigger for ObjectStack — 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 224c7cbf8d..33e254df36 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,285 @@ # @objectstack/types +## 17.3.0 + +### Minor Changes + +- db8c288: fix(types): let `sendError`'s `extra` carry `declaredCode`, so a nested-envelope route can emit the ADR-0112 open channel (#11719) + + `ApiErrorSchema` has declared `declaredCode` since #9106 — the open, + author-authored channel that carries a metadata app's own `.code` verbatim when + the spelling is not a member of the closed `code` vocabulary. ADR-0112's + 2026-08-17 amendment rules that demote **platform-wide**, and #9232 extended it + to the flat `/data` door, which emits the pair today. + + The shared nested-envelope writer could not. `sendError`'s `extra` was typed + `Pick`, so + passing a demoted spelling was a **compile error** and every route answering the + nested envelope dropped it. Nothing invalid shipped — the closed `code` still + carried the member derived from the status — which is exactly what made the loss + silent and one-directional: the author's spelling gone, and a consumer told by + the ADR to read `declaredCode` finding nothing there. Declared-but-unemittable + is a `declared = enforced` gap, closed here at the one writer rather than per + module. + + Additive: `declaredCode` joins the `Pick`. No existing call site changes, no + wire byte moves for any body already being emitted, and the contract's accept + set is untouched — the schema has always permitted the field. + + ⛔ Presence still MEANS demotion, and the writer does not re-derive that. The + caller passes `demotedDeclaredCode(thrown)` (`@objectstack/types`), exactly as + the flat door's `thrownCodeFields` does; that helper answers `undefined` when + the producer's spelling is already the vocabulary member sitting in `code`, so a + registered refusal never carries two spellings of one fact. Vocabulary and + position stay two decisions (#9232). + + Pinned in `response-envelope.test.ts` by driving the real pipeline — a + sandbox-shaped throw carrying a tenant-authored `.code` through + `resolveThrownHttpError` and `demotedDeclaredCode` — and by parsing the emitted + body with the real `ApiErrorSchema`, asserting the field is still on it *after* + the parse. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, + so a `.success` assertion alone would have passed against a schema declaring + nothing. +- 0e5fe7f: fix(types): let `sendError`'s `extra` carry `userMessage`, so a nested-envelope route can emit the #9934 user-facing channel (#12404) + + `ApiErrorSchema` declares `userMessage` — the producer-side opt-in for "this + exact text is addressed to the END USER" (#9934; maintainer ruling 2026-08-19 on + objectui#5210, option 1), where **presence IS the marking** and a consumer that + sees the field renders it verbatim instead of substituting its generic string + (#3821 preserved by construction, for everything unmarked). + + Two of the three doors already emit it: the flat `/data` door through + `withDeclaredUserMessage` (`@objectstack/rest`) and the dispatcher door through + `thrown.userMessage` (`@objectstack/runtime`). The shared nested-envelope writer + could not — `sendError`'s `extra` was typed + `Pick`, + so passing the field was a **compile error** and every route answering the + nested envelope dropped it. Nothing invalid shipped, which is what made the loss + silent and one-directional: the author's deliberate, localized refusal text + gone, and a consumer told to read `userMessage` finding nothing there. + Declared-but-unemittable is a `declared = enforced` gap, closed here at the one + writer rather than per module. + + Additive: `userMessage` joins the `Pick`. No existing call site changes, no wire + byte moves for any body already being emitted, and the contract's accept set is + untouched — the schema has always declared the field. + + The channel is live on both ends, which is what makes this a repair rather than + a new declared-but-dead surface: a hook opts in at throw time (host-side, or a + metadata app's sandboxed body whose `e.userMessage` crosses the QuickJS boundary + through `SANDBOX_ERROR_PASSTHROUGH`), and `resolveThrownHttpError` already + carries it onto `ThrownHttpError` for every caller of the shared resolver. + + ⛔ Unlike `declaredCode`, this field hands the caller **no invariant to + re-derive**. `declaredCode`'s presence means demotion, so its caller passes + `demotedDeclaredCode(thrown)`; `userMessage`'s presence means only that the + producer opted in, which `declaredUserMessage` has already decided (a non-empty + string, or nothing). The caller passes `thrown.userMessage` straight through, + exactly as the dispatcher door does. That difference is why `extra` stays an + explicit `Pick` rather than being derived from `ApiError`'s optional fields: a + derivation would admit every future optional on the day it lands, with nobody + asked what obligation the channel hands the caller — and these two fields needed + opposite answers to exactly that question. + + Pinned in `response-envelope.test.ts` by driving the real pipeline — a hook + refusal shaped like the one `hook-refusal-user-facing-marking.dogfood.test.ts` + drives, through `resolveThrownHttpError` — and by parsing the emitted body with + the real `ApiErrorSchema`, asserting the field is still on it *after* the parse, + paired with a control showing an undeclared sibling being stripped from the same + body. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys, so a + `.success` assertion alone would have passed against a schema declaring nothing. + A blank marking is pinned ABSENT: the writer never invents a marked message for + a producer that wrote none. +- 9735662: fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + + **BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or + `isolated`), shipped as `minor` under the repo's launch-window convention for + breaking changes. Single-org deployments are byte-for-byte unchanged. + + Measured defect (cloud#1509): on a walled multi-tenant SaaS with + `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the + FIRST self-registrant received the cross-tenant `admin_full_access` grant + (`platform_admin`, `isPlatformAdmin: true`) and — because the default-org + bootstrap binds "the platform admin" — was merged into the deployment's + Default Organization as its owner. Whoever curls the public sign-up endpoint + first owned the platform. + + Per the maintainer ruling of 2026-08-23 (verbatim: + 「1509 选择 env 指定 owner 邮箱」): + + - **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. + - **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). + - **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. + - The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + + Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the + operator account's email address before upgrading. Deployments that already + hold a human platform admin are untouched (the bootstrap remains a no-op once + any human holds the cross-tenant grant); the variable governs installs that + have not yet minted their admin. `@objectstack/types` gains the + `resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` + constant; the verify harness declares the owner email (defaulting to its dev + admin) for walled fixtures. + + + +### Patch Changes + +- 2cf5a96: An organization no longer stops accepting members at 100 — membership is not a + limited axis, and the ceiling nobody chose is now stated explicitly + + A customer adding users was refused with `Organization membership limit + reached`. Nothing in this codebase set that ceiling: better-auth's organization + plugin substitutes a vendor default of **100** for an absent `membershipLimit` + (`count >= (membershipLimit || 100)` in `routes/crud-members`), and + `auth-manager` passed `organizationLimit` — how many organizations one user may + CREATE — while never passing `membershipLimit`, which is a different question. + + The two read almost identically in a config block and mean nothing alike, which + is why the gap survived: the option that WAS set looked like the option that + was not. In the field the refusal is worse than merely wrong — it arrives while + an operator is looking at licences and seat counts, and reads as an entitlement + problem on an axis that carries no entitlement at all. Seats are metered on AI + usage; plain membership has never been billed. + + - `membershipLimit` is now passed explicitly, defaulting to unbounded. + - `OS_ORG_MEMBERSHIP_LIMIT` is the opt-in for a deployment that DOES want a + ceiling (a pilot, a trial tenant). Unusable values (empty, non-numeric, + zero, negative) read as unset rather than as a cap — a typo must not be the + thing that locks an organization, which is exactly the failure mode being + fixed. + - The decision lives in `resolveMembershipLimitOption()` rather than inside the + plugin-construction expression, so it is testable: the unset case, the + explicit ceiling, the unusable-value direction, and — deliberately — that the + chosen value clears the vendor's 100 by a wide margin. If a future + better-auth changes that default, the test says so instead of leaving an + unexplained constant behind. + + The unbounded value is `Number.MAX_SAFE_INTEGER`, not `Infinity`: the option is + compared numerically but also travels through option plumbing that may assume a + finite value, and nine quadrillion members is unlimited by any measure that + reaches a real deployment. +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [40a93b5] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [7a25e7d] +- Updated dependencies [1fa05a6] +- Updated dependencies [dcb10a5] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [e25403c] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [3a04b01] +- Updated dependencies [b9e9227] +- Updated dependencies [d395692] +- Updated dependencies [5894d30] +- Updated dependencies [d2619fd] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [e5ce2ed] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [42a117b] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [5691b07] +- Updated dependencies [2a6122b] +- Updated dependencies [8af88dd] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [177ebdc] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] + - @objectstack/spec@17.3.0 + ## 17.2.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 6b9ee26008..daccf968b9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.2.0", + "version": "17.3.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 9a90e9af74..cc653563a0 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,285 @@ # @objectstack/verify +## 17.3.0 + +### Patch Changes + +- 4f24e9d: feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only` + + **BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end. + + - New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced. + - **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`. + - `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted. + - Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture. + - `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger. + - The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too. + - `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`). + + +- 9735662: fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + + **BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or + `isolated`), shipped as `minor` under the repo's launch-window convention for + breaking changes. Single-org deployments are byte-for-byte unchanged. + + Measured defect (cloud#1509): on a walled multi-tenant SaaS with + `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the + FIRST self-registrant received the cross-tenant `admin_full_access` grant + (`platform_admin`, `isPlatformAdmin: true`) and — because the default-org + bootstrap binds "the platform admin" — was merged into the deployment's + Default Organization as its owner. Whoever curls the public sign-up endpoint + first owned the platform. + + Per the maintainer ruling of 2026-08-23 (verbatim: + 「1509 选择 env 指定 owner 邮箱」): + + - **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. + - **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). + - **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. + - The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + + Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the + operator account's email address before upgrading. Deployments that already + hold a human platform admin are untouched (the bootstrap remains a no-op once + any human holds the cross-tenant grant); the variable governs installs that + have not yet minted their admin. `@objectstack/types` gains the + `resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` + constant; the verify harness declares the owner email (defaulting to its dev + admin) for walled fixtures. + + +- Updated dependencies [387e231] +- Updated dependencies [2d4fa75] +- Updated dependencies [0e4e51b] +- Updated dependencies [8064e6d] +- Updated dependencies [6dd3e69] +- Updated dependencies [40a93b5] +- Updated dependencies [99ccbb9] +- Updated dependencies [74cee59] +- Updated dependencies [6747718] +- Updated dependencies [399ecad] +- Updated dependencies [8bdd955] +- Updated dependencies [4f24e9d] +- Updated dependencies [8e31083] +- Updated dependencies [2efa1e1] +- Updated dependencies [fe3d74f] +- Updated dependencies [266436a] +- Updated dependencies [983edf1] +- Updated dependencies [c6c895c] +- Updated dependencies [c33f185] +- Updated dependencies [56d3c7a] +- Updated dependencies [7181101] +- Updated dependencies [7a25e7d] +- Updated dependencies [d3bee87] +- Updated dependencies [1fa05a6] +- Updated dependencies [5cb62d8] +- Updated dependencies [dcb10a5] +- Updated dependencies [ce744bc] +- Updated dependencies [6171331] +- Updated dependencies [0010797] +- Updated dependencies [a58eac3] +- Updated dependencies [3d79144] +- Updated dependencies [7c0d0c3] +- Updated dependencies [daae7aa] +- Updated dependencies [25b1b81] +- Updated dependencies [e25403c] +- Updated dependencies [1a68552] +- Updated dependencies [d2b2381] +- Updated dependencies [67ceb9a] +- Updated dependencies [64baa68] +- Updated dependencies [9fa70d7] +- Updated dependencies [09db64a] +- Updated dependencies [92916e7] +- Updated dependencies [a84f3ea] +- Updated dependencies [f2eaae8] +- Updated dependencies [7345308] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [30d96ab] +- Updated dependencies [f658793] +- Updated dependencies [0fd4899] +- Updated dependencies [5997207] +- Updated dependencies [8b13cc8] +- Updated dependencies [c3c72a4] +- Updated dependencies [7bd6447] +- Updated dependencies [5a22dd7] +- Updated dependencies [0d7b1f3] +- Updated dependencies [8155855] +- Updated dependencies [f90e820] +- Updated dependencies [e8bd715] +- Updated dependencies [e914733] +- Updated dependencies [3bfa1e6] +- Updated dependencies [1524927] +- Updated dependencies [8542bd4] +- Updated dependencies [2af5eac] +- Updated dependencies [4635f3e] +- Updated dependencies [ee3595c] +- Updated dependencies [c61ad20] +- Updated dependencies [09b4f4e] +- Updated dependencies [3a04b01] +- Updated dependencies [3954fb7] +- Updated dependencies [4805b56] +- Updated dependencies [b9e9227] +- Updated dependencies [bbf1167] +- Updated dependencies [e7f56d6] +- Updated dependencies [d395692] +- Updated dependencies [0e0bf80] +- Updated dependencies [5894d30] +- Updated dependencies [66bbb4c] +- Updated dependencies [d2619fd] +- Updated dependencies [af56546] +- Updated dependencies [6acb11a] +- Updated dependencies [33c5fd3] +- Updated dependencies [20b0fdb] +- Updated dependencies [905019b] +- Updated dependencies [6269a55] +- Updated dependencies [a17da05] +- Updated dependencies [e170b0a] +- Updated dependencies [e5ce2ed] +- Updated dependencies [9e72090] +- Updated dependencies [be21955] +- Updated dependencies [bc56e18] +- Updated dependencies [be21955] +- Updated dependencies [15d58db] +- Updated dependencies [d63b014] +- Updated dependencies [9abe4e4] +- Updated dependencies [2cc7122] +- Updated dependencies [311433f] +- Updated dependencies [9abe4e4] +- Updated dependencies [9086761] +- Updated dependencies [f6344e7] +- Updated dependencies [42a117b] +- Updated dependencies [e577445] +- Updated dependencies [4297fe7] +- Updated dependencies [f11fc61] +- Updated dependencies [8f79379] +- Updated dependencies [e6ca40e] +- Updated dependencies [017130a] +- Updated dependencies [466b389] +- Updated dependencies [c5b9ccc] +- Updated dependencies [c68c670] +- Updated dependencies [1f6d047] +- Updated dependencies [7131f12] +- Updated dependencies [a02540f] +- Updated dependencies [2cf5a96] +- Updated dependencies [5691b07] +- Updated dependencies [14b1145] +- Updated dependencies [092b9da] +- Updated dependencies [2a6122b] +- Updated dependencies [5619aac] +- Updated dependencies [8af88dd] +- Updated dependencies [502ff8b] +- Updated dependencies [fb5fbb8] +- Updated dependencies [d7b3963] +- Updated dependencies [b72db01] +- Updated dependencies [f64668d] +- Updated dependencies [177ebdc] +- Updated dependencies [599515d] +- Updated dependencies [8d237b4] +- Updated dependencies [2d8dd8d] +- Updated dependencies [348860c] +- Updated dependencies [5383fa6] +- Updated dependencies [1a6a19c] +- Updated dependencies [064d484] +- Updated dependencies [527e050] +- Updated dependencies [dd33bf9] +- Updated dependencies [4cb2a90] +- Updated dependencies [fe72aa5] +- Updated dependencies [1394768] +- Updated dependencies [da43fde] +- Updated dependencies [a933ed7] +- Updated dependencies [6202043] +- Updated dependencies [8519095] +- Updated dependencies [6e33394] +- Updated dependencies [911da5f] +- Updated dependencies [7986d97] +- Updated dependencies [033a34c] +- Updated dependencies [5ae4303] +- Updated dependencies [ece4dad] +- Updated dependencies [146f448] +- Updated dependencies [735f5c7] +- Updated dependencies [18b53ac] +- Updated dependencies [e1d773e] +- Updated dependencies [8619f95] +- Updated dependencies [b706af9] +- Updated dependencies [db8c288] +- Updated dependencies [0e5fe7f] +- Updated dependencies [fc8627e] +- Updated dependencies [050d8d8] +- Updated dependencies [1272f0a] +- Updated dependencies [18b53ac] +- Updated dependencies [3194c91] +- Updated dependencies [a11c1a5] +- Updated dependencies [71f9cd1] +- Updated dependencies [ee17d86] +- Updated dependencies [cdbd920] +- Updated dependencies [18c432e] +- Updated dependencies [3c418c4] +- Updated dependencies [a933ed7] +- Updated dependencies [a933ed7] +- Updated dependencies [424f73c] +- Updated dependencies [cccbe51] +- Updated dependencies [a8d6b1d] +- Updated dependencies [e4a7695] +- Updated dependencies [14cfc00] +- Updated dependencies [dfebfc8] +- Updated dependencies [598b7ec] +- Updated dependencies [ffbb7a1] +- Updated dependencies [9981f31] +- Updated dependencies [f7b25c5] +- Updated dependencies [122ef38] +- Updated dependencies [428f9b2] +- Updated dependencies [811a3c2] +- Updated dependencies [c4db311] +- Updated dependencies [d173125] +- Updated dependencies [8425c17] +- Updated dependencies [a5ef1d8] +- Updated dependencies [772d5de] +- Updated dependencies [ce80ec2] +- Updated dependencies [b372318] +- Updated dependencies [29d0676] +- Updated dependencies [6bd3231] +- Updated dependencies [644ad50] +- Updated dependencies [c0714eb] +- Updated dependencies [9735662] +- Updated dependencies [bf8d129] +- Updated dependencies [e3f056f] +- Updated dependencies [5d16379] +- Updated dependencies [aa0688a] +- Updated dependencies [cad8b42] + - @objectstack/spec@17.3.0 + - @objectstack/runtime@17.3.0 + - @objectstack/plugin-security@17.3.0 + - @objectstack/plugin-auth@17.3.0 + - @objectstack/service-settings@17.3.0 + - @objectstack/service-analytics@17.3.0 + - @objectstack/rest@17.3.0 + - @objectstack/core@17.3.0 + - @objectstack/objectql@17.3.0 + - @objectstack/service-datasource@17.3.0 + - @objectstack/service-automation@17.3.0 + - @objectstack/platform-objects@17.3.0 + - @objectstack/plugin-sharing@17.3.0 + - @objectstack/types@17.3.0 + - @objectstack/plugin-hono-server@17.3.0 + ## 17.2.0 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index 4604f35f57..6cce61d1eb 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.2.0", + "version": "17.3.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",