diff --git a/.changeset/add-member-team-id-no-active-team-fallback.md b/.changeset/add-member-team-id-no-active-team-fallback.md deleted file mode 100644 index c1b7ff49eb..0000000000 --- a/.changeset/add-member-team-id-no-active-team-fallback.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/plugin-auth": patch ---- - -Correct a false vendor claim in the `organization/add-member` source comments: -`teamId` has **no** active-team fallback (#10532). Two comments — the -`sys_member` `add_member` action metadata (the origin) and the -`organization-add-member.ts` module header that cited it as authority — stated -that "organizationId/teamId default to the caller's active org/team when -omitted". Measured on the installed better-auth 1.7.1 -(`dist/plugins/organization/routes/crud-members.mjs`, inside `addMember`), only -the organization half is true: - -```js -const orgId = ctx.body.organizationId || session?.session.activeOrganizationId; -const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0; -``` - -`activeOrganizationId` is read 8 times in that module; `activeTeamId`, never. An -omitted `teamId` therefore stays `undefined` and the member joins no team — every -`if (teamId)` branch (team lookup, `TEAM_NOT_FOUND`, per-team limit) is skipped. - -No runtime behaviour changes, and no deployment was ever misled: the `add_member` -action's `params` list carries no `teamId`, so the toolbar never sent one and the -claim was never exercised. What the comment did mislead was the next reader of -the mount, which cited it as the justification for forwarding request headers — -forwarding buys the organization default only. Forwarding `teamId` itself remains -correct: pass it and it works. - -The asymmetry the docs now publish is held by a new pin, -`organization-add-member-team-fallback.test.ts`, which reads the fact out of the -installed vendor artifact (not out of our own comments) so that a future -better-auth bump *adding* an active-team fallback reddens instead of silently -putting the docs out of date. diff --git a/.changeset/admin-vendor-refusal-envelope.md b/.changeset/admin-vendor-refusal-envelope.md deleted file mode 100644 index a7788a5489..0000000000 --- a/.changeset/admin-vendor-refusal-envelope.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -fix(plugin-auth): the better-auth-native `/admin/` routes refuse an anonymous caller with the ADR-0112 envelope (#10349) - -**BREAKING** response-shape change on the `/api/v1/auth/admin/` namespace, -shipped as `minor` under the repo's launch-window convention for breaking -changes. - -`/api/v1/auth/admin/` is served by two implementations and answered the same -question in two shapes. ObjectStack's raw mounts (`create-user`, -`set-user-password`, `unlock-user`, `import-users`, `ban-user`, `unban-user`, -`oauth2/toggle-disabled`, `sso/*`) refuse an anonymous caller through -`judgePlatformAdmin` with the declared envelope and `code: 'UNAUTHENTICATED'`. -The routes better-auth serves itself refuse through the vendor's -`adminMiddleware` — `getAuthoritativeSessionFromCtx(ctx)` then -`APIError.fromStatus('UNAUTHORIZED')`, with no body argument at all. - -Measured on the installed better-auth 1.7.1, anonymous, through -`AuthManager.handleRequest`: ten vendor-lane routes (`impersonate-user`, -`set-role`, `revoke-user-sessions`, `revoke-user-session`, -`list-user-sessions`, `update-user`, `list-users`, `get-user`, -`has-permission`, `stop-impersonating`) answered `401` with a -`content-type: application/json` header and the **empty string** as the body. -A client that believes that header and parses the body throws on the refusal -instead of branching on it, and a client that wants to branch has to know, per -route, which of the two implementations happens to serve it — an -implementation detail, not a contract. - -`AuthManager.handleRequest` now gives those refusals the declared envelope at -the one seam every vendor route passes through. **Statuses are unchanged and -admission is unchanged**: nothing that was refused is now admitted, nothing -that was admitted is now refused, and no status moved. What is added is the -machine-readable `code`, derived from the status by ADR-0112's own -`standardErrorCodeForHttpStatus` map rather than spelled out again — so no new -error code is registered and the vendor lane's anonymous refusal is now -byte-identical to the ObjectStack lane's. - -Scope is the `/admin/` namespace only. Three narrowings hold the rest of the -surface still, and each is pinned: - -- **A refusal that already carried a body keeps it, byte for byte.** The - signed-in non-admin's `403` with the vendor's own - `YOU_ARE_NOT_ALLOWED_TO_*` vocabulary is untouched; this change fills in an - empty body and never rewrites a spoken one. -- **Only the two refusal statuses are named** (`401`, `403`). A bodyless `404` - such as `/admin/oauth2/*` with the `oidcProvider` plugin off, and any - semantic `4xx` the vendor owns, are left exactly as they are. -- **Nothing outside `/admin/` is touched.** `POST /sign-in/email` still answers - `401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}`, - measured identical on both sides of the change. - -Consumers that branch on the HTTP status are unaffected. Consumers that already -parse the ObjectStack `/admin/*` envelope now get the same shape everywhere in -the namespace, with no per-route knowledge required. - - diff --git a/.changeset/aggregate-per-aggregation-filter.md b/.changeset/aggregate-per-aggregation-filter.md deleted file mode 100644 index 1710444432..0000000000 --- a/.changeset/aggregate-per-aggregation-filter.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/driver-sql": patch -"@objectstack/driver-turso": patch -"@objectstack/driver-mongodb": patch -"@objectstack/driver-memory": patch ---- - -`engine.aggregate` honours a per-aggregation `filter` (#10576, the contract -half of #10413). `AggregationNodeSchema.filter` — declared since #4286 but -marked experimental and enforced by nothing — is now live with SQL -`FILTER (WHERE …)` semantics: the predicate narrows the SOURCE rows that one -aggregation reads while sibling aggregations in the same call keep seeing -every row of the group, so a measure-scoped filter (`stage: 'closed_won'`) -can finally reach the engine instead of being silently dropped (the #10413 -wrong-numbers defect on the ObjectQL analytics path). The -`StrategyContext.executeAggregate` bridge (`@objectstack/spec/contracts`) -gains the same optional `filter` on its aggregation entries so analytics -strategies can lower measure filters into it (#10413 phase 2 consumes this -seam next). - -Execution is the correct-first two-tier shape date bucketing and HAVING use: -the engine lowers filtered aggregations in memory for every driver (unknown -operators refuse loudly with `INVALID_FILTER`/400 naming the aggregation -position; a group emptied by its filter answers the ruled empty-group values -— count/sum 0, avg/min/max null). No driver compiles conditional aggregation -natively today, so each native aggregate face (driver-sql — inherited by -driver-sqlite-wasm and Turso local —, the Turso remote transport, -driver-mongodb's pipeline builder, driver-memory's `performAggregation`) -refuses a directly-delivered per-aggregation filter with -`NOT_IMPLEMENTED`/501 instead of silently aggregating the unfiltered rows. -Aggregations without a `filter` are byte-identically unchanged, including -their native pushdown path. diff --git a/.changeset/ai-chat-not-agent-resolved.md b/.changeset/ai-chat-not-agent-resolved.md deleted file mode 100644 index f27a2ced9d..0000000000 --- a/.changeset/ai-chat-not-agent-resolved.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/client": patch ---- - -Stop documenting bare `POST /api/v1/ai/chat` as agent-resolved (#10510). Two -shipped docblocks described a resolution step the route does not perform: -`client.ai.agents` claimed `/ai/chat` "talks to the environment's default -agent", and `App.defaultAgent` claimed that endpoint auto-resolves the app's -agent from `context.appName`. The bare route loads no agent and never reads -`context.appName`; the default-agent chain (explicit > `defaultAgent` of the -named app > first active) is driven by the assistant chat endpoint, -`POST /api/v1/ai/assistant/chat`, and `client.ai.agents.chat()` is the only SDK -method that reaches an agent at all. - -Both sites read as a security-relevant scoping guarantee — an agent-resolved -endpoint would have its tool offer scoped by that agent's skills (ADR-0063 -§1/§5) — so a reader auditing "which endpoints are surface-scoped?" from these -declarations got the wrong answer at both. Documentation text only: no schema -key, no parse behaviour and no runtime path changes. diff --git a/.changeset/approval-payload-read-time-redaction.md b/.changeset/approval-payload-read-time-redaction.md deleted file mode 100644 index fef5dd81d0..0000000000 --- a/.changeset/approval-payload-read-time-redaction.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -Apply the subject object's field-level read controls to the approval payload -snapshot at serve time (#10749), so an approver no longer receives fields the -app author declared they may not read. - -`sys_approval_request.payload_json` stores the submitted record's raw row, -captured from the flow's `$record` variable — which the automation layer hands -over with the record's own FLS never applying. The column is a `textarea` on -`sys_approval_request`, so to every read door it is an opaque string: the -field-visibility machinery governs *columns of objects* and cannot see inside a -JSON column. Every field-level read control declared on the SUBJECT object — -`requiredPermissions` (ADR-0066 D3), a permission set marking a field -non-readable, a `maskingRule` — was therefore unenforceable on the approval -path, for every app. - -Per the maintainer's ruling (2026-08-22, Option B) the full snapshot **stays at -rest**: the approval record remains audit evidence of what was actually -submitted, which write-time trimming would have given away. Redaction happens at -**serve** time, keyed on the reading caller, so the same row answers an admin -with the whole snapshot and a restricted approver with only the fields they may -read. The readable set is not recomputed — it comes from the security service's -`getReadableFields`, documented as the same field mask the read middleware -applies, so this seam cannot drift from data-plane FLS. - -Two doors are covered, because `payload_json` has two independent readers and a -seam covering one manufactures the belief that the path is masked: - -- the **service door** (`getRequest` / `listRequests`, behind - `GET /api/v1/approvals/requests[/:id]`), which serves the parsed `payload`. - Redaction runs BEFORE display enrichment, so `payload_display` and - `payload_labels` — both built by walking the snapshot's own keys — cannot ship - a restricted field's name, its authored label, or the title of the record it - points at; -- the **generic data door**: the object declares - `enable.apiMethods: ['get','list']`, so a plain `find`/`findOne` returns the - raw string without the service ever running. Covered by object-scoped engine - middleware, which reaches the whole family sharing that producer (REST data - routes, ObjectQL, CSV/XLSX export, MCP). Middleware rather than an `afterFind` - hook on purpose: a hook receives `buildSession`'s output, which carries no - `onBehalfOf`, so a hook-based seam would drop the ADR-0090 D10 delegator - intersection and answer a delegated read more permissively than the service. - -**Behaviour change, argued rather than assumed.** A non-admin caller that was -reading restricted keys out of the snapshot now receives fewer keys, and that is -a real change for such a consumer. It is shipped as a fix rather than a breaking -change because those fields were never that caller's to read: the approval path -was a bypass of a declaration the platform enforces everywhere else, and the -served type is `payload?: unknown` — never a promised field set. This follows the -`__search` companion strip (#7642), which shipped the same way on the same -reasoning. Object-level access is deliberately untouched: an approver commonly -holds no read grant on the object under approval at all, and -`getReadableFields` answers a caller with no field-permission entries with the -full set, so every approval drawer shipping today keeps rendering. - -`hidden: true` is deliberately NOT acted on. It is a UI contract ("Hidden from -default UI") which, in the spec's own words, "has never governed serialization" -— measurably: no read path in the repo strips a value on it. Enforcing it here -alone would make the approval path stricter than a direct read of the same row -(closing no leak, since the approver can simply read the record) while breaking -drawers that render a `hidden` business column. That is a `packages/spec` -semantics question and is left open. diff --git a/.changeset/approval-row-organization-id.md b/.changeset/approval-row-organization-id.md deleted file mode 100644 index 415dc8bc92..0000000000 --- a/.changeset/approval-row-organization-id.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Declare `organization_id?: string | null` on `ApprovalRequestRow` and -`ApprovalActionRow` (#10331). The approval service has always stamped the -tenancy placement on the rows it inserts — and returns it on request-row -reads — but the published contract types omitted the field, so consumers had -to cast past the contract to reach it. Type-only widening: one declared -optional field per row, no runtime change. diff --git a/.changeset/approval-service-logger-warn-required.md b/.changeset/approval-service-logger-warn-required.md deleted file mode 100644 index e5ebab9aaa..0000000000 --- a/.changeset/approval-service-logger-warn-required.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/plugin-approvals": minor ---- - -**BREAKING** (compile-time only): `ApprovalServiceOptions['logger']` now -declares a **non-optional** `warn`, so a durability report always has -somewhere to land (#9754, #10556). This is the thirteenth of the thirteen -mechanical repairs the card names — held out of #10691 to serialize against -PR #10547, which owned `approval-service.ts` while it was open; that fence -has since cleared. - -`minor`, not `major`: during the launch window this stack ships breaking -changes as `minor` — every publishable package versions in lockstep, so a -`major` would promote the whole release. `patch` would be wrong in the other -direction, because this *can* break a consumer's build. This is the same -reasoning #10691 used for the twelve sibling repairs; no exemption for a -types-only break was found there either, and none applies here. - -`error` stays optional — hosts legitimately inject reduced sinks, and -requiring `error` was measured and rejected as #9754 option C. What changes -is that its *absence* now has a declared, guaranteed destination. Call sites -keep the `logger?.warn?.(…)` spelling as the backstop for hosts the type -cannot reach, so **no runtime behaviour changes**: nothing that printed -before stops printing, and nothing silent starts printing. - -### Who has to change, and what to do - -Only a caller that constructs `ApprovalService` (or an `ApprovalServiceOptions` -value) with a `logger` object that has **no `warn` method** — for example -`{ error }` alone. Add a `warn` member; there is no rename, no removal, and no -stored value or metadata key to rewrite. The only non-test construction site -in this repo (`ApprovalsServicePlugin.start`, in this same package) passes the -kernel `ctx.logger`, whose `warn` is already required, so the in-repo cost is -zero. - - diff --git a/.changeset/approvals-free-text-not-over-masked-snapshot.md b/.changeset/approvals-free-text-not-over-masked-snapshot.md deleted file mode 100644 index 81b3419806..0000000000 --- a/.changeset/approvals-free-text-not-over-masked-snapshot.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -**Deliberate search-semantics change.** `ApprovalService.listRequests` / -`countRequests` no longer push a free-text predicate onto the payload snapshot -column for a caller whose view of that snapshot is masked (#11040). - -Since #10749 the approval snapshot (`sys_approval_request.payload_json`, the -submitted record's row) is redacted **at serve time, per reader**: each row is -cut down to the fields that caller may read on that row's subject object. The -full row deliberately stays at rest, so the approval record remains audit -evidence of what was actually submitted. - -The free-text filter, however, is evaluated by the driver against the stored -column — before anything is served, and therefore against the unmasked bytes. A -predicate over that column is a question about its contents whose answer is row -membership, so the field-level read controls #10749 enforces on the way out did -not hold on the way in. That is `declared ≠ enforced`, and the platform has -already settled the governing principle for it: under `maskingRule` (#8993) a -field a caller sees masked is non-filterable, refused loudly, because otherwise -equality probes reconstruct the hidden span. This extends that settled posture -to the snapshot column, which is reached through a different door. - -**What changes.** For a caller whose view of the snapshot is masked, free-text -search matches on `process_name`, `object_name`, `record_id` and `submitter_id` -— the columns of `sys_approval_request` itself, which anyone who can see the row -reads whole — and no longer on snapshot contents. Such a caller can still find a -request by process, object, record id or submitter; they can no longer find one -by a value they may not read. Rows returned, their order and pagination are -otherwise untouched, and no query is refused: the change only ever removes one -disjunct, never denies. - -**What does not change.** A caller the serve path hands the whole snapshot to -keeps today's behaviour exactly — same rows, same order. That includes every -deployment that has not wired a field-visibility authority (the seam is -late-bound, and absent it snapshots are served unredacted), and the case where a -wired authority declines to narrow. Consistency with the serve path is the rule -here rather than blanket fail-closed: where serve hands over the whole snapshot, -keeping the predicate discloses nothing serve does not already disclose. - -The masked/unmasked verdict is read from **the same authority and the same -per-caller call the serve path uses**, asked as the caller. It is deliberately -not a second, independently derived notion of "redacted" — two derivations drift, -and the drift between a serve rule and a filter rule is exactly what this fixes. - -Because redaction is decided per row while a filter is built before any row -exists, the predicate-time scope matters: with an `object` filter the subject -object is known and the seam is asked about it directly; without one the query -spans every object, nothing sound can be asked, and the disjunct is dropped. - -Held by `approval-free-text-scope.test.ts`. diff --git a/.changeset/archiver-governance-windows-and-floors.md b/.changeset/archiver-governance-windows-and-floors.md deleted file mode 100644 index b117544655..0000000000 --- a/.changeset/archiver-governance-windows-and-floors.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -The Archiver resolves its window through ADR-0057 P4 governance (#10528). -`LifecycleService.archiveObject` read `archive.after` — and, since #10347, -`ttl.expireAfter` — straight off the declaration, so for any object declaring -`lifecycle.archive` an operator's settings override was silently ignored, a -registered `LifecycleRetentionFloor` was never evaluated, and per-tenant windows -did not apply. - -This was not a forgotten call. `reapObject` **returns** into `archiveObject` for -any object declaring `archive`, so the three `effectiveWindowMs` resolutions on -the reap path sat on a branch archive-declaring objects skip entirely — which is -why the divergence was total rather than partial, and why threading an override -into the cutoff alone would still have left floors and tenant windows unreached. - -All three legs now run, through the same resolver the Reaper uses: - -- a per-object `retention_overrides` entry beats the declaration, on the key that - matches which window the selection picked — `expireAfter` for a ttl-selected - archive, `maxAge` for an age-selected one; -- an override below a registered floor is rejected (the declared window stands), - logged at `error` naming the registrar, consequence and fix, and recorded in - `report.floorViolations` — the leg whose absence was *silent*, since an empty - `floorViolations` is indistinguishable from a healthy sweep. A *declared* - archive window below a floor is reported the same way and still enforced; -- tenant-scoped windows issue one candidate read per overriding tenant, then one - global pass covering everyone else including NULL-org rows — the shape `reap()` - already used, with tenant overrides going through the same floor. - -Unchanged on purpose: #10347's cutoff **selection** (a declared `ttl` still -decides which rows move, on `ttl.field`); the retain-first posture (no archive -datasource ⇒ `archive-pending`, hot-delete only what the cold store took); the -per-batch abort checks, now the first act of every pass; and the cold-side -`archive.keep` prune, which bounds the archive rather than the hot store and has -no settings key. An object with no override and no floor sweeps exactly as -before, as one pass over exactly the predicate it ran before. diff --git a/.changeset/archiver-honours-declared-ttl.md b/.changeset/archiver-honours-declared-ttl.md deleted file mode 100644 index 6baaaaef88..0000000000 --- a/.changeset/archiver-honours-declared-ttl.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -**Behaviour change:** a `lifecycle` that declares both `ttl` and `archive` now -has its **`ttl` enforced** — the Archiver selects the rows it moves by the -declared TTL cutoff (`ttl.field` past `ttl.expireAfter`) instead of by -`created_at` age (#10347). - -That pair has always parsed — ADR-0057 §3.5 is satisfied because `ttl` is a -bounding policy, and the `archive.after === retention.maxAge` refine only fires -when `retention` is present — but it did nothing: `LifecycleService.reapObject` -returns into `archiveObject` before its `ttl` branch is reachable, so no reap on -`ttl.field` ever ran and the Archiver copied and hot-deleted by `created_at` age -alone. Declared, not enforced. What the author wrote is now what executes; they -no longer have to discover that the two keys cannot usefully be written -together. - -**Lifecycles that declare `archive` without `ttl` are unaffected** — they keep -selecting rows by `created_at` past `archive.after`, unchanged. Every -archive-declaring object shipped with the platform (`sys_audit_log`, -`sys_metadata_audit`) is that shape, so no bundled object changes behaviour. - -Two details of the new selection, both deliberate: - -- A row whose `ttl.field` is **null or absent is retained, not archived**. `$lt` - is a positive comparison and a value that is not there satisfies none of them - (the platform-wide null answer settled in #5298/#5299), which is also the - right reading: a row with no expiry stamp has not been given one, and treating - "absent" as "expired at the epoch" would archive exactly the rows whose expiry - the author has not yet decided. -- The cold-side `archive.keep` prune is unchanged. It bounds how long **archived** - rows survive in cold storage, not which hot rows are due, and it still measures - from `created_at` under either policy. - -If you declare `retention` beside `ttl` and `archive`, the TTL cutoff is what -selects: the age window no longer separately bounds the hot store for that -triple. Whether the Archiver should honour both windows is a separate open -question, filed as #10527 rather than decided here. diff --git a/.changeset/attachment-before-update-guard.md b/.changeset/attachment-before-update-guard.md deleted file mode 100644 index 5ac0c9df7b..0000000000 --- a/.changeset/attachment-before-update-guard.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-storage": patch ---- - -**Behaviour change (tightening):** updates of `sys_attachment` rows are now authorization-gated, where they previously ran with **no record-level check at all** (#10091). - -`installAttachmentAccessHooks` gated insert (parent-edit access, `uploaded_by` server-stamped) and delete (uploader-or-parent-editor), but registered **no `beforeUpdate` hook** — so under the default member permission sets (wildcard CRUD, no row scoping) any member could rewrite any attachment row: re-point `parent_id` at a record they cannot read, or rewrite `uploaded_by` and then walk through the delete gate's uploader shortcut. The `sys_comment` kit — explicitly derived from this one — has gated update with the same rule since #4630; the source kit was missing the limb its derivative copied. - -The new `beforeUpdate` gate narrows the accept set as follows; if a currently-working update starts failing, the caller lacked rights the other two verbs already required: - -- **Row rule:** the caller must be the attachment's uploader OR hold edit on its parent record (`ISharingService.canEdit`; degrades to caller-scoped parent READ visibility when no sharing service is present). A multi-row update requires EVERY matched row to pass. Refusals are HTTP 403 with the **standard catalog code `RECORD_NOT_ACCESSIBLE`** (ADR-0112: generic permission conditions take the catalog — the same envelope the comment kit's update gate emits; the insert/delete gates keep their grandfathered `ATTACHMENT_*` codes). -- **Re-point rule:** an update that changes `parent_object`/`parent_id` must additionally satisfy the attach rule on the NEW parent (edit access, read visibility in degraded mode) — 403 `ATTACHMENT_PARENT_ACCESS` otherwise, and a re-point half that names no record (`null`/empty) is refused rather than left to validation. -- **Unscoped shape:** an unscoped `multi: true` update (no `where` at all) is refused outright via the `dispatchUnscopedMultiWrite` whole-operation dispatch (#9974), mirroring the delete verb's #4757 refusal. The explicit match-all `where: {}` is still accepted and authorized per row. - -System-context operations and context-less programmatic calls on bare kernels bypass the gate exactly as the insert/delete gates do. `uploaded_by` is deliberately not re-stamped on update: the caller is already verified as uploader or parent editor before the write proceeds, so the rewrite-then-uploader-delete escalation is closed by the row rule itself. diff --git a/.changeset/attachment-lifecycle-update-leg.md b/.changeset/attachment-lifecycle-update-leg.md deleted file mode 100644 index d4e3f087f0..0000000000 --- a/.changeset/attachment-lifecycle-update-leg.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-storage": patch ---- - -**Bug fix (retention leak):** an UPDATE that re-points a `sys_attachment` row's `file_id` now detaches the PRIOR file the same way deleting that row would — tombstoning it when the re-pointed row was its last reference (#10171). - -`installAttachmentLifecycleHooks` registered only delete-side and insert-side handlers, so a `file_id` re-point left the old `sys_file` sitting at `status='committed'` with zero join rows and no `deleted_at`. That is not the module's "fail toward retention" bias, which buys a **later** look: `sys_file`'s declared lifecycle nominates a row for the sweep only through `ttl { field: 'deleted_at' }` or `retention { onlyWhen: { status: 'pending' } }`, and a silently detached file matches neither — so the reap guard is never asked about it and the storage bytes are stranded permanently, with no later re-examination. - -The new `afterUpdate` handler fires only when the payload actually carries `file_id` and the value actually changes, then runs the existing orphan rule (zero remaining join rows, attachments-scope, committed) on the prior id. It is best-effort like its siblings and never blocks the user's write; with no pre-image available it tombstones nothing, keeping the file. - -The departed id comes from the engine-bound pre-image `ctx.previous`, **not** from a `beforeUpdate` stash mirroring the delete pair. Since #5574 (ADR-0058 Addendum II D1/D2) a predicate write dispatches one fresh context per matched row in each phase, so a stash written in `beforeUpdate` reaches `afterUpdate` on the by-id path and is lost on the predicate path — a stash-based twin would have been silently half-dead on exactly the multi-row updates that orphan the most files. Reading `previous` also adds no driver round trip: the prior-row read is memoized per operation and already demanded on this object. - -**No revival leg was added**, deliberately. Re-pointing a row ONTO a grace-window tombstone is already handled by the reap guard's sweep-time re-verification, which resolves current references, un-tombstones the file and vetoes the reap rather than reclaiming bytes. A second revival mechanism here would be a duplicate answer to a question that already has one. diff --git a/.changeset/audit-plugin-boot-path-reachability.md b/.changeset/audit-plugin-boot-path-reachability.md deleted file mode 100644 index bb2813c6e4..0000000000 --- a/.changeset/audit-plugin-boot-path-reachability.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -**Docs (published README) + ruling:** record-view auditing now documents how to turn it on under `objectstack serve`, and the answer to "should `os serve` grow an `appAuditPluginOptions(config)` helper?" is **no** (#9863). - -The README and `content/docs/permissions/record-view-auditing.mdx` both said the audited set is configured "where you compose the kernel", and the docs page went further: *"The CLI's `os serve` registers `AuditPlugin` with no options, so a stack served that way has record-view auditing off and no knob to turn it on."* That last clause stopped being true when #9864 declared and pinned the duplicate-registration contract. The knob is the stack's `plugins` array — a configured `new AuditPlugin({ readAudit: { objects: [...] } })` there supersedes the CLI's option-less instance by name, last-one-wins, on both kernels, with the displaced instance never reaching `init()`. Both pages now spell that path, and name the `Plugin superseded: 'com.objectstack.audit'` boot line as the opt-in working rather than a misconfiguration. - -**No new configuration surface was added, deliberately.** A `config.audit` key read by an `appAuditPluginOptions(config)` helper would reproduce, in `objectstack.config.ts`, exactly the failure #8992's ruling refused for the object-metadata spelling: a declaration that survives in a deployment which never installs this package, reading as coverage while recording nothing. It would also be a *second* configuration surface that silently loses to the first, since an app's own `plugins` entry supersedes whatever the CLI constructed. The `#7001` symmetry argument does not carry it either — `@objectstack/verify`'s `bootStack` constructs no `AuditPlugin` and does not depend on this package, so there is no second boot path to disagree with. - -No runtime behaviour changed. `packages/cli` gains only the reasoning at its registration site and `serve-audit-registration.contract.test.ts`, which pins the three facts the ruling rests on — including the load-bearing ordering (`AuditPlugin` registered above the stack `plugins` loop) that until now was asserted by a comment and nothing else. diff --git a/.changeset/auth-mount-ledger-and-docs.md b/.changeset/auth-mount-ledger-and-docs.md deleted file mode 100644 index 7bb37b08f6..0000000000 --- a/.changeset/auth-mount-ledger-and-docs.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -Ledger and document the ObjectStack-owned auth mounts that were in neither the route ledger nor the docs (#10534). - -`auth-plugin.ts` mounts 17 routes directly on the raw Hono app ahead of the better-auth catch-all. A census found **nine** of them in neither half of `auth-route-ledger.ts`, and **six** with no literal wire path anywhere in the hand-written docs — the state that let a mount and its documentation gap ship separately with nothing objecting. - -**Ledger:** eight mounts gain reviewed `source: 'objectstack'` rows — `/admin/import-users`, `/admin/oauth2/toggle-disabled`, `/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification`, `/admin/sso/verify-domain`, `/admin/unlock-user`, `/sys-oauth-application/register`. All are `disposition: 'server-only'`: each was measured to have zero `ObjectStackClient` callers and exactly one real caller that is a declarative metadata action target or a Console wizard. `POST /api/v1/auth/set-initial-password` is deliberately left unledgered and escalated rather than given a guessed disposition. - -**Docs:** `GET /api/v1/auth/bootstrap-status`, `POST /api/v1/auth/set-initial-password`, `POST /api/v1/auth/admin/unban-user`, `POST /api/v1/auth/admin/sso/register`, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` are now documented with their literal wire paths, including the opt-in `OS_SSO_DOMAIN_VERIFICATION` domain-verification flow and the asymmetric way its two halves report the switch being off. - -No route's mounting, behaviour or accept/reject set changes. diff --git a/.changeset/automation-write-manage-metadata-gate.md b/.changeset/automation-write-manage-metadata-gate.md deleted file mode 100644 index 055657e8aa..0000000000 --- a/.changeset/automation-write-manage-metadata-gate.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -**Behaviour change (security tightening):** the `/api/v1/automation` **definition writes** now require the `manage_metadata` capability (#10145). - -`POST /api/v1/automation`, `PUT /api/v1/automation/:name` and `DELETE /api/v1/automation/:name` — `automation.create` / `automation.update` / `automation.delete` on the SDK — were reachable by **any authenticated caller**. They now answer **403 `PERMISSION_DENIED`** unless the caller holds `manage_metadata` (ADR-0066 D1's authoring capability), the same key the sibling `PUT /api/v1/meta/:type/:name` and every state-changing `/api/v1/packages/*` route already demand. Engine self-invocation (`isSystem`) bypasses, as on every other capability gate. - -**Existing credentialed callers that author flows over HTTP will start getting 403** and must be granted `manage_metadata`. A flow is authored metadata: this closes the last write door onto the metadata plane that did not ask the metadata plane's question. - -What was measured on a walled multi-organization deployment (`OS_TENANCY_POSTURE=isolated`): a plain tenant org owner holding `organization_admin` — the same session answered 403 by `PUT /meta/:type/:name`, `POST /ai/tools/:tool/execute` and `POST /packages/*` — created, modified and deleted flows through this door, all 200. Flow definitions are registered at **environment** scope, not organization scope, so the write crossed the tenant wall: a shipped flow deleted by one tenant read 404 for the actor, for an unrelated tenant **and** for the platform admin, and an injected flow read 200 for all three. - -**Deliberately unchanged — execution is not authoring:** - -- `POST /automation/:name/trigger` and the legacy `POST /automation/trigger/:name` **run** a flow. They keep their existing posture (authenticated, plus the flow's own `runAs` authorization envelope). -- `POST /automation/:name/runs/:runId/resume` is already fail-closed through the suspended node's `resumeAuthority`; a metadata capability in front of it would refuse the very user the flow paused for. -- `POST /automation/:name/toggle` mutates engine enablement rather than a definition, and is filed separately rather than folded into a security fix. -- The reads (`GET /automation`, `GET /automation/:name`, the run surfaces) are untouched; run-state reads keep their `sys_automation_run` grant. - -The gate sits ahead of the service probe and ahead of body validation, so a refused caller neither writes anything nor learns from a 501-vs-403 whether the deployment mounts automation at all. diff --git a/.changeset/banner-reads-own-version.md b/.changeset/banner-reads-own-version.md deleted file mode 100644 index 9f52c35b2c..0000000000 --- a/.changeset/banner-reads-own-version.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"create-objectstack": patch ---- - -Fix `create-objectstack`'s startup banner hardcoding `◆ Create ObjectStack v6.x` -regardless of the package's real, released version — eleven majors stale, on -the first line of output a newcomer ever sees (#10325). The banner now calls -`readCliVersion()`, the same reader `.version()` already used, instead of a -literal string. - -Dropping the real version in without recomputing the box's padding would have -reintroduced the same defect one line later — the border is a fixed run of -`═` computed for the 4-character `v6.x`, and a longer real version (`v17.1.0` -is 7 characters) would push the right border out of alignment (the sibling -bug fixed in #10322, one function away in the same file). The box now derives -its width from the version string's plain length and widens the frame — never -truncates — for a version long enough to need more room; ordinary versions -still render at the historical box size. - -No behaviour change beyond the printed banner. diff --git a/.changeset/batch-identity-boot-seed-round-trips.md b/.changeset/batch-identity-boot-seed-round-trips.md deleted file mode 100644 index 674db4afc1..0000000000 --- a/.changeset/batch-identity-boot-seed-round-trips.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -Batch the identity boot seeds' existence read and stop re-writing rows that -already match the declaration (#10946). - -Every permission set and every position an environment declared cost **exactly -4 sequential database round trips on every kernel boot** — measured on a real -per-environment kernel build with every `@libsql/client` call counted: slope -4.0000, R² = 1.000000 on both axes, with a per-statement histogram naming the -four legs (2 × existence `SELECT`, 1 × `UPDATE`, 1 × `SELECT`). Two of the four -were an `UPDATE` that fired even when nothing had changed. On a local file -database the loop is invisible; on a remote libsql/Turso database — every hosted -environment — each leg is its own sequential HTTP request. Schema sync had -already been batched (`TursoDriver.supports.batchSchemaSync`), which is why -objects, views and artifact seeds add 0.00 round trips each on the same rig; -identity content was the one content axis still paying per item. - -Both loops now hoist **one** `{ name: { $in: [...] } }` existence read out of the -loop — the declaration is known in full before the loop starts — and write only -when the stored row actually differs from what would be written. A steady-state -rebuild of both loops is now O(1) round trips: measured in-repo against a -call-counting ObjectQL double, a rebuild of 1, 5, 20 and 40 declared items costs -1 round trip in every case, for permission sets and positions alike. - -Three things the change is careful **not** to become: - -- **Drift still reconciles.** The skip is on equality, never on "we have seen - this name": a row whose stored value differs — a package version bump, a - hand-edit, a partially applied write — still gets its `UPDATE`. An - implementation that skipped all writes would show the same round-trip curve - and silently stop reconciling, so the round-trip pins are paired one-for-one - with drift pins over the same fixtures. -- **A read that could not answer is not the answer "none exist."** A batched - read fails for the whole set at once, so swallowing its failure into `[]` - would make every boot conclude nothing is seeded and re-create everything. The - seam is judged on whether the driver returned a result set, never on whether - the array came back empty; a failed batched read degrades to the per-item read - (loudly warned), and a name whose record cannot be read at all is declined - rather than inserted. That last step is deliberately stricter than the code it - replaces, which turned a failed read into an insert attempt and leaned on the - `name` unique index to refuse it. -- **A converged publish is still a successful publish.** `PermissionSeedOutcome` - gains `unchanged` (rows that already matched) and `unreadable` (names declined - because their record could not be read). The ADR-0086 P2 publish materializer - asks "did the record end up matching the published body", which was - accidentally identical to "was a write issued" only because the seeder always - wrote; it now reads `seeded + updated + unchanged`, so every case that reported - a materialization before still reports one. A re-publish of a byte-identical - body reports `inserted: 0, updated: 0` instead of `updated: 1` — the one - reporting difference, and the truthful reading. - -`bootstrapDeclaredPositions` likewise returns `unchanged` and `unreadable` -alongside `seeded`/`updated`. diff --git a/.changeset/blank-template-console-disclosure.md b/.changeset/blank-template-console-disclosure.md deleted file mode 100644 index 34ba53975a..0000000000 --- a/.changeset/blank-template-console-disclosure.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"create-objectstack": patch ---- - -Tell a newcomer that the `blank` starter ships no app, so an empty Console -reads as the intended starting point rather than a broken install (#10317). - -Measured on a real scaffold-and-boot (`create-objectstack my-app -t blank`, -published 17.1.0 packages, `objectstack dev --ui`): `GET /api/v1/meta/app` -returns the two platform apps (Setup, Account) and nothing of the project's -own, while `GET /api/v1/data/my_app_note` serves the scaffolded object the -whole time. The template ships `src/objects/` only — deliberately, as every -scaffolder template in this repo does — but nothing the newcomer could reach -said so, and `pnpm dev` advertises the Console URL on every boot. - -Documentation only: a new "The Console" section in the generated `README.md` -naming the Console path, the consequence, and `src/apps/*.app.ts` as the -remedy. No change to what the scaffolder writes into `src/`. diff --git a/.changeset/blueprint-sharing-model.md b/.changeset/blueprint-sharing-model.md deleted file mode 100644 index c8db108721..0000000000 --- a/.changeset/blueprint-sharing-model.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Add an optional `sharingModel` slot (enum `private | public_read | public_read_write | controlled_by_parent`) to `BlueprintObjectSchema` and, as a required-but-nullable key, to the OpenAI-strict structured-output mirror (`SolutionBlueprintStrictSchema`). The propose-stage LLM can now author a deliberate Org-Wide Default (OWD) choice — e.g. `private` for an object the user described as personal/sensitive — instead of having the platform's deterministic default silently override the intent expressed at propose time. Omitting the key (or emitting `null` in the strict mirror) still defers to the platform default (business object → `public_read_write`, master-detail child → `controlled_by_parent`). diff --git a/.changeset/break-glass-guard-after-auth.md b/.changeset/break-glass-guard-after-auth.md deleted file mode 100644 index 2030eaddb4..0000000000 --- a/.changeset/break-glass-guard-after-auth.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -Run the break-glass last-local-credential guard after authentication - -The guard that refuses removal of the last local-password login was registered -as a better-auth `before` hook, which runs ahead of the endpoint middleware that -establishes identity. It therefore decided — and answered — a question about a -named user for a caller who had not been authenticated, while every neighbouring -route on the same lane answers with the ordinary "please log in" refusal. - -The guard now runs only once the acting user is resolved. An unauthenticated -caller falls through to the ordinary refusal and learns nothing about the named -user. For an authenticated caller nothing changes: the same lookup runs and the -same `LAST_LOCAL_CREDENTIAL` conflict is returned, so the lockout protection is -unaffected. diff --git a/.changeset/button-metric-icon-liveness.md b/.changeset/button-metric-icon-liveness.md deleted file mode 100644 index a9c3ae2a70..0000000000 --- a/.changeset/button-metric-icon-liveness.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): record the live read points of `element:button.icon` and `object-metric.icon` — the last two icon slots whose describes stated only the vocabulary (#10053) - -Both keys parsed and rendered while saying only what alphabet their value is -drawn from: `Icon name (Lucide icon)` and `Icon name (Lucide)`. That sentence is -equally true of the `page:header` `icon` retired in #6946 — refused *precisely -because no render path reads it* — so the prose could not separate a live key -from a dead one. It is the same absence that sent #9397 through a full dispatch -cycle re-deriving the accordion read point from scratch before the retirement -candidate was closed premise-overtaken. #9881 and #9972 recorded the accordion -and tab items; these two close the set for `component.zod.ts`. - -**Both are live**, re-measured rather than transcribed from the card. Note the -pin: the earlier records cite `82a94170c`, but `.objectui-sha` moved to -`9a3daf8d3` in #10137, and these were measured there. - -- `element:button.icon` — `packages/components/src/renderers/form/button.tsx:44-47` - resolves `schema.icon`, and `:69` / `:71` draw it either side of the label per - `iconPosition`, both suppressed while `loading`. -- `object-metric.icon` — `plugin-dashboard/src/index.tsx:161` publishes it as a - designer input; `ObjectMetricWidget.tsx:142` destructures it and forwards it at - `:474` to `MetricWidget`, which resolves it at `MetricWidget.tsx:312-321` and - draws it at `:373-382` in the `colorVariant`-tinted square. - -**The button is the one authorable icon on this surface that does not go through -`LazyIcon`**, and the docblock now says so, because the two paths are not -interchangeable: - -- button: `toPascalCase` (splits on `-` only) → a one-entry rename map - (`Home` → `House`) → `icons[name]` from `lucide-react`. An unknown name - resolves to `undefined` and the button renders with **no icon and no - diagnostic**. -- `LazyIcon` / `getLazyIcon` (`components/src/lib/lazy-icon.tsx:66-92`, the slot - the metric tile and every container icon use): normalises to kebab-case, - validates against Lucide's own name list, and degrades an unknown name to the - `Database` glyph. - -So a spelling that draws an icon in a tab trigger can draw nothing on a button — -previously discoverable only by reading two objectui files. - -**Nothing about what parses changes.** Both keys were already declared and -already optional; no key is widened, narrowed, retired or renamed. What is added -is the prose that makes each liveness verdict readable from the spec side alone, -and the accept-pins that keep it readable: per key, an accept carried through to -the parsed output, an undeclared-sibling refusal so the accept is not vacuous, -and an assertion that the `.describe()` still names its consumer. diff --git a/.changeset/canonical-docs-host-in-published-links.md b/.changeset/canonical-docs-host-in-published-links.md deleted file mode 100644 index fcaf9ca034..0000000000 --- a/.changeset/canonical-docs-host-in-published-links.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"create-objectstack": patch -"@objectstack/knowledge-ragflow": patch -"@objectstack/plugin-audit": patch -"@objectstack/service-analytics": patch -"@objectstack/service-automation": patch -"@objectstack/service-cache": patch -"@objectstack/service-i18n": patch -"@objectstack/service-job": patch -"@objectstack/service-knowledge": patch ---- - -Point every documentation link in these packages' published READMEs — and in -the project `create-objectstack` scaffolds — at the canonical docs origin -`https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. - -Both spellings reach the same pages (the alias redirects to the apex, -path-preserving), so no link was broken. The reason it needs a release rather -than an in-repo fix alone: a README ships inside the npm tarball, so the -version already on npm keeps showing the old host to every reader of the -package page until a new one is published. diff --git a/.changeset/canonical-docs-host-in-runtime-strings.md b/.changeset/canonical-docs-host-in-runtime-strings.md deleted file mode 100644 index d005fdfca5..0000000000 --- a/.changeset/canonical-docs-host-in-runtime-strings.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/plugin-security': patch -'@objectstack/studio': patch -'@objectstack/setup': patch -'@objectstack/spec': patch -'@objectstack/cli': patch ---- - -Point every runtime-emitted documentation URL at the canonical host, and retarget the -metadata-protection `docsUrl` at a page that actually exists. - -Two defects, one string. The host half: `docs.objectstack.ai` is an alias that redirects -to `https://objectstack.ai` path-preservingly, so nothing here was a broken link — it was -the unratified spelling sitting in the places a user copies from. The CLI's spec-version -advisory, the Setup and Studio in-app overview pages (English and Chinese alike), and a -showcase demo action now all name the canonical host. - -The path half is the real fix. All 29 `protection.docsUrl` values on the platform's -system objects and apps pointed at `/adr/0010-metadata-protection`, and `/adr/...` is not -a route on any host: the docs site mounts `content/docs` under `/docs`, `docs/adr/` is -not published, and no redirect source lives outside the `/docs` space. The slug was wrong -too — the record is `0010-metadata-protection-model.md`. Studio renders this URL as a -link in the lock banner, so an operator asking why an item is locked was being sent -nowhere. They now point at `https://objectstack.ai/docs/references/shared/protection`, -the published reference for the very schema that carries the field. diff --git a/.changeset/capability-gate-update-verb.md b/.changeset/capability-gate-update-verb.md deleted file mode 100644 index 60b92f8500..0000000000 --- a/.changeset/capability-gate-update-verb.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -**Behaviour change (tightening):** `enable.files` / `enable.feeds` are now enforced on the **update** verb, not only on insert (#10170). - -Both capability gates in `audit-writers.ts` registered on `beforeInsert` only. `enable.files` says whether `sys_attachment` rows may **target** an object and `enable.feeds` whether `sys_comment` rows may target it — properties of the target object, not of the verb that got a row there — so a re-point via update landed rows the declaration refuses: a caller who could not *create* an attachment on an object without `enable.files: true` could *move* an existing one onto it, and a comment could be re-threaded into a `feeds: false` object's thread. The access kits authorize the re-point (`comment-access-hooks.ts` since #4630, `attachment-access-hooks.ts` since #10091), but those are **access** checks — the capability half was never asked on update. - -What an operator will now observe: - -- An update of `sys_attachment` whose payload sets `parent_object` to an object that does not declare `enable: { files: true }` is refused with **403 `FILES_DISABLED`** — the same envelope the insert path has emitted since #2727 (ADR-0112: `code` + `status`). Fail-closed as on insert: an absent `enable` block, an absent flag, and an unknown parent object all reject. -- An update of `sys_comment` whose payload sets `thread_id` to a thread on an object declaring `enable: { feeds: false }` is refused with **403 `FEEDS_DISABLED`**. Opt-out semantics as on insert: only an explicit `false` rejects, and a missing or free-form `thread_id` is still allowed through — this is capability gating, not access control. -- Both apply on **both dispatch shapes**: a by-id update (`dispatch.mode` `record`) and a predicate `multi: true` update, which is evaluated per matched row (#5574 / ADR-0058 Addendum II). An unscoped predicate update is refused on its first matched row. - -**No existing row is newly refused, and no update that is not a re-point changes.** The gates read the payload: an update that never names `parent_object` / `thread_id` returns on the gate's first line, so renames, body edits, reaction writes and other column updates on a row whose parent object has since had the capability flipped off keep working exactly as before. Only a write that makes a row *newly target* a walled object is refused. - -**Blast radius.** A structural sweep of the 4 660 in-tree source files found **no** caller — none in `packages/` source, `examples/`, or the dogfood apps — that issues an update whose payload names `parent_object`, and none that re-points `thread_id`; in the console the only `sys_attachment` write is a create, and the only `sys_comment` update writes `reactions`. If you have your own "move this attachment" or "move this comment" flow, point it at a target object that declares the capability, or declare it on the target. - -No new error code: both codes are existing standard-catalog members already registered in `packages/spec/src/api/error-code-ledger.zod.ts` and already mapped to 403 by `packages/rest/src/error-response.ts`. diff --git a/.changeset/capability-loop-host-copy-first.md b/.changeset/capability-loop-host-copy-first.md deleted file mode 100644 index 5619243047..0000000000 --- a/.changeset/capability-loop-host-copy-first.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -Stop claiming the `os serve` capability loop loads a "host copy first" (#10909). -Two module-header comments in `packages/cli/src/commands/serve.ts` — above the -`@objectstack/plugin-email` and `@objectstack/service-sms` imports — described -the capability loop (`Serve.CAPABILITY_PROVIDERS`, the `for (const cap of -requires)` block) as resolving `EmailServicePlugin`/`SmsServicePlugin` "host -copy first". Measured at head, the loop does a bare `await import(spec.pkg)` / -`await import(ex.pkg)` — no `importFromHost` in either path — which Node ESM -resolves against **this CLI's own** realpath, so the CLI's bundled copy always -wins; the host app's copy is never consulted. The comments described a -behaviour the code does not have. - -The corrected comments also name the contrast the file now actually contains: -`Serve.importConfigPlugin` (the served app's own `plugins: [...]` entries) IS -host-anchored — an app-declared package wins there — while the capability -loop is not. Making that split legible is the point of the fix, so the next -reader does not assume one resolution rule governs the whole file. - -Comment-only: no runtime path, resolution order, or accepted specifier changes. -All 21 `CAPABILITY_PROVIDERS` packages remain CLI-declared, so bare resolution -still finds every one of them today — this only corrects what the comment -claims about *how* that resolution happens. diff --git a/.changeset/clean-first-install-peer-warnings.md b/.changeset/clean-first-install-peer-warnings.md deleted file mode 100644 index c3ebe6f919..0000000000 --- a/.changeset/clean-first-install-peer-warnings.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"create-objectstack": patch -"@objectstack/cli": patch ---- - -**First-run polish:** a brand-new scaffold's very first `pnpm install` no longer reports two unmet peer dependencies (#10326). - -Reproduced on a clean scaffold from published `create-objectstack@17.1.0` — no lockfile, `node_modules` removed, nothing configured by the user — and again on the second scaffold path, `objectstack init`. Both printed the same two: - -``` -✕ unmet peer better-call - Installed: 1.4.0 - Wanted: - 1.3.7: - @better-auth/scim@1.7.0-rc.1 - -✕ unmet peer better-sqlite3 - Installed: 13.0.3 - Wanted: - ^12.0.0: - better-auth@1.7.1 -``` - -Nothing was broken — but it is the first screen a newcomer sees, and there is nothing they did to cause it or can do about it. - -**`better-sqlite3`: the pin is right and the upstream range is stale — so it is widened, not corrected.** better-auth 1.7.1 declares `better-sqlite3` as an **optional** peer at `^12.0.0`, and it governs exactly one configuration: a raw better-sqlite3 `Database` handed to better-auth's `database` option, which its Kysely dialect then drives. ObjectStack never takes that path — `AuthManager.createDatabaseConfig()` returns `createObjectQLAdapterFactory(dataEngine)`, and every `better-sqlite3` use under `plugin-auth` is knex's `client: 'better-sqlite3'` beneath ObjectQL. Measured anyway on the configuration the range *does* govern: better-auth 1.7.1 with `database: new Database(':memory:')`, running `getMigrations().runMigrations()`, `signUpEmail`, `signInEmail` and adapter `findOne`/`update`/`delete`, is green on **better-sqlite3 13.0.3** and byte-for-byte equivalent on **12.11.1**. The same probe with `Database.prototype.prepare` neutered fails, so that green is the driver's and not an unexercised path. Pinning our own `^13.0.3` declarations back to `^12` would downgrade a native module across the platform to satisfy a range measurement shows is simply behind. - -**`@better-auth/scim`: the rc pin stays, and one `better-call` copy is the correct tree.** `npm view @better-auth/scim dist-tags` reads `latest: '1.7.1'`, but stable 1.7.x ships the rc.2 whole-model rewrite, so adopting it is a separate migration rather than a version bump; the exact `1.7.0-rc.1` pin is deliberate. The rc peers an exact `better-call@1.3.7` while better-auth 1.7.1 depends on `1.4.0` — and a better-auth plugin has to share the **host's** better-call instance, so the single 1.4.0 copy every install already resolves is right, not a skew to repair. This declaration retires together with the rc pin. - -**What changed, and what deliberately did not.** Both remedies are pnpm `peerDependencyRules.allowedVersions` entries, scoped `>` so each widens exactly one declaration. They ship *inside* the scaffold — the bundled `pnpm-workspace.yaml` template and the one `objectstack init` renders — because a block in this repo's own workspace file does not travel with published packages. `allowedVersions` changes what pnpm **reports**, never what it resolves: measured on both scaffold paths, the lockfile is byte-identical with and without it (0 lines of diff), and no dependency version, range or resolution moved anywhere. This repo's own resolutions are untouched. diff --git a/.changeset/cli-invocation-loudness.md b/.changeset/cli-invocation-loudness.md deleted file mode 100644 index 9d303eb061..0000000000 --- a/.changeset/cli-invocation-loudness.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -Two ways to invoke the CLI wrong used to present as a crashed boot. Both now say what they are. - -`node packages/cli/dist/index.js` — the package `main`, which is a re-export barrel — ran to completion, printed nothing and exited 0. Backgrounded, that is indistinguishable from a server that came up and died. It now writes two lines to stderr, the first saying that running this file starts nothing and the second naming `bin/run.js` as the CLI entry point, and exits 1. - -A rejected invocation such as `objectstack dev --no-ui` answered with oclif's error line followed by a full usage dump, and in a background log the dump is what the eye lands on. One line now goes to stderr ahead of it: - -``` -objectstack: INVOCATION ERROR — Nonexistent flag: --no-ui. The command never ran: nothing was started and nothing is listening. Invoked as: objectstack dev --no-ui -``` - -No flag surface changed: `dev` still rejects `--no-ui` (only `serve` declares `ui` with `allowNo`). What changed is what the CLI says when it rejects an invocation. diff --git a/.changeset/cli-readme-drop-phantom-codemod.md b/.changeset/cli-readme-drop-phantom-codemod.md deleted file mode 100644 index 33f8567609..0000000000 --- a/.changeset/cli-readme-drop-phantom-codemod.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -docs(cli): drop the phantom `os codemod v2-to-v3` claim and stale `projects/` tree node (#10881) - -`packages/cli/README.md` is this package's published README, and it carried two -false claims about what the CLI can do. - -Its "Code Transforms" section tabled `os codemod v2-to-v3` in the exact same -format as the ~60 real commands above it, and the Architecture source-tree -listing showed a matching `src/commands/codemod/v2-to-v3.ts`. Neither the -command nor the file has ever existed: `packages/cli/src/commands/` has no -`codemod/` directory, and oclif (which resolves commands by globbing -`dist/commands/**/*.js`) returns `command codemod:v2-to-v3 not found` (exit 2). -Removed rather than marked "not yet available", because a reader-facing -row/node with that exact name and shape would still misdescribe the one -concrete plan for this space: #9591 (`os migrate meta --write`, on hold, -targeting v18) is a differently-scoped, differently-named command over the -mechanical retired-key set, not a "v2 config to v3 format" transform — so there -is no accurate future command to point the row at. The "not yet available" -information already lives in `content/docs/protocol/backward-compatibility.mdx` -and `docs/DX_ROADMAP.md`; this brings the package README in line with the tool -itself, which lost the same false prescription in #10882. - -The same source-tree listing also showed a `projects/` node under -`src/commands/` with `list/show/create/switch/bind` — stale since the v5.0 -`project` → `environment` rename (ADR-0006, no aliases). Renamed to -`environments/`, matching the real directory; the subcommand file list was -already accurate and is unchanged. diff --git a/.changeset/cli-readme-drop-projects-prose.md b/.changeset/cli-readme-drop-projects-prose.md deleted file mode 100644 index 08b2eac010..0000000000 --- a/.changeset/cli-readme-drop-projects-prose.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -docs(cli): correct three more stale `os projects` mentions in the README prose (#10927) - -Follow-up to #10881, which renamed the Architecture source-tree node. This -covers the three remaining places in `packages/cli/README.md` that described -an `os projects` command surface as if it still resolved: - -- The Cloud command table (`:75`) claimed `os projects create` was a - registered **alias** of `os environments create`. It is not: none of the - five files in `packages/cli/src/commands/environments/` (`list.ts`, - `show.ts`, `create.ts`, `switch.ts`, `bind.ts`) declares an `aliases` static - field, and neither does the `oclif` block in `packages/cli/package.json`. - Reworded to name it as the pre-rename spelling instead: "was `os projects - create` before the v5.0 project → environment rename (ADR-0006, no - aliases)". -- The Plugin Management prose (`:98`) called `os projects bind ...` a - "legacy" path that "still binds" an artifact — implying a working - fallback. Replaced with the real current invocation, `os environments - bind ...`. -- The Typical Workflow example (`:278`) used `os projects bind ...` directly, - with no caveat at all. Same replacement, and the trailing comment ("Bind to - a Cloud Project") is updated to "Cloud environment" to match — "Project" - now means only the npm/monorepo sense post-rename (ADR-0006). - -Verified against the built binary (`packages/cli/bin/run.js`), matching the -falsification standard from triage: the old spellings still fail — -`Error: Command projects:create not found.` / `Error: Command projects:bind -not found.` (exit 2, both) — and the new spellings resolve — `os environments -create --help` and `os environments bind --help` both exit 0 and print their -real flag/argument help. diff --git a/.changeset/cli-retire-abandoned-tsup-config.md b/.changeset/cli-retire-abandoned-tsup-config.md deleted file mode 100644 index 10a4cbad73..0000000000 --- a/.changeset/cli-retire-abandoned-tsup-config.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -Remove the abandoned tsup build path from `packages/cli` (#10185): the -`tsup.config.ts`, the orphaned `src/bin.ts` it was the only referrer of, and -the now-unused `tsup` devDependency. - -The package has built with `tsc -p tsconfig.build.json` since the oclif -migration, which also introduced `oclif.commands.target: "./dist/commands"` -and moved the `bin` field onto `bin/run.js`. The tsup config was left behind -by that commit and never invoked again — but it was not inert. It declared -`clean: true` with only `src/bin.ts` and `src/index.ts` as entries, so anyone -running the obvious `tsup` next to a `tsup.config.ts` would wipe `dist/` and -emit no `dist/commands/**` at all, leaving a CLI that resolves zero commands. -Deleting it removes the trap rather than documenting it. - -No published behaviour changes: the resolved oclif command surface is -identical before and after (60 commands, 68 topics). The only build-output -difference is that `dist/bin.js` — a re-export of `execute` from -`@oclif/core` that nothing imported — is no longer emitted. diff --git a/.changeset/cli-serve-host-anchored-cluster-import.md b/.changeset/cli-serve-host-anchored-cluster-import.md deleted file mode 100644 index 9778ee09b5..0000000000 --- a/.changeset/cli-serve-host-anchored-cluster-import.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -Fix `os serve` failing to boot with `OS_CLUSTER_DRIVER=redis` when the app -declares `@objectstack/service-cluster` (#10645). The cluster gate and its -driver were reached through a bare dynamic `import()`, which Node ESM resolves -against the CLI's own realpath — inside the framework workspace — so packages -installed under the host app were invisible to it and boot died with -`Cannot find package '@objectstack/service-cluster'`. Both loads now go through -the host-anchored importer `serve` already uses for its other optional and -enterprise packages, so any package the app declares resolves the way the app -declares it. The host importer is now defined at the top of the boot sequence -rather than partway down, which is what made these two loads fall back to bare -resolution in the first place. No change to what `serve` accepts or refuses: -an undeclared package is still refused by the same declaration gate. diff --git a/.changeset/close-terminates-watch-iterators.md b/.changeset/close-terminates-watch-iterators.md deleted file mode 100644 index 996db61f9c..0000000000 --- a/.changeset/close-terminates-watch-iterators.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -`SysMetadataRepository.close()` now terminates every live `watch()` iterator -instead of broadcasting a synthetic drain event (#11021). A consumer holding a -`for await` over `watch()` at shutdown could hang forever, and the hang was -worst for the subscription shapes most likely to be in use. - -Shutdown was modelled as a metadata event — `{ seq: -1, ref: { org: '', type: -'view', name: '_close' } }` — pushed through the same dispatch closure real -events pass, followed by clearing the watcher registry. Both of that closure's -guards reject it: - -- `matchesFilter` drops it for any subscription naming an `org` (the synthetic - ref's org is the empty string), a `type` other than `view`, or a `name` — - `MetadataCache.start()` with any non-empty `watchFilter` is exactly that - shape; -- the `since` drop-filter drops it for every numeric-`since` subscription, - since `-1 <= since` holds against every real seq. - -Dropped and then unsubscribed, nothing could settle the parked promise. Measured -before the fix: `watch({org:'system'}, seq)` and `watch({org:'system'})` were -both still unsettled 500ms after `close()`. The empty-filter case looked drained -and was not — it received the synthetic event as a *real* one (a `view` named -`_close`, deleted, at seq -1, which `MetadataManager` turns into a cache -invalidation and re-emits to Studio's HMR stream) and then hung on the next pull -anyway, because delivering an event does not end an iterator. - -`close()` now runs each subscription's terminator — the same routine the -consumer's own `iterator.return()` runs — so a parked `next()` settles with -`{ done: true }` and no value, and so does every later one. Consumers no longer -need to recognise a shutdown event, because there is no longer one to recognise; -nothing in the repo ever named the `_close` sentinel. - -The contract this repairs was unstated, which is why the two defensible repair -shapes were both arguable. It is stated now: invariant 8 in -`packages/metadata-core/src/repository.ts` ("shutdown terminates; it does not -emit") says what a repository-level `close()` owes a pending iterator, and -records the one measured non-conformance among today's implementations -(`FileSystemRepository`, filed as #11127). diff --git a/.changeset/companion-source-never-primary-key.md b/.changeset/companion-source-never-primary-key.md deleted file mode 100644 index 674704b49b..0000000000 --- a/.changeset/companion-source-never-primary-key.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -The `__search` companion is no longer provisioned or backfilled on objects whose only companion source is the primary key (#10290) - -`resolveSearchCompanionSources` resolves the companion's source through -ADR-0079's `resolveDisplayField`. That derivation ends at "first title-eligible -field by declaration order", and on a table whose only text column IS its -primary key — system tables, junction tables, append-only logs — it lands on -`id`. `id` is `type: 'text'`, not hidden and carries no `requiredPermissions`, -so it passed the eligibility gate: `provisionSearchCompanion` declared a -`__search` column on those objects and `plugin-pinyin-search`'s backfill walked -them at every boot. - -That work is doomed by construction rather than merely unlikely. Both writers — -the `beforeInsert`/`beforeUpdate` stamp and the boot backfill — gate on -`containsCJK(row[source])`, and a platform-generated primary key is ASCII by -construction, so the predicate can never be true. Measured on a real -`bootStack` of `examples/app-showcase`: **20 of the 66 objects** the backfill -enumerated were in this state, walking whole platform tables to compute nothing -— `sys_secret`, `sys_oauth_access_token` and `sys_jwks` among them. - -`resolveSearchCompanionSources` now returns `[]` when the resolved display -field is the record's primary key, and `isPrimaryKeyField` is exported as the -named judgement behind it. - -**Keyed on the field's ROLE, not on "resolved by fallback".** The registry's -materialization seam runs `provisionPrimary(schema, { synthesize: false })` -before this module — a contractual order — and that pass writes `nameField: -'id'` onto the document, so by the time provisioning asks, a derived fallback -and an author's explicit pointer are byte-identical. The role is readable from -the name because that is where the platform keeps it: the driver provisions -`id` on every physical table unconditionally and there is no per-field -`primaryKey` marker in the spec, which is why `isPreservableUnderAudit` already -keys on `SystemFieldName.ID` for the same reason. `_id` is refused as the -alternate spelling of the same address. - -**This interprets ADR-0079, it does not amend it.** The title contract is -untouched: `resolveDisplayField` still resolves `id`, `provisionPrimary` still -designates it, and `resolveRecordDisplayName` still renders the `Record #` -floor. Only the search normalizer declines to take its input from there — the -same distinction #4483 drew one seam over on the READ path, where the display -field's job in the `$search` auto-default is to ORDER the set and never to -ADMIT a field the exclusions already rejected (`SEARCH_AUTO_EXCLUDED_FIELDS` -names `id` and `_id`). - -**What does not change.** Existing permanently-NULL `__search` columns on -already-migrated tables stay: ADR-0045 migrations are additive and dropping a -physical column is a separate decision. Those deployments still stop walking — -the backfill skips an object whose sources resolve empty even when its schema -still declares the column. Objects with a real name/title field are unaffected: -provisioning, write-time stamping and the query-time `$or` clause all behave -exactly as before, including when the object also declares an `id` field and -when its display field is a plain text column that is not named `name`/`title`. diff --git a/.changeset/converge-blank-template-docs-host.md b/.changeset/converge-blank-template-docs-host.md deleted file mode 100644 index 5bed6f8098..0000000000 --- a/.changeset/converge-blank-template-docs-host.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"create-objectstack": patch ---- - -Converge the blank scaffold template's `README.md` docs links on the ruled -canonical origin, `https://objectstack.ai` (maintainer ruling, 2026-08-21: -「这个仓的文档站规范 URL 是 https://objectstack.ai」; enforced by -`CANONICAL_DOCS_ORIGIN` in `scripts/check-published-readme-links.mjs`). The -template previously linked the accepted-but-unratified `docs.objectstack.ai` -alias in three places, which disagreed with the root `README.md`'s already- -canonical spelling — so a single `npm create objectstack@latest` run handed -the user two different hostnames for the same docs site. diff --git a/.changeset/count-opt-out-and-permission-set-memo.md b/.changeset/count-opt-out-and-permission-set-memo.md deleted file mode 100644 index fd7b13e7eb..0000000000 --- a/.changeset/count-opt-out-and-permission-set-memo.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor -"@objectstack/plugin-security": patch ---- - -Stop issuing two DB queries for questions already answered earlier in the same -request (#10757). One authenticated `GET /data/:object?$top=1` measured **24 DB -queries before, 23 after** — **22** when the caller opts out of the count. -Measured with `X-OS-Debug-Timing: json` on `pnpm dev:crm`, whose `Server-Timing` -carries `db;dur=…;desc="N queries"`. - -**`$count=false` now skips the COUNT query** (`@objectstack/metadata-protocol`). -The parameter has been declared (`ODataQuerySchema.$count`), aliased on the wire -(`$count` → `count`), reserved out of the implicit-field-filter bucket, -arity-checked and boolean-coerced for a long time — and then deleted unread, so -every paginated list ran `engine.count()` whether or not the caller wanted a -total. It is honoured now: - -``` -GET /data/task?$top=25 → { records, total, hasMore } (unchanged) -GET /data/task?$top=25&$count=true → { records, total, hasMore } (unchanged) -GET /data/task?$top=25&$count=false → { records, hasMore } (no COUNT query) -``` - -Read the shape of that carefully before adopting it: - -- **Only an explicit `false` opts out.** An ABSENT `$count` still counts and - still reports `total`. OData reads absent as "omit the count", and taking that - reading here would silently strip `total` from every existing caller — none of - them send the parameter, all of them read the number. The asymmetry is - deliberate and pinned by tests. -- **`total` is OMITTED, never estimated.** `FindDataResponse.total` is declared - optional ("if requested"), so absent is the declared shape for "not - requested". A caller that opted out and then reads `total` gets `undefined`, - not a plausible-looking guess — guard the read (`total ?? undefined`) or do - not send `$count=false`. -- **`hasMore` is still answered**, from the page alone: a full page means there - may be more. Same page-local rule the `$search` path already uses. - -**A find and its COUNT resolve permission sets once, not twice** -(`@objectstack/plugin-security`). `findData` answers a paginated list with two -engine operations, and the security middleware runs on both; each pass re-read -`sys_permission_set` for the same context with identical bindings. The -resolution is now memoized per execution context — a `WeakMap` keyed on the -context object, which is built once per request and collected with it, so -nothing outlives the caller it was resolved for — and **retired by any write**: -a process-wide epoch is bumped on every `insert`/`update`/`delete` the engine -middleware sees, ahead of the `isSystem` bypass so a seeder, a package publish -or an auto-org-admin grant invalidates too. A context whose grants are rewritten -in place re-resolves as well (the memo key covers `positions`, `permissions`, -`principalKind` and the presence of `userId`). No authorization answer is reused -across a write, across a context, or across a request. - -Not a fix for the whole cost: the remaining ~22 queries per authenticated -request are session resolution, grant resolution, localization and metadata -reads that repeat on every request. Removing those needs cross-request caching -with an invalidation design, which is deliberately not in this change. diff --git a/.changeset/create-objectstack-bin-exec-bit.md b/.changeset/create-objectstack-bin-exec-bit.md deleted file mode 100644 index e4da0cbc72..0000000000 --- a/.changeset/create-objectstack-bin-exec-bit.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"create-objectstack": patch ---- - -Fix the declared bin (`bin/create-objectstack.js`) being tracked non-executable -in git. It carries a `#!/usr/bin/env node` shebang and is pnpm's link target -for the `create-objectstack` command, but was committed `100644` instead of -`100755` — matching the sibling declared bin `packages/cli/bin/run.js`, which -was already tracked executable. - -Patch bump: this is a packaging-mode correction with no content, API or -behavior change (the blob hash is identical) — it only fixes how the file is -tracked in git and therefore how it is packed for npm. diff --git a/.changeset/csrf-localhost-trio-dev-only.md b/.changeset/csrf-localhost-trio-dev-only.md deleted file mode 100644 index 1ee559580c..0000000000 --- a/.changeset/csrf-localhost-trio-dev-only.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -Gate the localhost trusted-origin substitution to non-production (#10366). - -`AuthManager`'s `trustedOrigins` block substituted a localhost wildcard trio -(`http://localhost:*`, `http://*.localhost:*`, `https://*.localhost:*`) whenever -the resolved trusted-origin list came out empty and `OS_CORS_ORIGIN` was unset -or `*`. Its own comment described this as a development convenience, but the -condition tested only emptiness — it carried no `NODE_ENV` term, no dev-mode -term, nothing. A production deployment that reached it with an empty list -CSRF-trusted every `localhost` and `*.localhost` origin. The declared boundary -and the enforced boundary disagreed, and only the declared one was visible in -the file. - -The substitution is now gated on `NODE_ENV !== 'production'`, the same dev -signal already used by the fallback auth secret and by the dev `Origin` -synthesis in the same file. The property enforced: **a development convenience -exists only outside production.** - -**What production receives instead.** With the trio gated off and the list -empty, the block's tail omits `trustedOrigins` from the better-auth config -entirely. That is not an absent policy. Measured against the installed -better-auth 1.7.1: `getTrustedOrigins` -(`dist/context/helpers.mjs`) unconditionally seeds the trusted set from the -resolved `baseURL` origin and treats `options.trustedOrigins` as purely -**additive**, so an omitted key and an empty array are equivalent — both leave -exactly the deployment's own origin trusted, and `validateOrigin` -(`dist/api/middlewares/origin-check.mjs`) refuses everything else with -`403 INVALID_ORIGIN`. - -**Who is affected.** Deployments with an explicitly configured `trustedOrigins`, -or one derived from `OS_CORS_ORIGIN`, are unchanged in production — the -substitution never fired for them. Non-production behaviour is unchanged, -including under `NODE_ENV=test` and when `NODE_ENV` is unset. A production -deployment that was relying on the substitution to reach its own login page -now receives a loud `403` rather than silent over-trust; the remedy is to set -`OS_TRUSTED_ORIGINS`, or to fix the base URL that resolved unusable (PR #10369's -boot diagnostic already names that condition at startup). - -Both existing pins keep their dev-only assertions verbatim; new pins cover the -production omission, the non-production legs, the SSO per-request-function -shape, and — load-bearing — that explicitly configured and `OS_CORS_ORIGIN`-derived -trust survives in production. diff --git a/.changeset/data-hooks-tsdoc-records-key.md b/.changeset/data-hooks-tsdoc-records-key.md deleted file mode 100644 index 7cda522c4a..0000000000 --- a/.changeset/data-hooks-tsdoc-records-key.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/client-react": patch ---- - -Fix the `useQuery` and `usePagination` TSDoc `@example` blocks in -`packages/client-react/src/data-hooks.tsx`, which read `data?.value` — a key -`PaginatedResult` (declared at `packages/client/src/index.ts:310`) does not -have. `PaginatedResult` declares exactly `records`, `total`, `object`, and -`hasMore`, so `data?.value` was always `undefined`; once the query resolved, -`data` was a real object and `.map` on `undefined` threw, taking the copied -component down. Both examples now read `data?.records`, matching the -hand-written doc that covers the same hooks (`content/docs/api/client-sdk.mdx`). - -Swept all four `@example` blocks in the file: the `useMutation` and -`useInfiniteQuery` examples never referenced `.value` and needed no change. diff --git a/.changeset/datasource-cli-envelope-unwrap.md b/.changeset/datasource-cli-envelope-unwrap.md deleted file mode 100644 index d0a82a3018..0000000000 --- a/.changeset/datasource-cli-envelope-unwrap.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -**Fix:** `os datasource list-tables`, `os datasource introspect` and -`os datasource validate` now read the response envelope the server actually -emits, so all three work against a live server for the first time (#10675). - -The three commands read the pre-#3843 **flat** shape — `body.tables`, -`body.draft`, `body.results`, and `body.error` as a string — while every REST -body the platform sends is the declared envelope written by `sendOk` / -`sendError`: `{ success: true, data: { … } }` or -`{ success: false, error: { code, message } }`. Nothing failed loudly, because -each payload simply read `undefined` and every command reported that as an -ordinary empty result: - -- `list-tables` printed `No remote tables found.` while the server was - returning two tables. -- `introspect` printed `Failed to generate draft` for drafts the server had - generated. -- `validate` printed `No federated objects to validate.` and exited **0** - against drift the server had flagged `missing_column … severity:error` — a - schema gate green-lighting a CI-breaking condition it had never read. -- An unknown datasource crashed with `TypeError: first argument must be a - string or instance of Error`, because the error **object** was handed to - oclif's `this.error()` instead of `error.message`. - -`validate`'s exit code is the behaviour change to note: a datasource whose -federated objects have drifted now exits **1** where it previously exited 0. If -you have a pipeline that treats this command as advisory, it starts failing on -drift that was always there. - -A body that is **not** the declared envelope is now a loud failure rather than -an empty payload. That distinction is the point: "nothing found" is reachable -only from a server that really said so, never from a response the CLI could not -read. The legacy flat shape is deliberately *not* also accepted — a -consumer-side fallback would re-create the divergence as a second de-facto -contract. diff --git a/.changeset/datasource-validate-scoped-to-url.md b/.changeset/datasource-validate-scoped-to-url.md deleted file mode 100644 index 3b3c26b4e3..0000000000 --- a/.changeset/datasource-validate-scoped-to-url.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/rest": patch -"@objectstack/service-datasource": patch ---- - -fix(rest): `POST /datasources/:name/external/validate` does URL-scoped work (#10537) - -The route asked the `external-datasource` service for `validateAll()` — every -federated object on every federated datasource, each validation driving a live -`introspect(datasource)` remote-schema read — and then kept only the rows whose -`datasource` matched the URL. The rows it kept were correct; the *work* was not -scoped, so one datasource's health check paid for N datasources' remote -round-trips and threw most of the measurement away. An unreachable *unrelated* -remote slowed the answer for the datasource actually asked about (and produced -rows that were then filtered off). - -Measured at the branch point, through the real Hono adapter and the real -`ExternalDatasourceService` over a recording introspector: a request for one of -three federated datasources introspected `['wh_a', 'wh_b', 'wh_c']`. A request -naming a datasource that does not exist introspected all three as well, to -answer the empty report it already answered. - -`ExternalDatasourceService` now carries `validateDatasource(datasource)`, the -scoped twin of the sweep composed from the same primitives (`listObjects` → -filter → `validateObject`) and the same per-object catch, and the route calls -it. Same request answers `['wh_a']`; an unknown name answers `[]`. - -**No response change.** The rows the post-filter used to keep are the rows the -scoped composition returns — same objects, same diffs, same `data.ok` verdict, -same `200`, the same `400 EXTERNAL_DATASOURCE_ERROR` when the service refuses, -the same `503 SERVICE_UNAVAILABLE` when federation is not wired in, and an -unknown `:name` still answers an empty, vacuously `ok` report rather than a -`404`. The selection is keyed on `o.datasource ?? 'default'`, which is exactly -the value `validateObject` reports back as `result.datasource`, so "the rows the -sweep would have kept" and "the objects this selects" are the same set — pinned -directly, in both packages, by comparing the scoped answer against the -sweep-then-filter answer rather than against a remembered body. - -Because the output was already right, the pins that matter here are about the -CALL RECORD, not the body: `external-datasource-validate-scope.test.ts` asserts -which datasources were introspected and that `validateAll()` is not called at -all, over a fixture carrying three federated datasources so the assertion can -actually fail. A body-only test passes on both sides of this change. - -`validateDatasource` is **not** on `IExternalDatasourceService`: the contract -offers `validateObject(objectName)` and `validateAll()`, and adding a -per-datasource spelling to it is a spec-surface decision to take on its own -terms. The composition therefore lives in the service — the only registrant of -the `external-datasource` slot — and the REST registrar probes for it. A wired -service with no scoped spelling takes the same `503` arm every other route in -this family takes when the service cannot serve it, deliberately *not* a silent -fallback to the fan-out: a fallback would leave the old behaviour reachable on a -path no test drives. - -Unchanged: `validateAll()` itself, and the boot-validation sweep in -`packages/runtime` that legitimately validates every federated object. diff --git a/.changeset/delivery-dispatcher-sweep-tenant-classification.md b/.changeset/delivery-dispatcher-sweep-tenant-classification.md deleted file mode 100644 index 5f988c1867..0000000000 --- a/.changeset/delivery-dispatcher-sweep-tenant-classification.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/service-messaging": patch ---- - -Classify the delivery dispatchers' predicate writes on `sys_http_delivery` and -`sys_notification_delivery` as global environment sweeps (#10673). On a walled -deployment (`OS_TENANCY_POSTURE=isolated|group`) the SQL driver's tenant-audit -gate reported every `updateMany` these outboxes issue from the claim path as an -un-isolated write. The audit was right to ask: both objects are tenant-scoped -via `organization_id`. The answer is that these six writes — the -visibility-timeout reap and the atomic claim in `SqlHttpOutbox.claim`, -`SqlNotificationOutbox.claim` and `SqlNotificationOutbox.claimDigest` — are -issued by a `setInterval` dispatcher tick under a cluster lock, with no request -context and no tenant anywhere in the `ClaimOptions` contract, and they must -cross organizations: one outbox drains the whole environment's queue, so a -per-organization predicate would strand every other organization's deliveries. -They now pass `bypassTenantAudit` through a single documented helper that -carries that warrant. Diagnostics only — per its spec the flag never changes -what a write touches, and the row-level `ack` / `redeliver` writes are -unaffected. diff --git a/.changeset/delivery-update-op-tenant-classification.md b/.changeset/delivery-update-op-tenant-classification.md deleted file mode 100644 index 2b3304a106..0000000000 --- a/.changeset/delivery-update-op-tenant-classification.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/service-messaging": minor -"@objectstack/plugin-webhooks": minor ---- - -fix(service-messaging,plugin-webhooks): the `update`-op tenant-audit surface on the delivery outboxes is classified — `ack` is a dispatcher sweep, `redeliver` threads the caller's tenant (#10740) - -**BREAKING** signature change on `IHttpOutbox.redeliver` and -`MessagingService.redeliverHttp`, shipped as `minor` under the repo's -launch-window convention for breaking changes. - -`sys_http_delivery` and `sys_notification_delivery` carry three single-record -(`multi: false`) writes that the SQL driver audits under the **`update`** op — -a different op, and a different throttle key, from the `updateMany` half -classified previously. Their correct classifications are **opposite**, and -treating them as one sweep is the dangerous reading: - -| site | reachable from | classification | -| --- | --- | --- | -| `SqlNotificationOutbox.ack` | dispatcher tick only | global sweep | -| `SqlHttpOutbox.ack` | dispatcher tick only | global sweep | -| `SqlHttpOutbox.redeliver` | `POST /api/v1/webhooks/redeliver` | request-contextual | - -**The two `ack` sites** are declared global sweeps through a new -`dispatcherAckOptions()` helper, sibling to `dispatcherSweepOptions()` and -deliberately not the same function — that one returns `& { multi: true }`, so a -`multi: false` site cannot borrow it by accident. The warrant was re-derived -against the current tree rather than inherited: `ack` has exactly two callers, -both inside `runPartition()` on a `setInterval` tick holding a per-partition -cluster lock, so no request context exists to thread; and the row being acked -was claimed by a sweep that crosses organizations by construction -(`hash(refId | notificationId | digestKey) mod N` is a load-spreading key, and -one outbox per environment drains the whole queue). Passing the claimed row's -own `organization_id` is documented at the helper as the tempting wrong answer: -a predicate read off the row you are about to write matches exactly that row, -adds no isolation, and silences the audit anyway — the appearance of scoping -without the substance. - -**`redeliver` is not that**, and it is the reason this shipped separately. The -route in front of it is served to any authenticated user, so on a walled -deployment (`OS_TENANCY_POSTURE=isolated|group`) an unscoped replay is an -authenticated user writing another organization's delivery row — the case the -tenant audit exists to catch. It now carries the caller's tenant, applied to -the rows it reads as well as the row it writes, and it must never be given -`bypassTenantAudit`: a scoped write and a bypassed write produce the same -silence in the log, so the flag would convert a detectable hole into an -undetectable one. The webhook route resolves the session's -`activeOrganizationId` and threads it. - -Behaviour change at the endpoint: a delivery row outside the caller's -organization is now **not found** (`RESOURCE_NOT_FOUND`, HTTP 404) rather than -replayed. It is deliberately invisible rather than forbidden, so the endpoint -is not an existence oracle for other tenants' delivery ids. An in-tenant -redelivery is unchanged. - -Migrating a caller: `redeliver(id, guard?)` becomes -`redeliver(id, { tenantId, guard? })`, and `redeliverHttp(id)` becomes -`redeliverHttp(id, { tenantId })`. `tenantId` is a **required** property typed -`string | undefined`, so omitting it does not compile — a caller with no tenant -has to write `tenantId: undefined` and mean it. That is the point of the shape: -an optional property would let the dangerous case, a request path that simply -forgot, type-check in silence. Passing `undefined` leaves the write unscoped -and the audit line still fires, which is the intended reporting behaviour on a -deployment that cannot resolve an organization for the caller. - - diff --git a/.changeset/diagnostics-request-arm-400.md b/.changeset/diagnostics-request-arm-400.md deleted file mode 100644 index e953ee6c3c..0000000000 --- a/.changeset/diagnostics-request-arm-400.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): `getMetaDiagnostics` refuses an unrecognised `type` spelling with the producer's 400 instead of answering "scanned 1 type, 0 problems" (#8924) - - - -This is a **narrowing that makes an already-classified 400 reach the caller**. -`GET /api/v1/meta/diagnostics?type=` -(and the SDK method `client.meta.getDiagnostics({ type })`) used to answer -`200 {"entries":[],"total":0,"scannedTypes":1,"scannedItems":0,"stats":{}}` — -"scanned 1 type, no issues" — for a spelling every sibling `/meta` door -refuses with a 400 that names both accepted spellings. The producer had -already classified the mistake (`status: 400`, `code: 'INVALID_REQUEST'`, -raised by `canonicalizeMetaRequestType` inside `getMetaItems`); the -diagnostics sweep's per-type `catch` swallowed the verdict into a benign -skip, and `scannedTypes: 1` then published a sweep that scanned nothing as -coverage. Maintainer ruling 2026-08-20: rethrow the 400 the same way #8855's -fix rethrows the 503. - -**Measured on a booted kernel (real HTTP), before → after:** - -``` -GET /api/v1/meta/diagnostics?type=fieldes 200 {"scannedTypes":1,"stats":{}} → 400 [invalid_request] "… Address it as 'field' or 'fields'." -GET /api/v1/meta/diagnostics?type=fields 200 (recognised plural) → 200 unchanged -GET /api/v1/meta/fieldes 400 → 400 unchanged -``` - -What is unchanged: recognised plurals (`fields`, `views`, …) still fold and -answer; a name that is a plural of nothing (`fieldz`) still answers an honest -`count: 0` entry; a genuine, unclassified listing failure still skips that -one type instead of failing the sweep; the whole-corpus sweep (no `?type=`) -cannot produce the refusal at all — its target set comes canonical out of the -registry. A caller that treated the old `200`-with-empty-stats answer as -"clean" now hears the refusal that names the accepted spellings. diff --git a/.changeset/doc-tags-frontmatter-list.md b/.changeset/doc-tags-frontmatter-list.md deleted file mode 100644 index ccd344b706..0000000000 --- a/.changeset/doc-tags-frontmatter-list.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -Read `doc.tags` from `src/docs/*.md` frontmatter, so a book group's -`include: { tag }` can match on the documented authoring path (#10486). - -`DocSchema.tags` was declared in 17.0.0 (#4509, ADR-0049) as the *enforce* half -of enforce-or-remove: the resolver side already compared against it -(`matchesInclude` in `book.zod.ts`) and the REST book-tree route already -forwarded it. But `collect-docs.ts` parsed frontmatter with `frontmatterScalar` -alone — single-line scalars — and had no case for `tags` at all. On the flat -`src/docs/*.md` path the docs actually recommend, a `tags:` block was therefore -dropped without a word: every doc reached `resolveBookTree` with -`tags === undefined`, and a group declaring `include: { tag: 'tutorial' }` -matched nothing and rendered as an empty section. - -Two halves: - -- **A minimal `frontmatterList`** reading the two ordinary YAML sequence - spellings — inline `tags: [tutorial, beginner]` and the block form of `- item` - lines — wired through `DocItem.tags`. The block sequence ends at the next - frontmatter key, so `group:` after a `tags:` block still parses. An authored - `tags: []` parses and means what it says: no tags. - -- **A loud `docs/frontmatter-tags` warning** whenever `tags:` is present in a - spelling the reader cannot parse — a bare scalar, an unterminated inline - sequence, a key with nothing under it. The reader is deliberately minimal and - is **not** growing into a YAML engine; this is what keeps that minimalism - honest, by converting the next unanticipated spelling from a silent drop into - a visible report. The same warning fires when a locale variant - (`..md`) declares `tags:`, since tags belong to the doc rather - than to one translation and a `DocTranslationItem` carries no such field. - -Warnings surface through the paths that already print `DocIssue`s: `os lint`, -`os validate`, and `os compile`. No schema change — `DocSchema.tags` already -declared the key; only the collector could not produce it. diff --git a/.changeset/docs-drop-standalone-output.md b/.changeset/docs-drop-standalone-output.md deleted file mode 100644 index 648743f83d..0000000000 --- a/.changeset/docs-drop-standalone-output.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/docs': patch ---- - -docs site: drop `output: 'standalone'` so the production build stops failing - -The production build of the docs site died at the end of `next build` with -`ENOENT: no such file or directory, open '.../apps/docs/.next/next-server.js.nft.json'`, -so nothing merged to `main` reached the site. - -That file is opened by the standalone packer (`writeStandaloneDirectory` -> -`copyTracedFiles`), which Next calls **only** when `output === 'standalone'`. -Nothing in this repo consumes `.next/standalone` — no Dockerfile, workflow, -script or config references it, and `docker/Dockerfile` does not build -`apps/docs` at all — and Vercel does its own serverless packaging. The setting -served no consumer and was the sole reason that read happened, so removing it -removes the only code path that can raise this error. diff --git a/.changeset/doctor-deprecation-hint-no-phantom-codemod.md b/.changeset/doctor-deprecation-hint-no-phantom-codemod.md deleted file mode 100644 index 138af00aa4..0000000000 --- a/.changeset/doctor-deprecation-hint-no-phantom-codemod.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os doctor --scan-deprecations` no longer prescribes a command that does not exist. After listing its hits the report used to print ``Run `objectstack codemod v2-to-v3` to auto-fix``, but no `codemod` command has ever been registered — following the advice returned oclif's exit 2, `command codemod:v2-to-v3 not found`, after the operator had already spent time on it. The hint is not repointed at `os migrate meta` either: that command replays the protocol migration chain over an authored stack config and declines the source rewrite by design ("does not silently rewrite TS config source"), writing only an `--out` JSON snapshot, so it cannot fix the `src/**` TypeScript the scan reports on. It now names the count and the remedy that really exists — the per-finding replacement, printed under `--verbose`. The scanner is unchanged: same file:line attribution, same `→ replacement` detail, still advisory with exit 0 either way. diff --git a/.changeset/doctor-withholds-checks-over-unexamined-tree.md b/.changeset/doctor-withholds-checks-over-unexamined-tree.md deleted file mode 100644 index a774efc685..0000000000 --- a/.changeset/doctor-withholds-checks-over-unexamined-tree.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os doctor` no longer prints `✓ Test coverage` / `✓ Deprecations` about a tree it -never examined, and no longer warns `@objectstack/spec Not built` about a -workspace that is not part of the tree (#10679). - -`findMissingTests()` and `findDeprecatedUsages()` both walk -`/packages/spec/src` — a path that exists in this monorepo and in no -application built with the framework. Both answered "that directory is not here" -with the same value they return for "I walked it and found nothing wrong" (an -empty array), so in a stock `create-objectstack -t blank` scaffold every run -printed, verbatim: - -``` - ✓ Test coverage All *.zod.ts files have matching tests - ✓ Deprecations No @deprecated tags found -``` - -about files doctor never opened. The command exits 0 either way, so "no problems -found" and "I never looked" were byte-identical to every downstream reader. - -Doctor already refuses to do this one screen down: the ADR-0120 D5e advisory's -`✓ Unique scope` is withheld unless `ledgerReadingIsComplete()` says the ledger -half was read in full. These two checks escaped that discipline; this restores -it, in the same shape #5413 used for the ledger — whether the tree was examined -is now a fact in the return type rather than an absence, so the print site -cannot reach the `✓` from the unexamined arm. Where the tree is absent doctor -prints an informational, named-reason skip instead: - -``` - ℹ Test coverage Skipped — no packages/spec/src in this directory (monorepo-only check) - ℹ Deprecations Skipped — no packages/spec/src in this directory (monorepo-only check) -``` - -`--verbose` adds the resolved directory it looked for. The skip is deliberately -not a warning: nothing is wrong in an application that has no -`packages/spec/src`, and withholding a false `✓` must not manufacture a false -`⚠`. - -The adjacent `⚠ @objectstack/spec Not built` probe read `/packages/spec/dist` -with no check that the workspace it names exists, so in an application it warned -about an absent package and prescribed `pnpm --filter @objectstack/spec build`, a -command that cannot succeed there. It is now gated on `packages/spec/package.json` -being present. Inside the monorepo the row is unchanged; outside it there is no -row, and an application's spec dependency stays covered by the `Dependencies` -check and by the spec-version-gap advisory. - -Exit codes are untouched — 1 exactly when an error row exists, warnings never -flip it. One visible consequence: a stock scaffold with no other findings now -ends on `✅ Environment is healthy and ready for development!` instead of -`⚠️ Environment is functional but has some warnings`, because the warning it -used to carry was about a workspace that was never there. diff --git a/.changeset/driver-emits-spec-introspection-shape.md b/.changeset/driver-emits-spec-introspection-shape.md deleted file mode 100644 index 814a566683..0000000000 --- a/.changeset/driver-emits-spec-introspection-shape.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/objectql": minor ---- - -fix(driver-sql): `introspectSchema()` emits the spec introspection contract — `primaryKey`, `dialect`, `introspectedAt` (#10676, #10998) - -**BREAKING** change to the value `SqlDriver.introspectSchema()` returns, shipped -as `minor` under the repo's launch-window convention for breaking changes. - -`packages/spec/src/contracts/schema-diff-service.ts` declares one introspection -contract. The driver declared a second one beside it and, separately, so did -`packages/objectql/src/util.ts`. The three agreed on the idea and disagreed on -the vocabulary: the driver spelled a column's primary-key membership -`isPrimary`, the spec spells it `primaryKey`; the spec declares `dialect` and a -REQUIRED `introspectedAt` that the driver's schema type never mentioned and -`introspectSchema()` therefore never emitted. Nothing was type-unsound — each -side compiled against its own declaration and the value crossed between them -with no compiler in the middle. - -Measured on a live in-memory SQLite database before this change: the id column -of a `primary key (id)` table came back carrying `isPrimary: true` with no -`primaryKey` key at all, and `Object.keys()` of the schema was `["tables"]`. -Two consequences, both silent: - -- `ExternalDatasourceService.generateObjectDraft` reads `col.primaryKey`, so - every federated object drafted from a real remote table lost the remote - primary key — the addressing key for the federated table, dropped by the - codegen meant to produce it (#10676). -- type mapping ran with `dialect: undefined` across the whole federation path, - making every per-dialect alias in `suggestFieldTypeForSqlType` unreachable - there, and `refreshCatalog` persisted `dialect: undefined` into the - `external_catalog` record Studio's schema browser and the boot gate read - back (#10998). - -Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 = -驱动侧对齐 spec 契约): `packages/spec` is the one contract and the driver -aligns to it. - -What the driver now returns: every column carries the boolean `primaryKey`, the -schema carries `dialect` and `introspectedAt`, and the retired `isPrimary` -member is gone rather than emitted alongside — one spelling, so no consumer can -key off the wrong one again. `dialect` is the driver's canonical dialect name -(`sqlite`, `postgres`, `mysql`, `unknown`), which is the vocabulary the only -in-tree consumer keys its alias tables on; `introspectedAt` is an ISO 8601 -instant stamped before the reads begin. - -`IntrospectedColumn`, `IntrospectedTable` and `IntrospectedSchema` in both -`@objectstack/driver-sql` and `@objectstack/objectql` are now derived from the -spec contract instead of re-declared, so a key added there fails their `tsc` -until the producer emits it. Two divergences are kept explicitly: `defaultValue` -stays `unknown` at the SQL layer because Knex reports `null`, and `indexes` is -omitted rather than emitted empty because this driver does not introspect -indexes and an empty array would tell a schema differ that a table has none. - -TypeScript consumers of the removed member are told by the compiler, precisely -and at every site: `Property 'isPrimary' does not exist on type -'IntrospectedColumn'`. - - diff --git a/.changeset/driver-sql-pg-introspection-search-path.md b/.changeset/driver-sql-pg-introspection-search-path.md deleted file mode 100644 index 89030fa8ab..0000000000 --- a/.changeset/driver-sql-pg-introspection-search-path.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -**Bug fix:** on Postgres, index and schema introspection now resolve tables the way the session does, instead of assuming the `public` schema (#9350). - -`introspectIndexes` pinned `n.nspname = 'public'` and `introspectSchema` pinned `table_schema = 'public'`. For a driver whose connection carries a `searchPath` pointing anywhere else, both returned **empty** — not an error, an empty result. Measured on a live Postgres 16: for a table carrying a primary key *and* a declared unique index, `introspectIndexes` returned `[]` and `introspectSchema` listed no tables at all. - -Empty does not read as "I could not see" downstream; it reads as "there are no indexes". `assertConflictTargetHonoured` turns that into a refusal, so an `upsert` against a perfectly well-indexed table would be rejected with *no PRIMARY KEY or UNIQUE index backs them* — and index-drift detection would propose creating indexes that already exist. - -- `introspectIndexes` now resolves the table with `to_regclass(?)` and reads `pg_index` by OID. That is the same resolution every other statement in the session performs — first match along `search_path` — and it removes an ambiguity a schema list would introduce, since two schemas on the path can hold the same table name and only one of them is the one a query reaches. -- `introspectSchema` now lists `table_schema = ANY (current_schemas(false))`. - -**No change for a default deployment.** With the default `search_path`, `current_schemas(false)` is exactly `{public}` and `to_regclass` resolves into `public`, so both queries return what they returned before. The behaviour only differs where the old queries returned nothing. diff --git a/.changeset/duplicate-destructive-remedy-face-aware.md b/.changeset/duplicate-destructive-remedy-face-aware.md deleted file mode 100644 index e529bb1d32..0000000000 --- a/.changeset/duplicate-destructive-remedy-face-aware.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): stop prescribing `?force=true` on the duplicate door, which accepts no `force` (#11015) - -`saveMetaItem`'s Phase 3a-destructive refusal ended every message with -`— re-submit with ?force=true to proceed.` The refusal is raised in one place -and quoted onto whatever response the caller's catch builds, so that one -sentence went out on every face that reaches the gate — including -`POST /packages/:id/duplicate`, which has no `force` to set. - -Measured: the duplicate route accepts `targetPackageId`, `targetName`, -`targetNamespace`, `organizationId` and `actor` — no `force` in the query -string or the body — and `duplicatePackage`'s own request type has no `force` -field either, so its internal `saveMetaItem` call cannot carry one. The gate is -reached on the ordinary duplicate-**again** workflow, where the target -namespace already holds the renamed object from an earlier duplicate; the copy -is refused and the refusal is reported as data on a `200`: - -``` -"error": "[destructive_change] object/crm2_task would drop or transform existing - data: Field 'b' removed — … — re-submit with ?force=true to proceed." -``` - -A caller who does what that sentence says gets the identical refusal back. The -remedies that do exist on that face — duplicate into a target namespace that is -free, or reconcile the colliding object first — were never stated. - -The clause is now rendered per face. The duplicate door says: - -``` -… — this copy cannot be forced: the duplicate door accepts no `force`. -Duplicate into a target namespace that does not already hold 'crm2_task', or -reconcile that item with the source first. -``` - -Three narrowings, each pinned: - -- **The clause is repaired, not the door.** No `force` parameter is added to - `POST /packages/:id/duplicate`; that would widen a public surface and is a - contract decision, not a message fix. Which face is being served is stated by - the server on the internal call, exactly as `source` already is — a caller - cannot smuggle one in. -- **Nothing else in the message moved.** #10886 measured that - `duplicatePackage`'s `failed[].error` is the sole carrier of the per-field - destructive findings, so the findings prose stays verbatim. Only the trailing - remedy sentence is face-dependent. -- **No accept/reject behaviour changed.** The copy is still refused, still - reported as `failed[]` data on the `200`, still counted. Faces that state no - door — the single-segment REST `PUT /api/v1/meta/:type/:name`, where - `?force=true` is a real query parameter the route threads — keep the previous - wording byte for byte. diff --git a/.changeset/durability-summary-reports-error-less-sink.md b/.changeset/durability-summary-reports-error-less-sink.md deleted file mode 100644 index 9b401bae80..0000000000 --- a/.changeset/durability-summary-reports-error-less-sink.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/plugin-security": patch ---- - -**Durability fix:** the two boot-time **summary** reports now reach a logger sink that has no `error` method, instead of printing nothing at all (#9748). - -`SweepLogger.error` and `ProjectionLogger.error` are both declared **optional**, and both summaries were spelled `logger?.error?.(…)` — an optional call that emits **nothing** when the method is absent. #9657 repaired the six per-row reports of this shape; it could not see these two, because `check:durability-log-level` only judges a call inside a `catch`, and a summary sits after the loop. Against a `{ info, warn }` sink the result was that the repair made the split **worse**: the per-row detail arrived at `warn` while the count of failures vanished, so the detail and the total reported through different channels. - -- `sweepStrandedOutbox()` — *"N stranded `sys_email` row(s) could NOT be delivered"*. Mail the platform **accepted** and never delivered, previously summarised to nobody. -- `reconcilePermissionSetProjection()` — *"N FAILED backfill(s)"*. Worse than a plain omission here: the `else` branch carrying the `info` "reconciled" line is skipped too, so such a sink heard **neither** — the reassuring half-truth this rule exists to remove, arrived at from the other side. - -Both now reach for `error` and fall back to `warn`, never to silence — the same repair shape #9657 applied to the per-row lines. A sink that **does** have `error` is unaffected and still gets the summary at `error`; a downgraded level is a degradation of the channel, never of the message, so the consequence and the fix survive the fallback intact. - -Also enforced from now on: `check:durability-log-level` grew a **summary limb** that judges a report keyed on the counter a durability-critical `catch` accumulated into, so this class cannot regress silently. The limb never second-guesses a chosen log **level** — it only checks that a call that reaches for `error` can actually print. diff --git a/.changeset/external-catalog-introspected-primary-key.md b/.changeset/external-catalog-introspected-primary-key.md deleted file mode 100644 index 3b76785cec..0000000000 --- a/.changeset/external-catalog-introspected-primary-key.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -Restore the introspected primary key in the persisted `external_catalog` -(#10676). `ExternalDatasourceService` reads `column.primaryKey` — the -`packages/spec` `IntrospectedColumn` spelling — but `plugin.ts` hands it the -driver's `introspectSchema()` result unmodified, and `SqlDriver` (and -`SqliteWasmDriver`, which extends it) speaks the other `IntrospectedColumn` -contract, from `packages/objectql/src/util.ts`: it sets `column.isPrimary` and -fills `table.primaryKeys`, never `column.primaryKey`. - -Measured against a live SQLite database: for a table declared -`primary key (id)`, the driver's `id` column carries `isPrimary: true` and the -table carries `primaryKeys: ['id']`, while `primaryKey` is `undefined`. Because -`ExternalCatalogSchema` defaults `primaryKey` to `false`, `refreshCatalog` -persisted a catalog in which **every** column of **every** remote table claimed -not to be part of the remote key — so Studio's schema browser and the boot gate -read a catalog that shows no primary keys at all. - -The seam now reads the union of all three signals (`primaryKey`, `isPrimary`, -`table.primaryKeys`) rather than any one of them. No in-tree producer uses a -`false` to negate a key another signal asserts, and taking the union means a -producer that fills only the table-level list — or only the per-column flag — -cannot lose half a composite key. No response or record shape changes: a field -that should always have carried the introspected value starts carrying it. - -The regression pin drives the service off a **real** `SqlDriver.introspectSchema()` -result rather than a hand-written fixture. The pre-existing suite could not see -this defect precisely because it hand-wrote its fixture in the spec spelling, so -no test ever fed the service what a driver actually emits. - -Not fixed here: `generateObjectDraft` still drops the key from the generated -object definition. Its destination is an open contract question rather than a -missing read — `fields..primaryKey` is **not** an authorable spec field -key (an object literal carrying it fails `tsc` against `ServiceObject` with -TS2353, and `ObjectSchema.safeParse` with `unrecognized_keys`), and there is no -key on `ObjectExternalBindingSchema` to hold a remote primary key either. See -#10676 for the routing decision. diff --git a/.changeset/external-object-draft-drops-unauthorable-primary-key.md b/.changeset/external-object-draft-drops-unauthorable-primary-key.md deleted file mode 100644 index ef86a43df6..0000000000 --- a/.changeset/external-object-draft-drops-unauthorable-primary-key.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -`os datasource introspect --primary-key` (and `POST /object-draft` with -`primaryKey`) now generates an object draft that compiles and parses (#11000). - -The generator emitted a field-level `primaryKey: true` — into the definition -and onto the rendered field line. `primaryKey` is **not a key of the spec field -schema**, so the `*.object.ts` the review-before-commit flow handed the user was -refused by both instruments the file is annotated for: - -- `tsc --noEmit` against `ServiceObject` — `TS2353: Object literal may only - specify known properties, and 'primaryKey' does not exist in type …`; -- `ObjectSchema.safeParse` — `unrecognized_keys` at `["fields",""]`. - -This was the last reason the `opts.primaryKey` path did not build. With #10712's -namespace/`sharingModel` repairs already landed, **both** paths — `primaryKey` -set and unset — now clear `defineStack()`'s namespace check, the -`authoringRulesFor('build')` rule set, and `tsc --noEmit` over the rendered -source. - -The introspected key is not discarded: it is preserved as a comment above the -`fields` block, naming the column(s) the draft was given as the key — - -```ts - // Remote primary key: order_id, line_no -``` - -— with the reason it is a comment rather than a field key, and an explicit -caveat that for a composite key some drivers report only the first column -(#10997), so the list is a lower bound rather than a verified complete key. A -table with no reported key gets no comment at all. - -Per the maintainer ruling of 2026-08-22, an authorable spelling for a federated -object's remote key (`external.primaryKey: string[]` on the binding schema) is -**deferred, not rejected** — it returns as its own `packages/spec` change when -federated upsert has a live runtime consumer to justify the surface. diff --git a/.changeset/external-object-draft-passes-os-build.md b/.changeset/external-object-draft-passes-os-build.md deleted file mode 100644 index f7430a2165..0000000000 --- a/.changeset/external-object-draft-passes-os-build.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -`os datasource introspect` now generates an object draft that `os build` -accepts (#10712). The review-before-commit flow was handing the user a -`*.object.ts` the platform's own validator refuses, on two independent counts: - -- **The object name carried no `${namespace}_` prefix**, so `defineStack()` - refused it outright (ADR-0028) — measured as - `Object 'customers' is missing the package namespace prefix.` The prefix is - now derived from the datasource's OWN owning package (`_packageId` → - that package's `manifest.namespace`), and applied through - `validateObjectNamespacePrefix` — the same function `defineStack()` and the - runtime publish gate call, so an already-prefixed remote table - (`wh_accounts` under namespace `wh`) is not double-prefixed. -- **No `sharingModel` was emitted**, so the author-time rule set refused it - (`security-owd-unset`, ADR-0090 D1) — the same rule family #9666 hit for the - `os init` template. The draft now declares `sharingModel: 'private'` - explicitly, following the shape #9666 settled on for generated scaffolds: - the rule's own recommended default, rendered with the reason attached. - -When no namespace can be resolved (a datasource with no package provenance, or -a package that declares none) the draft keeps the bare remote-table name and -the rendered source carries a loud `TODO(namespace)`. It does not invent a -prefix — mirroring `defineStack`, which skips the check entirely rather than -inventing one, and avoiding an `_customers` that would trade one invalid draft -for another. - -At the time this landed, the `opts.primaryKey` path still did not build: it -emitted `fields..primaryKey`, which is not an authorable spec field key. -That was #11000, and it is fixed separately in this same release — both paths -build now. See that changeset for what replaced the key. diff --git a/.changeset/external-validate-read-capability.md b/.changeset/external-validate-read-capability.md deleted file mode 100644 index c1b5b84cfb..0000000000 --- a/.changeset/external-validate-read-capability.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**Behaviour change (tightening) — `POST /datasources/:name/external/validate` now requires `manage_platform_settings`** (#10255, completing the #9901 federation-family gate). This was the one route of the external-datasource federation family still admitting **any authenticated caller**; it now requires the same capability as the family's two read routes. Maintainer ruling, 2026-08-20 (verbatim: 「同意你的意见。」, accepting option A on #10255). - -**This is published SDK surface.** `datasources.external.validate` on `ObjectStackClient` and the CLI's `os datasource validate` reach exactly this route. An existing integration that presents a valid credential — a better-auth session or a `sys_api_key` — and does not hold `manage_platform_settings` was served before and is **refused now**: `403` with the standard catalog code `PERMISSION_DENIED` (ADR-0112), the message naming the missing capability so the caller knows which grant to request. The anonymous floor is unchanged: no identity is still `401 UNAUTHENTICATED`. - -**Why the read capability.** `validateAll` drives the same live remote-schema introspection the family's gated read routes expose (`introspect` per datasource), and its report — schema diffs naming remote columns and types, driver error strings for unreachable remotes — is a read of the same federation surface. An unentitled caller refused at `GET /:name/external/tables` could previously still trigger live remote introspection through this route and read what it found. One family, one door-type: reads on `manage_platform_settings`, writes on `manage_metadata`. - -**Migration.** Grant the calling credential's permission set `manage_platform_settings` — the same grant the family's read routes have required since #10254, so an integration already migrated for those is covered. The platform's `admin_full_access` set carries it; a purpose-built operator set is the case to check. diff --git a/.changeset/face-aware-invalid-metadata-422.md b/.changeset/face-aware-invalid-metadata-422.md deleted file mode 100644 index faee09ed69..0000000000 --- a/.changeset/face-aware-invalid-metadata-422.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/rest": patch -"@objectstack/runtime": patch ---- - -Render `saveMetaItem`'s `422 INVALID_METADATA` findings clause per write face - -The spec-validation refusal restated its own findings in the message -(`: ` for the first three, plus a `(+N more)` tail) while -attaching the same array as `issues`. On the HTTP 422 both channels ride one -response, so every console rendering both showed each finding twice. - -The clause is now rendered per face. The `/meta` HTTP write doors — REST's -`PUT /meta/:type/:name` and `PUT /meta/:type/:a/:b`, and the runtime -dispatcher's `PUT /meta` — declare that they carry the findings structurally -and get a one-sentence headline instead: the issue count plus up to three -`path [zod code]` locators, the same grammar the seed refusal and the -author-time gate already compose. `issues[]` is attached unchanged on every -face, so nothing is withheld from anyone. - -Faces that carry no structured channel keep the full prose, byte for byte — -`duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s -`rows[].reason`, and the two out-of-package log faces, where this sentence is -the sole carrier of the author's prescription. Silence means "keep the prose": -a write door only ever drops the restatement by declaring itself, never by -omission. diff --git a/.changeset/federated-sweep-phantom-columns.md b/.changeset/federated-sweep-phantom-columns.md deleted file mode 100644 index ff113f9888..0000000000 --- a/.changeset/federated-sweep-phantom-columns.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -**Waste removed:** the lifecycle dangling-reference audit no longer asks a federated (ADR-0015 `external`) remote for platform anchor columns that were never provisioned on it (#8414). - -`applySystemFields` injects `organization_id`, `owner_id`, `owning_business_unit_id` and the audit `*_by` lookups into every registered object, federated ones included — that is deliberate (#7865, direction B). `Engine.syncObjectSchema` then issues no DDL for a federated object, because the remote database owns its schema. So those five reference columns existed in the registered schema and nowhere else, and `auditDanglingReferences` — which enumerated reference fields off `fields` alone — projected all of them onto the remote table. Measured on a real boot of `examples/app-showcase`, against a `customers` table whose real columns are `id, name, email, region, lifetime_value`: - -``` -select `id`, `organization_id`, `created_by`, `updated_by`, `owner_id`, `owning_business_unit_id` from `customers` limit ? -select * from `customers` limit ? -``` - -The first statement cannot compile (`no such column` — a backtick-quoted identifier does not take SQLite's double-quote literal fallback, and Postgres/MySQL raise their own error); `SqlDriver.find`'s unknown-column recovery caught it and retried `select *`, fetching up to 500 whole rows to audit columns that cannot exist — once per federated object, every lifecycle sweep interval, each pass also emitting a #4363 non-deterministic-paging warning. **No answer was ever wrong**; the pass was pure waste, and it was being absorbed by a safety net rather than by a design. - -The enumerator now consults `unprovisionedInjectedColumns` (`@objectstack/spec/data`, the #7865 provenance derivation) and skips columns that are the platform's own injected anchor on an object the platform provisions no storage for. - -**This reads provenance, not `external != null`.** A federated object that declares a real remote `organization_id` — or any other anchor name — keeps its audit on that column: the author's definition is not byte-identical to the shipped one, so provenance answers `'author'` and nothing is withheld. Objects the platform provisions storage for are untouched: the derivation returns an empty set for them, so an ordinary object is still swept with its full column set. - -Two consequences worth knowing: - -- A federated object left with **no real reference column** is no longer read at all, and is deliberately not filed in `unscannedObjects` — a column that was never provisioned stores no reference, so its absence from `dangling` is proven, not assumed. A federated object that declares a real reference column is still opened and audited on it. -- `AuditableObject` now carries an index signature. The port was already being handed the whole registered document (the engine passes `SchemaRegistry.getAllObjects()` straight through); the type now says so, because the provenance derivation reads the injection plan's inputs off it. Hand-written doubles carrying only `name`/`fields` still satisfy the type and behave exactly as before. - -The card also named `backfillSearchCompanion` (`@objectstack/plugin-pinyin-search`) for `select `id`, `name`, `__search` from `customers``. **That statement is already gone and this release changes no code for it:** #9469 stopped `provisionSearchCompanion` from declaring `__search` on a federated object, so the backfill's existing `if (!schema.fields[SEARCH_COMPANION_FIELD]) continue` early-out drops those objects before enumerating anything. A second federation-aware guard inside the backfill would have been redundant, and — spelled as "skip external objects" — would have wrongly withheld the companion from a federated object whose author declares a real remote `__search`. The precondition is now pinned on a real boot instead. diff --git a/.changeset/federation-family-capability-gate.md b/.changeset/federation-family-capability-gate.md deleted file mode 100644 index 37fc9f011d..0000000000 --- a/.changeset/federation-family-capability-gate.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**Behaviour change (tightening) — a capability is now required on the external-datasource federation family** (`/api/v1/datasources/:name/external/*`, #9901). These routes previously admitted **any authenticated caller**; four of the five now also require a platform capability. Maintainer ruling, 2026-08-20 (verbatim: 「其他接受你的建议。」). - -**This is published SDK surface.** `datasources.external.*` on `ObjectStackClient` reaches exactly these routes, and the CLI's `datasource` commands go through them. An existing integration that presents a valid credential — a better-auth session or a `sys_api_key` — and holds neither capability was served before and is **refused now**. Nothing about the credential itself changed; what changed is what the credential must carry. - -| route | SDK call | now requires | -| --- | --- | --- | -| `GET /:name/external/tables` | `datasources.external.listTables` | `manage_platform_settings` | -| `POST /:name/external/tables/:remote/draft` | `datasources.external.draft` | `manage_platform_settings` | -| `POST /:name/external/tables/:remote/import` | `datasources.external.import` | `manage_metadata` | -| `POST /:name/external/refresh-catalog` | `datasources.external.refreshCatalog` | `manage_metadata` | -| `POST /:name/external/validate` | `datasources.external.validate` | *(unchanged — authentication only)* | - -A refusal is **`403` with the standard catalog code `PERMISSION_DENIED`** (ADR-0112; deliberately not the grandfathered `FORBIDDEN` synonym), and the message names the missing capability so the caller knows which grant to request. The anonymous floor is unchanged: no identity is still `401 UNAUTHENTICATED`. - -**Why these two capabilities.** The first two routes are the declared twins of `GET /:name/remote-tables` and `POST /:name/object-draft` on the datasource-admin spelling, which has required `manage_platform_settings` since #9593 — the same operation was reachable through two mounted routes with two different admission policies, so an agent or integration refused at one spelling was served at the other. The two write routes have no twin and create live metadata (the import mounts a runtime-origin federated object; the refresh rewrites the cached catalog snapshot), so they take `manage_metadata`, this package's existing gate for metadata creation. - -**Migration.** Grant the caller's permission set the capability its routes need — `manage_platform_settings` for remote-schema introspection, `manage_metadata` for import/refresh. The platform's `admin_full_access` set already carries both, so admin-credentialed integrations are unaffected; a purpose-built operator set is the case to check. diff --git a/.changeset/formula-scale-at-producer.md b/.changeset/formula-scale-at-producer.md deleted file mode 100644 index 7611aa63f6..0000000000 --- a/.changeset/formula-scale-at-producer.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -Apply a `formula` field's declared `scale` when the formula is evaluated -(#10280). `Field.formula({ scale: 2 })` was accepted and then ignored: a -percentage formula such as `(record.num_responses * 100.0) / record.num_sent` -**returned** `41.666666666666664`, so the API response — and the record page -rendered from it — carried all fifteen digits despite the declaration. - -The value is now rounded where it is produced, in the engine's formula -evaluation, so all three surfaces that materialize a formula inherit it: list -reads, single-record reads, and the record a write responds with. - -- **Rounding is `Number(v.toFixed(scale))`** — round-half-away-from-zero, the - same arithmetic the console's client-side computed columns use. Negatives - round away from zero: `-1.5` at `scale: 0` is `-2`, not `-1`. -- **A formula declaring no `scale` is unchanged** and keeps full precision. -- **Non-numeric results are untouched** — a formula returning a string, - boolean or `null` is returned as-is. -- A formula value is **returned, never stored** — it is virtual and has no - column. Rounding it at the producer is what makes an app's own copy of that - result writable into a stored `DECIMAL(10, 2)`-style field, which previously - failed that field's decimal validation. - -Unchanged: `scale` on a **caller-supplied** number (`Field.number`, -`Field.currency`, …) is still enforced by **rejection** (`max_scale`), never by -rounding. A value someone sent has an author to refuse; a platform-computed -formula result does not. diff --git a/.changeset/hono-server-readme-kernel-bootstrap.md b/.changeset/hono-server-readme-kernel-bootstrap.md deleted file mode 100644 index 1ba759b8c5..0000000000 --- a/.changeset/hono-server-readme-kernel-bootstrap.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/plugin-hono-server": patch ---- - -docs(plugin-hono-server): boot the kernel with the method it actually ships (#9870) - -`packages/plugins/plugin-hono-server/README.md` is in the package's `files` array -with `private` unset, so it is the page npm renders. Its Usage block ended: - -```ts -const kernel = new ObjectKernel(); -kernel.use(new HonoServerPlugin({ port: 3000, /* … */ })); -await kernel.start(); -``` - -Measured against the built type surface: `ObjectKernel` (re-exported by -`@objectstack/runtime` from `@objectstack/core`) declares `bootstrap()` and -`shutdown()` and has **no** `start` member. A reader copying the block gets a -compile error on its last line. - -The line reads plausibly because the `IKernel` *interface* in -`@objectstack/types` does declare `start()` — but the concrete class the fence -constructs does not implement that name, and eight sibling READMEs -(`objectql`, `rest`, `runtime`, `service-cache`, `service-job`, -`service-automation`, `service-package`, `service-cluster-redis`) all spell the -same step `await kernel.bootstrap()`. Fixed to match. - -Found by the call-site widening in the same PR, not by hand: the receiver is -never import-bound, so before that widening this call site was one of the 262 -`check:published-readme-exports` could not read. diff --git a/.changeset/hook-body-gate-reporting-honesty.md b/.changeset/hook-body-gate-reporting-honesty.md deleted file mode 100644 index 5c8e1c6dd5..0000000000 --- a/.changeset/hook-body-gate-reporting-honesty.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -Make the hook-body build gates report only what they establish (#10678). Three -defects, one shape — a gate reporting something it never established. The -enforcement net was never the gap and is unchanged: no forbidden body ever -shipped as `body.source`, and every forbidden or free-identifier hook is still -refused under `--strict-body`, at the same exit codes as before. - -**The default build no longer warn-and-bundles in silence.** A hook body -containing a forbidden pattern made `os build` exit 0 with no output at all: the -extraction failure was recorded in `bodyExtractionWarnings` and then printed -nowhere, so the only way to learn a handler had *not* become a metadata body was -to diff the artifact. The recorded warnings now reach a human — on stdout, -naming the hook and the pattern, with a pointer at `--strict-body` — and in -`--json` under a new `bodyExtractionWarnings` key. That key is separate from -`warnings` on purpose: `warnings` carries author-time rule advisories in the -shape `os validate --json` also reports, and these are a different record -(`{origin, reason}`). It is an empty array on a clean build, so a CI consumer can -read it unconditionally. - -The build still exits 0 in this case. Making a forbidden pattern fatal by default -would change what `os build` accepts and is not part of this change. - -**The `require()` refusal reason now fires on the real authoring path.** A -TypeScript config is loaded through `bundle-require` → esbuild, whose ESM interop -shim rewrites `require('node:os')` to `__require("node:os")` before `String(fn)` -runs — so the `require()`-specific reason could never match, and the refusal -arrived instead as the generic free-identifier message naming `__require`, an -identifier the author never typed. Both spellings now carry the one reason, which -also explains the rewrite. Accept behaviour is unchanged: the body was already -refused, already bundled, at the same exit code; only the wording moved. - -**The `// @capabilities` directive is documented at its real reach.** It is read -off `String(fn)`, and esbuild strips `//` line comments before the handler is ever -a runtime function — so through `os build` it reaches the extractor from no -ordinary authoring shape. Measured on all four: `objectstack.config.ts`, `.js`, -`.mjs`, and a handler imported from a local `./handlers.js` all silently drop it -and ship the inferred capabilities alone. `hook-bodies.mdx` and the extractor -header now say so, and point at `body.capabilities` — data rather than a comment — -as the escape hatch that does survive. Whether the directive should gain a real -authorable surface or be retired is left open. - -The extractor header claimed a forbidden pattern "makes the build **fail** … -no silent fallback"; docs described warn-and-bundle. The code agreed with the -docs, so the header was the outlier and has been rewritten to describe both -outcomes. - -A new `os build`-level test (`hook-body-build-reach.e2e.test.ts`) spawns the real -CLI and pins all three behaviours against the artifact and the shell's exit code. -The existing extractor unit tests could not have caught any of this: they feed raw -JS function literals, which keep their comments and their `require(` spelling -because nothing transformed them. diff --git a/.changeset/http-request-errors-total-retired.md b/.changeset/http-request-errors-total-retired.md deleted file mode 100644 index 210fcaf471..0000000000 --- a/.changeset/http-request-errors-total-retired.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/observability": minor -"@objectstack/runtime": minor -"@objectstack/spec": minor ---- - -fix(observability): **BREAKING** — `http_request_errors_total` is retired (ADR-0049 enforce-or-remove, #9834) - -**⛔ If you have a Grafana panel, an alert rule or a recording rule keyed on -`http_request_errors_total`, it will read a FLAT ZERO after this upgrade.** That -zero is the removal, not a healthy server, and it is the one way this change can -hurt you — nothing throws, nothing warns, the series simply stops receiving -samples. Rewrite the query before you deploy. - -Maintainer ruling 2026-08-20: **RETIRE**. The name was declared in `SEMCONV` as -part of a stable namespace *"so hosts can wire alerts/dashboards against it"*, -but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, -applied only by the dispatcher's own route Proxy — so the series never saw -auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other -inbound surface. Its two siblings in the same HTTP family moved to the -`IHttpServer.afterResponse` transport seam (`http_requests_total`, #9650/#9835; -`http_request_duration_ms`, #9834/#10004) and this one could not follow: -`HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` -and **no throw signal of any kind**, so every transport-side shape would have -counted a *different* population rather than the same one more widely. - -Migration (FROM → TO): - -| Wrote | Write instead | -|---|---| -| `rate(http_request_errors_total[5m])` in a panel or alert | `rate(http_requests_total{status=~"5.."}[5m])` — emitted by the transport, so it covers every inbound surface instead of the dispatcher's routes only | -| `sum by (route) (http_request_errors_total)` | `sum by (route) (http_requests_total{status=~"5.."})` | -| `SEMCONV.httpRequestErrorsTotal` / `RUNTIME_METRICS.httpRequestErrorsTotal` in host code | Delete the read. Both members are gone; `tsc` reports the missing property at the read site. | - -One-line fix: replace the metric name with `http_requests_total{status=~"5.."}`. - - - -**The replacement is wider, not merely different.** The retired counter was -divergent from a 5xx rate in *both* directions, measured: the dispatcher answers -its own errors through `errorResponseBase`, which sets a status and does **not** -re-throw — so the counter **missed** those — while its `catch` incremented -unconditionally, so a **thrown 4xx WAS counted** as an error. And -`http_requests_total` already carries a `status` label, so a status-class error -counter was fully derivable from data the transport already publishes. Prove the -new query wider rather than merely non-empty: make an auth route or a REST -data-API route answer 5xx and confirm it moves, where the retired counter would -not have moved at all. - -**If what you were actually alerting on was "a handler threw rather than -returning an error envelope"** — the one signal this counter uniquely carried — -that is the `errorReporter`, not a metric. Wire an `ErrorReporter` adapter -(Sentry / Datadog / your own); it still fires on every 5xx throw and is -untouched by this change. - -What is NOT removed: `http_requests_total`, `http_request_duration_ms`, -request-id propagation, the 5xx error reporter, and the -`res.__obsRecordedError` side channel that carries a swallowed error to it. The -dispatcher still instruments every route it mounts; it just no longer publishes -a fourth series whose name promised more coverage than it had. diff --git a/.changeset/i18n-inline-map-retired-spellings-by-name.md b/.changeset/i18n-inline-map-retired-spellings-by-name.md deleted file mode 100644 index 75e61e5a57..0000000000 --- a/.changeset/i18n-inline-map-retired-spellings-by-name.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): reject the retired `key`/`defaultValue` spellings in inline locale maps BY NAME, in any combination — and stop claiming the retired form "resolves to nothing" (#10492) - -Two legs, both on `InlineLocaleMapSchema` in `packages/spec/src/ui/i18n.zod.ts`: - -1. **Message accuracy.** The `INLINE_LOCALE_KEY` rejection message said the - retired key-reference form (#5055) "resolves to nothing". Measured false: - both resolvers — `resolveI18nLabel` here and objectui's `pickLocalized`, - parity-pinned — fall through to their last resort (first string value, in - key insertion order) and return the raw dotted key, which renders as the - visible label. The message now states the measured behaviour. - -2. **Enforcement hole closed.** `key` is three letters — syntactically a valid - BCP-47 primary subtag — so `{ key: 'common.save' }` alone parsed as a - "language `key` inline locale map" and painted `common.save` on screen; the - pair form was rejected only because `defaultValue` fails the tag grammar. - The key pattern now refuses the two retired spellings by name, in any - combination, matching the emitted type's `{ key?: never; defaultValue?: - never }` narrowing (#9925, maintainer ruling 2026-08-19, option B). This is - an enforcement gap of the #5055 retirement, not a new contract: nothing else - is denied — real 2–3 letter subtags (`deu`, `fra`, `yue`) still parse. - -FROM → TO: a label authored as `{ key: '' }` (or any inline map -carrying a `key`/`defaultValue` entry) is now refused at parse time with the -named message; write the inline locale map form `{ en: '…', 'zh-CN': '…' }`, -or a plain string resolved through a translation bundle. This is the same -prescription the #5055 retirement and the #9925 type narrowing already carry — -the runtime now enforces what the type already refused. - - diff --git a/.changeset/impersonate-user-platform-admin.md b/.changeset/impersonate-user-platform-admin.md deleted file mode 100644 index 6169aa62cf..0000000000 --- a/.changeset/impersonate-user-platform-admin.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -**Fix:** `POST /api/v1/auth/admin/impersonate-user` now admits ObjectStack **platform admins**. It previously refused every one of them with `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS` — byte-identical to the refusal a plain member received — so the `sys_user` "Impersonate User" button was dead on every deployment (#9968). - -better-auth's `admin` plugin authorizes on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing. ObjectStack's platform admin is a `sys_user_permission_set` row pointing at `admin_full_access` with `organization_id = null`, which the vendor cannot be pointed at, and re-synthesizing the scalar is permanently vetoed. - -**What an operator will now observe.** A platform admin who could not impersonate anyone can now impersonate a non-admin user, and the impersonation takes effect for cookie and bearer clients alike. Refusals are unchanged for everyone else: a signed-in non-platform-admin (including an organization owner or admin, who is **not** a platform admin under ADR-0068) still gets `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`, and an anonymous caller still gets `401` from better-auth's own `adminMiddleware`. - -**One refusal is newly reachable.** The vendor refuses to impersonate an admin-grade *target* by reading that same `role` scalar against `adminRoles: ['admin']` — a column nothing writes after ADR-0068 D2, so the guard was inert. It is now asked through the ADR-0068 predicate, so impersonating a **platform-admin target** is refused with `403 YOU_CANNOT_IMPERSONATE_ADMINS` where it previously succeeded. - -Implemented as a better-auth **plugin endpoint**, replacing the vendor endpoint in place on the `admin` plugin's own `endpoints` record — not a raw Hono mount. That keeps the signed-cookie contract with `/admin/stop-impersonating` and keeps the `/admin/impersonate-user` path-keyed rotation hook attached, so bearer-client impersonation does not regress to a silent 200 no-op. - -Every other better-auth-native `/admin/*` route still gates on the legacy scalar and still refuses platform admins — unchanged here. diff --git a/.changeset/init-created-files-summary-after-install.md b/.changeset/init-created-files-summary-after-install.md deleted file mode 100644 index 67038a4e43..0000000000 --- a/.changeset/init-created-files-summary-after-install.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/cli": patch -"create-objectstack": patch ---- - -Fix `objectstack init`'s closing "Created files" summary omitting `pnpm-lock.yaml` / `package-lock.json` and `node_modules/` (#10557). - -The summary used to be printed from a list accumulated while the template -files were written — before ` install` ran — so it could never name what -the package manager wrote. `init` now prints it after the install attempt -(succeeded or failed) from a walk of the finished project directory, reusing -`create-objectstack`'s `created-summary.ts` (now published as the -`create-objectstack/created-summary` subpath) instead of a second copy of the -same renderer. diff --git a/.changeset/init-scaffold-pnpm11-allow-builds.md b/.changeset/init-scaffold-pnpm11-allow-builds.md deleted file mode 100644 index 0241e5ea2a..0000000000 --- a/.changeset/init-scaffold-pnpm11-allow-builds.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`objectstack init` now writes both build-approval keys into the scaffolded -`pnpm-workspace.yaml`, so a brand-new project's first `pnpm install` succeeds -on pnpm 11 (#10405). - -The renderer emitted only `onlyBuiltDependencies`. pnpm 11 does not read that -key at all, and it turned an unapproved dependency build script from a warning -into a hard error — so `objectstack init my-app && cd my-app && pnpm install` -exited 1 with `ERR_PNPM_IGNORED_BUILDS`, on the very first command after -scaffolding. The rendered file now also carries `allowBuilds`, built from the -same source list, which is the only key pnpm 11 reads. Measured one clean -install per pnpm version, each with its own store: pnpm 10.0.0-10.25.0 read -`onlyBuiltDependencies`, 10.26.0-10.34.x read either key, and 11.x reads -`allowBuilds` only — so both keys are load-bearing and neither is redundant. - -Build permission is still granted to exactly the two packages that need it and -nothing else: `esbuild` (a `postinstall` that installs its platform binary, -used to compile `objectstack.config.ts`) and `better-sqlite3` (ships a -`binding.gyp`, which pnpm treats as a native build; without it `objectstack -serve` can fail with "Could not locate the bindings file"). No wildcard. - -Existing scaffolds are unaffected — `init` never overwrites a -`pnpm-workspace.yaml` that is already there. To fix a project scaffolded by an -earlier CLI, add to its `pnpm-workspace.yaml`: - -```yaml -allowBuilds: - better-sqlite3: true - esbuild: true -``` diff --git a/.changeset/kernel-resolver-resolve-environment.md b/.changeset/kernel-resolver-resolve-environment.md deleted file mode 100644 index be3029ccee..0000000000 --- a/.changeset/kernel-resolver-resolve-environment.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/rest": patch ---- - -`KernelResolver` gains an optional environment-only member so a REST request -pays ONE kernel-waiter window instead of two (#10988). - -`RestApiPlugin` wraps the host's ADR-0006 `kernel-resolver` so `RestServer` can -ask "which environment is this request in?". It asked `resolveKernel` — a -kernel-ACQUISITION api — and kept only `context.environmentId`. A host resolver -writes the id and then awaits that environment's kernel, so the wrapper paid a -full waiter window and discarded what it bought; `resolveProtocol` then acquired -the kernel again. Free on a warm environment (a cache hit, which is why this was -invisible), a second serial wait on a cold or wedged one. Measured on a live -multi-tenant host with `waiterTimeoutMs: 20s`: REST-owned routes -(`/api/v1/discovery`, `/api/v1/data/:object`) answered 503 after ~42s where -dispatcher-owned routes answered after ~21s. - -`KernelResolver.resolveEnvironment?(context, defaultKernel)` resolves ONLY the -request's environment onto the context, acquiring no kernel; the REST wrapper -prefers it when the host implements it, leaving `resolveProtocol` as the single -kernel-acquisition point on the path. - -**Non-breaking, and no flag day.** The member is `?.`-optional: a host that -implements only `resolveKernel` type-checks and behaves exactly as before (it -keeps paying the discarded acquisition on cold builds), so this ships before any -host implements the new half. Adding an optional member to an interface the -framework CONSUMES cannot invalidate an existing implementation — every resolver -already in the field still satisfies the contract. Marked `minor` on -`@objectstack/runtime` because it is a new public capability on an exported -contract, `patch` on `@objectstack/rest` because the wrapper change is a fix -with no surface of its own. - -Fail-closed is unchanged and pinned: the surviving `getOrCreate` still rejects -for a genuinely unavailable kernel, so the caller still gets the host's declared -503 — a shorter wait to the same verdict, never a response served against no -kernel. `waiterTimeoutMs` is a host setting and is untouched; the defect was -waiting twice, not waiting wrong. diff --git a/.changeset/kernel-timeout-guard-reclaim.md b/.changeset/kernel-timeout-guard-reclaim.md deleted file mode 100644 index 561d0c8c1d..0000000000 --- a/.changeset/kernel-timeout-guard-reclaim.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/core": patch ---- - -The kernel's two `Promise.race` timeout guards — the startup guard around each -plugin's `init`/`start`, and the shutdown guard around `performShutdown()` — -now reclaim **both** halves of the guard when the race settles: the timer is -cleared *and* the losing promise is settled (#10604). - -Neither site settled its loser, so the timeout promise and the reaction -`Promise.race` held on it were retained for the life of the process — four -leaking promises per showcase test run under `vitest --detectAsyncLeaks`, now -zero. The two hand-rolled copies had also drifted into doing opposite halves of -the same cleanup: the startup site cleared its timer and never `unref`'d, the -shutdown site `unref`'d and never cleared. Both now go through one internal -`TimeoutGuard`, so they cannot drift apart again. No exported API changes. - -**Behaviour change, at the shutdown guard:** the shutdown timer is no longer -`unref()`d. Two consequences for an embedding host (CLI, auth-proxy, test -runner): - -- After a **successful** shutdown, no timer is left armed. Previously the guard - survived its own race and stayed scheduled to fire against a kernel already - `'stopped'`. That late rejection was *handled* — `Promise.race` had attached a - rejection handler to it — so this was never an unhandled-rejection risk; it - was retained work and a wakeup after teardown. -- When teardown **hangs**, the guard now actually fires. An unref'd timer does - not keep the event loop alive, so a process with nothing else to run could - exit silently — status 0, teardown incomplete — before `shutdownTimeout` - elapsed, leaving `Shutdown timed out — forcing exit` and its `exit(1)` - unreachable in exactly the case they exist for. Reclaiming on settle keeps the - guard ref'd exactly as long as the race is undecided, which is the guarantee - the startup guard already had (#4813). - -If your host relied on a hung `shutdown()` letting the process fall out of the -event loop on its own, it will now wait up to `shutdownTimeout` (default 60s) -and then hard-exit with status 1. Lower `shutdownTimeout` in the kernel config -to shorten that window. diff --git a/.changeset/lifecycle-prose-family-10526-10336.md b/.changeset/lifecycle-prose-family-10526-10336.md deleted file mode 100644 index 7b3934d4c6..0000000000 --- a/.changeset/lifecycle-prose-family-10526-10336.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Correct two stale author-facing contract statements in `Object.enable` / `Object.lifecycle` — text only, no change to what parses. - -- `lifecycle.ttl.onlyWhen` × `archive` (#10526): the refusal's rejection message no longer says "the Archiver moves rows by age alone". Since #10347 the Archiver selects candidates by the declared ttl cutoff, so that reason had gone stale; the reason it states now is the one that holds — the ttl **window** carries over to the Archiver, the `onlyWhen` **filter** does not, so the filtered-out rows would still be archived. The refusal itself is unchanged. -- `enable.files` / `enable.feeds` (#10336): the two `.describe()` strings said the flags reject *creation*. Since #10170 both capability gates are registered on `beforeUpdate` as well, so they refuse any write that makes a row **target** the walled object — a create and an update that re-points/re-threads an existing row alike (403 `FILES_DISABLED` / `FEEDS_DISABLED`). The strings now state that, matching the docblocks above them. `enable.activities` is unaffected and untouched. diff --git a/.changeset/lifecycle-triple-alignment-refine.md b/.changeset/lifecycle-triple-alignment-refine.md deleted file mode 100644 index a564fd0fdf..0000000000 --- a/.changeset/lifecycle-triple-alignment-refine.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`LifecycleSchema` now refuses the `retention` + `ttl` + `archive` triple at -parse time unless the ttl restates the age bound exactly — `ttl.field: -'created_at'` with `ttl.expireAfter` equal to `retention.maxAge` (#10527). - -Since #10347 the Archiver selects the rows it moves by the declared ttl cutoff -(`ttl.field` older than `ttl.expireAfter`) whenever `ttl` is declared, and by -`created_at`/`archive.after` only when it is not. On a diverging triple that -leaves `retention.maxAge` (pinned equal to `archive.after` by the existing -alignment refine) declared but enforced by nothing — a row whose `ttl.field` -sits in the future stays hot past `retention.maxAge`, silently. A declared -bound nothing enforces is the class this block already refuses loudly, so the -divergence is now rejected at authoring time with a named message instead of -being resolved by whichever column the sweep happens to read. - -No shipped or example object declares the triple (censused in #10527: -`sys_audit_log` and `sys_metadata_audit` are the only archive-declaring -objects, both `retention` + `archive` pairs) — so no bundled object changes -behaviour, and the ruled-legal shapes are unchanged: `retention` + `archive` -aligned pairs and `ttl` + `archive` pairs parse exactly as before. diff --git a/.changeset/lint-checked-parse-findings.md b/.changeset/lint-checked-parse-findings.md deleted file mode 100644 index f3d47733a7..0000000000 --- a/.changeset/lint-checked-parse-findings.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -Report an unparseable source instead of scoring it CLEAN (#10653). - -Four validators parsed authored source with `ts.createSourceFile` and never read -`parseDiagnostics`. That call **cannot throw**, so a source with syntax errors -came back as a tree built by error recovery, got walked like any other, and -produced no findings — a source the validator could not read, reported as a -source with nothing to report. Two of the sites carried a `try/catch` around the -parse that never once ran. - -Each now reports what it could not read, as a finding the author receives rather -than as an exit — a publish-time validator is handed metadata by someone else, -so ending the process on their input is not its call. Four new advisory -(`warning`) rule ids, all additive: every finding these rules produce today they -still produce, including from a partially recovered tree. - -- `react-page-source-unparseable` — `kind:'react'` page source - (`validateReactPageProps`) -- `startup-source-unparseable` — plugin source (`findStartupRegistryVerdicts`) -- `hook-body-source-unparseable` — L2 hook body (`validateHookBodyWrites`) -- `action-body-source-unparseable` — L2 action body (`validateActionBodyWrites`) - -New exports: the four rule-id constants, plus `describeParseFailure`, -`PARSE_FAILURE_HINT` and the `SourceParseFailure` / `CheckedParse` / -`CheckedParseOptions` types. `ExtractedHookBodyWriteSet` gains an optional -`parseFailure`, so a consumer of the extractor can tell "wrote nothing" from -"could not be read" — the distinction that was missing. - -Nothing is removed or renamed, and no source that parses gains a finding. A -stack whose authored sources all parse lints exactly as before; one carrying a -source with a syntax error gains a warning that names the file, line and column -instead of silently skipping the checks. diff --git a/.changeset/localization-context-ttl-cache.md b/.changeset/localization-context-ttl-cache.md deleted file mode 100644 index ff3c4fd46d..0000000000 --- a/.changeset/localization-context-ttl-cache.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/core": patch ---- - -`resolveLocalizationContext` now memoizes a FAILED read's fallback per `(ql, tenantId, userId)` for 30s (#10221). - -On a fresh environment whose `sys_setting` table hasn't been created/migrated yet, every authenticated request re-ran the same `sys_setting` localization read, and every one of those reads failed the same way ("no such table"). The `#2409` batching had already collapsed the three per-key reads a single request used to issue into one query, but that one query still repeated on every subsequent request, and `driver-sql`'s `backendStatementFault` logs a `[sql-driver] DATABASE_ERROR` warning on every failed read — so the identical warning printed once per request and buried real errors in between. - -Only the case where the underlying read genuinely fails (a backend fault, e.g. the missing table) is cached; a successful read — including a legitimate "nothing configured yet" empty result — is never cached and always re-reads on the next call, so a settings write takes effect immediately. (An earlier version of this fix cached every outcome, mirroring `packages/plugins/plugin-audit/src/audit-writers.ts`'s existing TTL cache of this same read — safe there because audit-trail enrichment is best-effort, but not safe for `@objectstack/rest`'s use of this function: analytics date-bucketing reads the org timezone on every query and `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` — the #1982/#2018 golden regression — asserts the very next read reflects a just-written timezone.) The `UTC` / `en-US` fallback behavior itself is unchanged; this only stops the failing query — and its log line — from re-running every request. The cache is keyed on the `ql` engine instance first, so two environments/tenants sharing one process never share a cached outcome, and self-heals within one TTL window once `sys_setting` exists. diff --git a/.changeset/manager-approver-org-screen.md b/.changeset/manager-approver-org-screen.md deleted file mode 100644 index 26301d1362..0000000000 --- a/.changeset/manager-approver-org-screen.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/plugin-approvals": minor ---- - -fix(approvals): screen the `manager` approver to the request's organization (#10153) - -`expandApprovers` hands the directory organization to every graph-shaped -approver expansion — `department`, `position`, `org_membership_level`. The -`manager` branch did not: `lookupManager` read `sys_user.manager_id` under a -system context and took no organization argument at all. `sys_user` is a global -identity table with no `organization_id`, so nothing else on that path supplied -the tenancy fact either. A `manager_id` crossing an organization boundary -therefore routed the submission to an approver **in another organization** — an -out-of-tenant person granted approval authority over the record. - -The same column has been screened on the hierarchy side since cloud#1195. This -brings the approvals consumer into line for the `manager` branch. - -## What the screen is - -`lookupManager(userId, organizationId)` now resolves the manager and then asks -whether he is **provably outside** the request's organization: - -| membership rows for the manager | result | -|---|---| -| some exist, none in the request's org | **screened out** — the slot falls through to the `manager:` literal | -| one is in the request's org | resolves, unchanged | -| none exist at all | resolves, unchanged — the tenancy fact is absent, not negative | -| the `sys_member` read failed | resolves, unchanged | -| the request carries no organization | resolves, unchanged — and no read is performed | - -The fail-open half is this file's ruled posture on addressing paths, stated -twice already: `filterApproversWhoCanRead` refuses to empty a live slate on an -infrastructure hiccup, and `expandPositionUsers` carries "a step routing to -nobody is worse than one routing to a lapsed holder". A drop is logged with the -manager's id, his organizations and the request's, so the fix ("repair the link" -/ "grant the membership" / "retarget the step") is legible without a debugger. - -## ⚠️ This moves one input from accepted to refused - -A node whose **sole** approver is a cross-org `manager` and which is authored -with the **non-default** `onEmptyApprovers: 'fail'` used to open successfully; -it now throws `NO_APPROVERS`. Nothing new is thrown — a screened-out manager -leaves only a `type:value` literal, which the pre-existing empty-slate test -already classifies as empty, and `'fail'` already throws on empty. Every -screened sibling has reached that same bucket since it was written. - -**The default policy is unaffected**: `admin_rescue` still opens the request -(decidable by a privileged admin) and warns, and `auto_approve` still -auto-approves. Both directions and both policies are pinned in -`manager-approver-org-screen.test.ts`. - -## What this does NOT decide - -- **#7497** (does approver routing imply record read visibility?) stays open. - The screen reads `sys_member`, which looks like the D2 read filter beside it, - and the code says at length why it is the *sibling* treatment instead: two of - the three org-scoped expansions already screen on `sys_member.organization_id`, - and `sys_user` offers no other tenancy fact. No reads are granted and no read - screen is applied to any type that lacked one. -- **`team`** is still unscreened — it is a sibling graph expansion that is not - org-scoped either, tracked as #10230, and it touches this same file. -- `APPROVER_ORG_SCOPED` is untouched. It answers ADR-0105 D9 *retargetability* - (may an author write `organization:` on this type?), not screening, and - `manager: false` remains correct. diff --git a/.changeset/member-role-projection-validity-window.md b/.changeset/member-role-projection-validity-window.md deleted file mode 100644 index 5d3a543c5e..0000000000 --- a/.changeset/member-role-projection-validity-window.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/core": patch ---- - -A lapsed `sys_member` row now confers no org role either — one row, one answer (#10982) - -`resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it: -`accessible_org_ids` (the `group` posture's read reach, ADR-0105 D2) and the -org-administration role projection into `positions` (ADR-0095 D3). Only the -first applied the ADR-0091 validity window. A membership outside -`[valid_from, valid_until)` was therefore excluded from org access while still -projecting its better-auth role — two answers from one read, and with -`role: 'owner'` the role reaches the `organization_admin` capability that -`derivePosture` reads for `TENANT_ADMIN`. - -The role projection now drops out-of-window rows **before** the derivation, the -same shape `sys_user_permission_set` already had, so an expired membership can -no more yield `org_owner` than an expired `admin_full_access` can yield -`platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22 -live session (item 2): a lapsed membership is *no membership*, not merely *no -org access*. - -**Why `patch` and not a breaking bump, argued in the open.** This is a real -change of authorization semantics — a membership that used to confer a role -stops conferring it — so the direction is a tightening, and tightenings are the -kind of change that normally earns a major. It is nevertheless `patch` because -the population it can affect is provably empty: `sys_member` declares neither -`valid_from` nor `valid_until` (see `sys-member.object.ts`), and `isGrantActive` -reads an absent bound as unbounded, so **no row any deployment can currently -store is lapsed** and every existing membership resolves exactly as before. That -is asserted directly rather than reasoned about, in -`resolve-authz-context.test.ts` ("a membership with NO bounds is unbounded — -every shipped row is unaffected"), alongside the load-bearing leg that an -in-window membership still projects its role. Landing it now is the cheap -moment: once the columns exist, the same change becomes a migration carrying -live semantics. - -**Not in scope, and deliberately so.** This does not add the validity columns to -`sys_member`, and it does not reach into `sys_user_permission_set` rows that -plugin-security's `reconcileOrgAdminGrant` provisioned from a membership role. -Such a grant is standing authority in its own right with its own ADR-0091 -window; the role is only its provisioning source (ADR-0095 D3). The boundary is -pinned as a measured fact rather than left as an assumption. diff --git a/.changeset/messaging-dispatchers-stop-on-shutdown.md b/.changeset/messaging-dispatchers-stop-on-shutdown.md deleted file mode 100644 index c519aa3878..0000000000 --- a/.changeset/messaging-dispatchers-stop-on-shutdown.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-messaging": patch ---- - -**Fix:** `MessagingServicePlugin` now releases its delivery dispatchers on `kernel.shutdown()`. Previously they kept running after shutdown had resolved (#9371). - -The plugin starts two `setInterval` dispatchers at `kernel:ready` — `NotificationDispatcher` over `sys_notification_delivery` and `HttpDispatcher` over `sys_http_delivery` — and released them from a method named `stop()`. The kernel's plugin teardown hook is `destroy()` (`Plugin.destroy?()` in `@objectstack/core`; the only teardown `ObjectKernel.performShutdown()` and `LiteKernel.destroy()` invoke), and `stop()` is not on that interface, so **nothing ever called it**. Both dispatchers went on claiming and updating delivery rows after `await kernel.shutdown()` returned. Measured on the new pin: 48 further delivery reads/writes in the 80 ms following a resolved shutdown. - -The teardown body now lives on `destroy()`. `stop()` is **retained as an alias** — it is public API of an exported class, and an embedder may well have learned to call it directly precisely because the kernel never did. No call site has to change, and no accept/reject behaviour of any contract moves. - -**Why it was invisible in production, and where the bill landed.** `start()` `unref()`s both timers, so a long-lived host process still exits and the leak is silent. Under vitest the worker process is alive throughout teardown, so a tick fires *after* a test file is over, reads a delivery table through a driver the suite already disconnected, and `SqlDriver`'s console fallback warns. `console.*` inside a vitest worker is an RPC to the main process (`onUserConsoleLog`); one issued after `rpcDone()` has snapshotted the pending set is rejected by `$rejectPendingCalls` as `EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending`. Nothing awaits that promise, so it lands as an unhandled rejection and fails a run in which every test passed — twice measured on `examples/app-showcase` (334/334 and 337/337 green, exit 1, a merge-queue eviction each time). The width of the window is the duration of `rpcDone()`, which is why it only ever fired on a loaded queue runner and never on the PR-side run of the identical diff. - -Suites that boot a kernel with this plugin get quieter and finish cleaner as a result: over 48 loaded runs of the affected showcase file, console output emitted after the file's own `afterAll` went 3 → 0, and console RPC round-trips per run roughly halved (6574 → 3456 in aggregate). diff --git a/.changeset/meta-bind-theme-analytics-cube.md b/.changeset/meta-bind-theme-analytics-cube.md deleted file mode 100644 index e72bf5682f..0000000000 --- a/.changeset/meta-bind-theme-analytics-cube.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): `theme` / `analytics_cube` are validated at the `/meta` write door (#10194) - -The two doors #6245 left open, closed the same way. Both are declared, -authorable stack collections with real `.strict()` schemas — -`defineStack({ themes })` validates with `ThemeSchema`, -`defineStack({ analyticsCubes })` with `CubeSchema` — yet neither was bound in -`UNREGISTERED_KIND_SCHEMAS`, so `getMetadataTypeSchema()` answered `undefined` -and `saveMetaItem` took its documented "unregistered type → store without -validation" branch: a body the stack door strictly refuses was stored, -unvalidated and badged `success: true`, through the metadata door. For `theme` -that is the console's own styling surface — a malformed one failed at render -rather than at write, with nothing at the write point to say so. - -**FROM** `PUT /meta/theme/:name` / `PUT /meta/analytics_cube/:name` with any -JSON → `200 { success: true }`, stored unvalidated. -**TO** a malformed body → `422 INVALID_METADATA` with structured `issues[]`, -the same envelope every other kind already returned. A well-formed body is -accepted exactly as before. - -Each entry binds the **same schema its stack collection is validated against** -(`ThemeSchema` at `stack.zod.ts` `themes:`, `CubeSchema` at `analyticsCubes:`), -and that closing invariant is now pinned by identity for all five map entries. - -**No new capability surface.** Shape validation only: no `MetadataTypeSchema` -member, no `DEFAULT_METADATA_TYPE_REGISTRY` entry, so every authorization -verdict keeps taking the identical "no static entry ⇒ synthesised -`allowRuntimeCreate: true`" branch. The write *door* is unchanged; only the -422 is new. #2657's B/C decision on whether these should become kinds is -untouched and unprejudged. `rag_pipeline` is deliberately not bound — it has -no stack collection to take a schema from (#6242 row 2). - -Graded **minor**, following #6245's landed precedent for the identical change -(itself following #5271): a write that previously returned 200 can now return -422. Nothing well-formed changes behaviour, but a caller relying on the API -accepting malformed bodies will see the difference. - -**One schema change rides along per kind, and it is load-bearing.** -`Theme` and `Cube` now declare the ADR-0010 protection envelope (`_lock`, -`_lockReason`, `_lockSource`, `_lockDocsUrl`, `_packageId`, `_packageVersion`, -`_provenance`) — the sharing_rule precedent from #6245: both metadata load -paths call `applyProtection` on **every** type, and these shapes are -`.strict()`, so binding the door without the spread would have aimed the new -422 at the runtime's own stamp instead of at malformed author input. Additive -and internal-only — no authored field changes. diff --git a/.changeset/meta-org-scope-folded-not-raw.md b/.changeset/meta-org-scope-folded-not-raw.md deleted file mode 100644 index 7b04188578..0000000000 --- a/.changeset/meta-org-scope-folded-not-raw.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/rest": patch -"@objectstack/metadata-core": patch ---- - -**Fix:** the REST `/meta` doors now decide **organization scope on the folded type**, never on the raw URL spelling (#10340). - -Storage folds `/meta/:type` through `META_URL_TO_SINGULAR` — the complete spelling map — while the doors' scope predicate (`declaresOrgOverride`) tolerates only the manifest-collection spellings. For the two registry-derived spellings, `translations` and `email_templates`, the doors therefore read and wrote **env-wide** where the singular twin was org-scoped: an org-active author's `PUT /meta/translations/:name` landed an env-wide row their own org-scoped read then shadowed (persisted, receipted as live, served by nothing), and `GET` under one spelling answered a different partition than the other — one item, two namespaces, addressed by spelling (#4432 / #7894's defect one layer down). - -- All nine `/meta` org-scope call sites (list, single read, layers view, compound read, save, compound save, delete, publish, rollback) fold the segment through `canonicalMetaUrlType` **before** calling `organizationIdForMetaRead` / `organizationIdForMetaWrite`, exactly as `metadata-url-spelling.ts` mandates: folding happens at the boundary and only there. -- The `GET /meta/:type/:name/published` code-store fallback folds too — the smaller second site of the same class: it reads a registry keyed by canonical types, so a recognised plural of a code-published item answered 404 while the singular answered 200. -- **Deliberately unchanged:** `GET /meta/_drafts` still applies no fold (it filters by the draft row's *stored* type, which is canonical because the protocol folds on save), the request `type` handed to the protocol stays the raw segment (the protocol owns its own fold), and `declaresOrgOverride` does **not** absorb the URL map — a predicate below the boundary consuming the URL spelling contract is the repair #7894 forbids. `@objectstack/metadata-core` changes are documentation and pins only: the predicate's header no longer claims parity with the protocol's normalization (measured false), and new tests pin both the composed fold→predicate contract and the predicate's deliberate limit. - -No stored rows move: rows previously minted env-wide through a plural spelling stay env-wide and keep serving org-less callers (and org-active callers until an org overlay exists), which is the same layering the singular spelling always had. diff --git a/.changeset/meta-publish-route-package-binding.md b/.changeset/meta-publish-route-package-binding.md deleted file mode 100644 index a09580dca8..0000000000 --- a/.changeset/meta-publish-route-package-binding.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -**Additive:** `POST /meta/:type/:name/publish` now accepts `?package=`, so a single-item draft→active promotion can state the package it belongs to (#10063). - -#9612 taught the runtime publish gate to narrow `objects` to the written item's package closure, but only for callers that can NAME a package. Of the three write doors that reach the gate, `saveMetaItem` (`?package=` on the `PUT` door) and `publishPackageDrafts` (the batch names it) both did; the single-item promotion door named nothing — so every HTTP-driven promotion, which is exactly Studio's designer save→publish loop on every edit, handed the gate the whole tenant. The protocol half already existed and was waiting: `promoteDraftForPublish` declares `packageId?: string | null` and threads it into both the gate and `repo.promoteDraft`. Only the REST caller was mute. - -- **Wire spelling:** `?package=`, deliberately the same parameter name and the same normalisation the `PUT` door states it with — `all` and the empty value mean "env-local overlay, no package", not a package literally named `all`. One value, one spelling across both steps of the save→publish loop. -- **Multiplicity:** a repeated `?package=a&package=b` is refused `400 VALIDATION_ERROR` in the ADR-0112 nested envelope, via the shared `refuseRepeatedQueryParams` rule the sibling doors already carry; a single occurrence encoded as a one-element array is unwrapped and accepted. Previously the parameter was ignored outright on this route, so no caller relying on a documented behaviour changes. -- **Ordering:** the read sits AFTER the `manage_metadata` capability gate, so an uncapable caller still gets `403` rather than a `400` that would let it probe the shape of the surface. -- **Absent behaviour is unchanged, deliberately down to key presence.** The key is omitted from the `publishMetaItem` request when no package is stated, rather than passed as `undefined`. `promoteDraftForPublish` forwards to `repo.promoteDraft` on `'packageId' in request` — the KEY, not the value — because `null` there is a meaningful scope (pin the lookup to the unbound row) while an absent key means "match any package". A present-and-`undefined` key would therefore coerce to `null` downstream and stop package-bound drafts from being found, answering `no_draft` on a path this change was not supposed to touch. - -⚠️ **The acceptance criterion is that the narrowing is now REACHABLE from HTTP, not that publishing got faster.** Package-closure narrowing has a second, independent gate this change does not touch: `narrowObjectsToPackageClosure` keeps any object carrying no `_packageId` provenance, unconditionally, and a tenant-authored overlay corpus carries none. On such a corpus supplying the package still narrows nothing. On a provenance-stamped corpus the shipped deriver measures 421 objects → 45. Both gates must hold; this closes the caller-side one. diff --git a/.changeset/meta-state-route-singular.md b/.changeset/meta-state-route-singular.md deleted file mode 100644 index 24eaac429a..0000000000 --- a/.changeset/meta-state-route-singular.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/client": minor -"@objectstack/rest": minor -"@objectstack/runtime": minor ---- - -The `/meta` FSM state route is singular: `meta.getLegalNextStates` moves, the plural registration is retired (#10077) - -Step 2 of the #9180 ruling — the `/meta` type segment is always singular, no -exception and no tolerated plural alias. Maintainer re-weigh, 2026-08-17, -verbatim: 「② 照原样做;只需要修正 objectstack objectui cloud 中错误的写法。」 - -- `client.meta.getLegalNextStates(object, field, from?)` now requests - `GET /api/v1/meta/object/:name/state/:field`. Same method, same arguments, - same response body — only the path segment changes. -- `GET /api/v1/meta/objects/:name/state/:field` is **no longer registered**. - The singular twin has been mounted alongside it since #7526, so the - migration for a hand-rolled HTTP caller is to drop the `s`. A request to the - retired spelling now gets the transport 404, which is the loud answer; the - one shape that changes hands rather than 404ing is a field literally named - `published`, which the compound `/:type/:section/:name/published` route - picks up. -- The two route ledgers follow what is mounted and what the SDK calls: the - plural row is deleted from `rest-route-ledger.ts` and the dispatcher ledger's - mirror row is respelled. - -**What this does not change.** The boundary fold `META_URL_TO_SINGULAR` is -untouched, so no `/meta/:type/...` spelling that is accepted today becomes -refused: the retired route matched a **literal** path segment and never -consulted the fold. The 2026-08-17 re-weigh (item 3) defers that break with no -scheduled window. The legacy dispatcher branch in `runtime/src/domains/meta.ts` -also still matches both literals; narrowing it is not part of this step. diff --git a/.changeset/metric-filters-retired.md b/.changeset/metric-filters-retired.md deleted file mode 100644 index ffda2ad085..0000000000 --- a/.changeset/metric-filters-retired.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `MetricSchema.filters` — the per-metric raw-SQL filter nothing read (#10414, 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). - -`filters` on a cube metric (`filters: [{ sql: string }]`) was a real authoring -surface — `defineCube()` parses an author literal and -`defineStack({ analyticsCubes })` carries every cube through `StackSchema.parse` -— with ZERO consumers, measured with a positive control: no `.filters` read in -`service-analytics` or any driver's non-test code, while the neighbouring -`format` key IS read. `NativeSQLStrategy.resolveMeasureSql` and -`ObjectQLStrategy.resolveMeasureAggregation` both wrap the metric's `sql` in -the aggregate and never look at `filters` — so a hand-authored -`filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently -returned the UNFILTERED aggregate under the author's metric name. That is the -#10298 dataset-measure failure for a hand-authored cube; the dataset half was -repaired through its own structured channel (#10411), which left this key inert -with the fix built around it. The raw-SQL fragment also ran against the -platform's structured-`FilterCondition` direction: it cannot be parameterized, -re-targeted per driver dialect, or walked by the lint filter rules -(`packages/lint/src/filter-walk.ts` deliberately never enumerated it). - -**What is refused:** `filters` on a metric. `MetricSchema` is `strictObject`, -so the key is deleted from the shape and the unknown-key rejection carries the -retirement prescription via the schema's `guidance` entry (fully-qualified key, -why it was inert, the replacement channels, the `os migrate meta` pointer). -The nested `strictObject` the key carried (closed by #4001 batch D) is gone -with it. - -**What stays accepted:** every other metric key (`name`, `label`, -`description`, `type`, `sql`, `format`) parses byte-identically. Filtering -that actually works is unchanged: the query's `where` (canonical Query DSL -`FilterCondition`), the condition folded into the metric's own `sql` -expression, or an ADR-0021 dataset measure's structured `filter`. - -The retirement kit: - -- strict deletion + `guidance` prescription at the schema - (`packages/spec/src/data/analytics.zod.ts`); the `AnalyticsQuerySchema` - `filters` guidance no longer points authors at the removed key -- ADR-0087 registration: retired-key entry `data/Metric:filters` and the D2 - conversion `metric-filters-removed` (protocol 18), wired into the step-18 - chain — `os migrate meta --from 17` strips the key from every metric in - `analyticsCubes[].measures` (pure lossless delete; it never had an effect to - lose) -- pin tests (`analytics.test.ts` — the old parse-survival pin flips to a - refusal pin asserting the prescription; `analytics-strictness-batchd.test.ts` - records the nested batch-D surface as superseded) -- generated baselines/docs follow the schema (`authorable-surface/`, - spec-changes, upgrade guide, reference docs) - -## FROM → TO - -```ts -// before — parsed green; both SQL strategies ignored it and the query -// returned the unfiltered aggregate -defineCube({ - name: 'orders', - sql: 'orders', - measures: { - closed_won_revenue: { - name: 'closed_won_revenue', label: 'Closed-Won Revenue', - type: 'sum', sql: 'amount', - filters: [{ sql: "stage = 'closed_won'" }], - }, - }, - dimensions: {}, -}); - -// after — delete the key; express the condition where something reads it: -// query time: { where: { stage: 'closed_won' } } -// in the metric: { type: 'sum', sql: "CASE WHEN stage = 'closed_won' THEN amount END" } -// dataset measure: a structured `filter` (ADR-0021, the #10411 channel) -``` - - diff --git a/.changeset/migrate-duplicates-kernel-ready-preflight.md b/.changeset/migrate-duplicates-kernel-ready-preflight.md deleted file mode 100644 index 9cc2e79f5c..0000000000 --- a/.changeset/migrate-duplicates-kernel-ready-preflight.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor -"@objectstack/cli": minor ---- - -`os migrate duplicates` now reports the rows blocking the three `kernel:ready` -NULL-safe index tightenings, and the three migrations' conflict messages point -there instead of at `os migrate plan` (#8725). - -**The gap.** Three migrations replace a declared UNIQUE index with the NULL-safe -— and sometimes active-rows-only — form it was always meant to have, at -`kernel:ready` on a serving boot: - -| table | index(es) | migration | -| --- | --- | --- | -| `sys_metadata` | overlay `active` + `draft` | `ensureMetadataOverlayIndexes` | -| `sys_view_definition` | `idx_sys_view_def_active` | `ensureViewDefinitionActiveIndex` | -| `sys_setting` | the declared row identity | `ensureSysSettingIdentityIndex` | - -Each is a tightening, so rows an installation already holds can block it. The -migration then refuses — previous index kept, no row touched, boot continues — -and reports at `error` on the boot channel. That channel was the only one: -these indexes are invisible to `os migrate plan` **by construction**, twice -over. After the tightening, `isRuntimeManagedIndex` excludes the index (without -that exclusion a boot would propose rebuilding away the guarantee it had just -created); before it, each migration deliberately reuses the *declared* index's -name, so the reconciler's name-matched slot reads as filled whichever physical -form is really there. Measured with a matched control — one database carrying -the same duplicate damage under a declared index and under -`sys_view_definition`'s runtime one — `plan` named the declared one in full and -said nothing whatsoever about the runtime one. - -**What is new.** The report gains a `runtimeIndexPreflight` section, one entry -per index, each `blocked` (with every colliding key group and its row count), -`clear`, `table-absent` (`sys_setting` arrives with the optional settings -service) or `unreadable` (with the driver's own message), plus -`summary.runtimeIndexesBlocked` and `summary.runtimeIndexBlockingRows`. -`reportVersion` moves `1` → `2`. Every `1` field keeps its name, shape and -meaning; the bump says there is more in the document, for consumers that -validate it strictly. - -The probes are the migrations' own duplicate-listing statements — -`@objectstack/metadata-protocol` exports `collectRuntimeIndexPreflight` and -`runtimeIndexProbes`, which read those builders rather than restating the keys, -so the pre-flight and the boot report cannot describe different duplicates. On -MySQL the `sys_setting` probe uses the migration's MySQL spelling, where the -bare form is `ERROR 1064` on the reserved word `key`. - -**The referral, repointed rather than deleted** (maintainer ruling, 2026-08-22). -All three conflict messages told the operator to "run `os migrate plan`" as an -alternative way to list the blocking rows, and that instruction was false: they -now name `os migrate duplicates`, which answers it. The six doc comments that -state the same referral as part of the ADR-0120 D4 disposition are updated with -them. - -**Nothing about a migration's behaviour changes.** No tightening is armed, -deferred or altered, and `os migrate plan`'s drift contract is untouched. The -pre-flight only makes the refusal's evidence readable one command before the -restart — from a command that boots read-only and writes nothing, which is -pinned logically (schema plus every row, ordered) rather than by a file hash: a -raw hash over a SQLite file moves on any read-write open and would accuse this -command of mutating the install it exists to describe. diff --git a/.changeset/migrate-duplicates-null-seam-refusal.md b/.changeset/migrate-duplicates-null-seam-refusal.md deleted file mode 100644 index 4837cf5a87..0000000000 --- a/.changeset/migrate-duplicates-null-seam-refusal.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os migrate duplicates` no longer reports a clean bill of health over a driver it -could not query (#10677). The `no_sql_seam` refusal #8928 mandated was dead code -for the memory driver, so the exact outcome the ruling exists to forbid was -reachable: - -``` -os migrate duplicates --database-url memory://qa - -> exit 0 {"duplicates":[],"skipped":[],"counters":{"status":"read"}} -``` - -`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` -and returns `null` — it neither throws nor is absent. The seam resolver asks -whether the driver has the SHAPE of a seam (`typeof d.execute === 'function'`), -which that satisfies, so the `if (!exec)` guard never fired; and -`normalizeRows(null)` is `[]`, which is also what a real driver returns for a -SELECT that matched nothing. Three statements were swallowed and the report said -the install was clean. - -The command now separates the two cases the guard used to conflate: **a seam -that cannot answer is absent, not empty.** It asks the resolved seam one trivial -statement before the scan starts and refuses when the answer is not a result -set, and it holds every individual probe to the same standard, so a probe that -returns no result set becomes a `skipped` entry with its reason instead of zero -findings. - -``` -os migrate duplicates --database-url memory://qa - -> exit 1 {"error":"no_sql_seam","detail":"The active driver exposes no - usable raw SQL seam — it is either absent, or present but - returning no result set — …"} -``` - -Nothing here names a driver: a seam is judged by what it returns, so any host -with the same no-op shape is covered without an allowlist to maintain. No driver -package was modified. - -Two behaviours are deliberately unchanged. A seam that **throws** is a driver -present and refusing loudly, and the per-probe `skipped` path already reports -that honestly — claiming it here would swallow a transient connection error as -"no seam" and would invent a refusal #8928 never mandated. And a real SQL driver -is unaffected: every shape the new check rejects is one `normalizeRows` already -flattened to `[]`, so no row that used to be reported can be lost. diff --git a/.changeset/migrate-meta-lists-not-rewrites.md b/.changeset/migrate-meta-lists-not-rewrites.md deleted file mode 100644 index 2e0bd68692..0000000000 --- a/.changeset/migrate-meta-lists-not-rewrites.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -Correct a false verb in `os migrate meta`'s own source comments: the `--from` -arm **lists** the mechanical edits an author's source needs; it rewrites no file -(#10831). - -The `pendingDataMigrations` docblock in -`packages/cli/src/commands/migrate/meta.ts` opened with "this command rewrites an -author's source" — 74 lines above the command header that says the opposite -("The command does not silently rewrite TS config source (that AST rewrite is -unsafe and lossy)"). Both `writeFileSync` calls in the file are guarded by -`if (flags.out)`, so the only file the `--from` arm ever writes is the `--out` -JSON snapshot. The in-place codemod is a separate, unbuilt piece of work. - -The contrast the docblock was drawing — metadata migration's subject is the -author's *source*, the two data migrations' subject is a deployment's *rows* — -is correct and is preserved; only the verb on the first half changed. The -`--stored` arm genuinely does rewrite `sys_metadata` rows and its wording is -untouched. - -No runtime behaviour changes: comment-only. diff --git a/.changeset/name-keyed-issue-paths.md b/.changeset/name-keyed-issue-paths.md deleted file mode 100644 index 0818e8cab3..0000000000 --- a/.changeset/name-keyed-issue-paths.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/spec": patch ---- - -Runtime publish-gate findings for collection-resident write types (`object` / -`permission` / `book`) now key the top-level collection entry in -`issues[].path` / `advisories[].path` by NAME — -`objects.acme_invoice.sharingModel` — instead of by the gate's private -per-write snapshot index (`objects[417].sharingModel`), which no caller could -resolve: that index numbered an in-memory array a Studio / MCP / REST receiver -has never seen. Single-member write types keep their trivially-stable -positional form (`flows[0].nodes[1]…`), and nested positions inside one named -item (`objects.acme_invoice.indexes[1]`) stay positional — they index the -author's own document. An entry with no splice-safe name falls back to the -positional spelling. The accepted metadata set is unchanged; only the spelling -of the emitted finding `path` changes, and `RuntimeAuthoringIssueSchema.path`'s -description now states the convention. CLI (`os validate` / `os lint`) output -is unchanged — there the index resolves against the author's own config file. diff --git a/.changeset/nav-run-action-liveness-live.md b/.changeset/nav-run-action-liveness-live.md deleted file mode 100644 index 0a29c14a90..0000000000 --- a/.changeset/nav-run-action-liveness-live.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Liveness-ledger verdict:** `app.navigation[].runAction` moves `planned` → `live`, and drops its `authorWarn` (#10068). - -The declared deep-link slot (`ObjectNavItemSchema.runAction`, #4848/#7253) now has a real consumer in a shipped shell, so authoring it changes runtime behaviour. **What changes for authors:** setting `runAction` no longer raises the liveness advisory that told you the auto-run does not fire from this declaration yet. Nothing about the schema, the accept set, or the authoring-time validation changed — `defineStack`'s cross-reference walk and lint's `validate-action-name-refs` nav arm still reject a name that resolves to no defined action, exactly as before. - -The row carries **two** evidence pointers, not one, and the split is the point: - -- **`producer`** — objectui `packages/layout/src/NavigationRenderer.tsx`: defines `NAV_RUN_ACTION_PARAM` (the wire name's one definition) and applies `withRunAction` inside `resolveHref`'s object branch, on the **list landings only** — never the `recordId` branch. It *writes* the deep link and runs nothing. -- **`evidence`** — objectui `packages/app-shell/src/hooks/useNavRunAction.ts`: the single read-once/consume-once consumer, wired generically at `ObjectView.tsx` (every object list) and behind the entitlement gate at `EnvironmentListToolbar.tsx`. - -A renderer-only pointer would have said the slot is live because something *emits* it; what makes the key live is that a shell *consumes* it, and that lives in `app-shell`, not `layout`. Both were read at the `.objectui-sha` pin `9a3daf8`, which postdates the consumer's merge (objectui#5216 via objectui PR #5354). - -⚠️ **Recorded on the row: enforcement is not consumption.** The published `@objectstack/spec@17.0.0` does **not** enforce the `runAction` × `recordId` exclusivity — the `objectNavTargetExclusivity` refinement exists on `main` but is outside the GA build — and it accepts `runAction: ''`. That is the merged-but-unpublished window, not a defect. The consequence worth carrying: objectui's list-surface-only precedence and its empty-string-is-absent handling are **load-bearing rather than defensive**, because the pinned schema refuses neither input for it. Generalising: merged upstream ≠ published ≠ pinned downstream, and unlike a missing key, a missing **refinement fails silent** — the input is let through and the consumer proceeds. diff --git a/.changeset/objectql-crossobject-conjunct-refusal.md b/.changeset/objectql-crossobject-conjunct-refusal.md deleted file mode 100644 index 8d67f7dbd3..0000000000 --- a/.changeset/objectql-crossobject-conjunct-refusal.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -**BREAKING**: `/analytics/query` now refuses a cross-object filter nested inside a -combinator on the ObjectQL path, instead of silently answering the wrong number -(#10759). - -`ObjectQLStrategy` runs one cross-object envelope check, from two call sites. -`generateSql()` (the `/analytics/sql` preview) asked it about every member the -`where` touches, flattened out of the filter tree. `execute()` asked it about the -built engine filter — where an AND-ed leaf sits at the top level and is seen, but -anything structural (an `$or`, a `$not`, a nested `$and` that cannot merge) has -been folded into `filter.$and`, so the only key readable for it was the literal -`$and`, which is never a field name. - -One query therefore got two answers, measured over one fixture in one run: - -``` -where: { $or: [{ 'account.region': 'West' }, { stage: 'won' }] } - -before /analytics/sql 400 INVALID_FIELD cross-object filter "account.region" - /analytics/query 200, rows -after both 400 INVALID_FIELD cross-object filter "account.region" -``` - -`engine.aggregate` cannot join. The half that returned rows was not answering the -cross-object query: the disjunct naming a column the base object does not have -can never match, so the query silently collapsed to its remaining branches and -reported a narrower figure as if it were the answer. Both call sites now derive -the member list from one shared view, so the invariant the strategy already -stated for itself — the preview accepts and rejects the same set the execution -door does — holds by construction rather than by two call sites agreeing. - -Who is affected: a deployment whose driver reports `objectqlAggregate` but not -`nativeSql` (Mongo, the memory driver), running an analytics query that puts a -related object's field inside `$or` or `$not`. Such a query now returns -`400 INVALID_FIELD` naming the member. The refusal already existed and already -had these words; what changed is that the execution door reaches it too. Nothing -an author writes in metadata changes, no stored shape is affected, and queries -whose combinators name only base-object fields are untouched — that set is pinned -in `crossobject-conjunct-refusal.test.ts` alongside the new refusal, because a -fix that refused every combinator would have looked identical from the refusal -side alone. - -The remedy for an affected query is the one the error message has always carried: -run it on a native-SQL driver, which can join, or drop the cross-object member -from the filter. - - diff --git a/.changeset/objectql-dataset-level-filter.md b/.changeset/objectql-dataset-level-filter.md deleted file mode 100644 index d821259f61..0000000000 --- a/.changeset/objectql-dataset-level-filter.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -Apply a dataset's definition-level `filter` on the ObjectQL analytics path -(#10413, phase 1). `/api/v1/analytics/query` served by a driver that reports -`objectqlAggregate` but not `nativeSql` (MongoDB, the memory driver) reached -`engine.aggregate` with no `filter` key at all: the dataset's own scope — a -`filter: { is_deleted: false }` on the dataset definition — was dropped, so -every measure aggregated the whole table while the dashboard door, on the same -cube and the same measure names, answered the scoped numbers. The scope is now -ANDed into the strategy's whole-call filter (never merged key-by-key, so a -caller's own `where` and the time windows cannot be overwritten by it), and the -representative SQL echo renders it too. - -Per-MEASURE `filter`s on this path are still not applied: an -`engine.aggregate` aggregation is `{ field, method, alias }` and cannot carry a -predicate of its own. Widening that contract is #10576; lowering the measure -filters into it is phase 2 of #10413. The native-SQL path already applies both -(#10298). diff --git a/.changeset/objectql-dataset-scope-crossobject-refusal.md b/.changeset/objectql-dataset-scope-crossobject-refusal.md deleted file mode 100644 index f71ed43ab2..0000000000 --- a/.changeset/objectql-dataset-scope-crossobject-refusal.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -**BREAKING**: on the ObjectQL path, a compiled dataset whose definition-level -`filter` is itself cross-object is now refused by both analytics doors instead -of reaching `engine.aggregate` with a predicate it cannot join (#10861). - -PR #10758 gave the dataset's own definition-level `filter` a route onto this -door for the first time. That route was outside the member view the cross-object -envelope check judges, so nothing ever saw it: - -``` -dataset: object 'opportunity', include: ['account'], - filter: { 'account.region': 'West' } - -before /analytics/query 200, rows -> engine.aggregate received - {"$and":[{"account.region":"West"}]} - /analytics/sql 200, SQL -after both 400 INVALID_FIELD, member "account.region", - cube ""; the engine is never reached -``` - -`engine.aggregate` cannot join. `account.region` is not a column of -`opportunity`, so on any driver that evaluates the predicate honestly it matches -nothing, and the widget answered a number that was neither the scoped number nor -the unscoped one — with no error anywhere. That is the silent mis-bucket #3654's -loud refusal exists to prevent, arriving through a producer #3654 predates. - -**Breaking, and argued rather than assumed.** A query that returns `200` with -rows today starts answering `400`, on a *saved* dataset rather than on anything -in the request — a dashboard that renders today can start showing an error. That -is the strongest reading of "breaking" and it is why this is called out here -rather than filed as a quiet fix. What is *not* lost is any correct answer: the -rows that stop being served were already wrong, and wrong in the way that hides -itself. The refusal names the member, names the dataset, and says the same -definition is valid on a native-SQL deployment, so the operator has somewhere to -go; the previous behaviour gave them a plausible number and nothing to notice. -Rejecting the dataset at compile time in `dataset-compiler.ts` was considered and -not taken (maintainer ruling, 2026-08-22): the compiler cannot see which driver -will serve the dataset, and the same definition is legal on a native-SQL one. - -Who is affected: a deployment whose driver reports `objectqlAggregate` but not -`nativeSql` (Mongo, the memory driver), serving a dataset whose definition-level -`filter` names a field on a related object. Nothing an author writes changes -shape, no stored document is rewritten, and an **ordinary** dataset scope -(`filter: { is_deleted: false }`) still passes both doors and still reaches the -engine carrying its predicate — that direction is pinned one character away from -the new refusal in `crossobject-conjunct-refusal.test.ts`, because an -implementation that refused *every* dataset scope would look identical from the -refusal side alone and would break every scoped dataset shipping today. - - diff --git a/.changeset/olive-pandas-repeat.md b/.changeset/olive-pandas-repeat.md deleted file mode 100644 index b3c8fdccf6..0000000000 --- a/.changeset/olive-pandas-repeat.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/service-package": patch ---- - -`get()` and `list()` no longer report "not installed" / "nothing installed" over a storage seam they never queried. - -A driver that cannot run raw SQL returns no result set rather than throwing (`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns `null`), and the service's row flattener mapped that to `[]` — the same value a working driver returns when a package genuinely is not installed. Both read paths then handed that back as a product answer, and the boot-time `sys_packages` rehydration skipped silently because of it. - -Reads now establish that the seam ANSWERED before reading emptiness as a fact. A seam that returns no result set is refused with `SERVICE_UNAVAILABLE` / 503 and a message saying the answer is unknown; boot logs the skipped rehydration at `warn` instead of passing over it. A seam that answers with genuinely zero rows is unchanged: `get()` still returns `null` and `list()` still returns `[]`. diff --git a/.changeset/olive-pumas-repeat.md b/.changeset/olive-pumas-repeat.md deleted file mode 100644 index 637381a744..0000000000 --- a/.changeset/olive-pumas-repeat.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Docs accuracy: correct the `AgentSchema` example and four stale `.strict()` tombstone rationales - -`AgentSchema`'s own `@example` wrote `knowledge: { sources: …, indexes: … }`, a key the -same schema declares as `retiredKey()` — so the canonical example an author (very often an -AI, ADR-0033) copies taught a key the schema rejects, and typed `never` fails `tsc` at the -authoring site. The line is dropped; the example keeps `skills`, which is the block's point. - -Four tombstone rationales still argued from "the schema is not `.strict()`, so a plain -deletion would silently strip the key". The #4001 `strictObject` conversion made that false -for the schemas named: `AgentSchema` (`agent.tools`), `FieldSchema` -(`field.conditionalRequired`), `ActionSchema` (`action.execute`), and the module docblock of -`shared/retired-key.ts` itself. Each now rests on the reason that is load-bearing today — -the prescription is the payload, since an unknown-key rejection carries neither the -FROM → TO mapping nor the migration command, and the key is typed `never` so the mistake -still fails `tsc` first. Every tombstone stays; only the stated reason changes. - -Prose only — no schema shape, acceptance behaviour or `.describe()` semantic is touched. diff --git a/.changeset/optional-error-sink-contract-requires-warn.md b/.changeset/optional-error-sink-contract-requires-warn.md deleted file mode 100644 index 82f1ca0477..0000000000 --- a/.changeset/optional-error-sink-contract-requires-warn.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-email": minor -"@objectstack/plugin-security": minor ---- - -`SweepLogger` and `ProjectionLogger` now declare `warn` as a REQUIRED channel, so a sink handed to the boot outbox sweep or to permission-set reconciliation can no longer be one that prints nothing (#9754) - -Both interfaces declared every member optional — `info?`, `warn?`, `error?` — which made `{ info }` a legal sink. Against such a sink both durability reports evaporated: each reaches for `error`, finds none, falls back to `warn`, and finds none of that either. For the sweep that is mail the platform accepted and never delivered, summarised to nobody; for reconciliation it is a permission set that will not survive a re-provision, with the `info` "reconciled" line skipped as well, so the sink heard neither the failure nor the reassurance. - -#9657 and #9748 repaired the call-site spellings. This is the other half, and the half that cannot regress: an optional `error` with no guaranteed alternative is a contract that permits silence, so an author reading the interface can write a report that never prints and be right about the type. Requiring `warn` makes that unrepresentable at the point of authoring rather than catchable one gate-run later. - -`error` deliberately stays optional on both types — hosts do inject reduced sinks, and requiring `error` would foreclose the `{ warn }`-only host the drivers were written for. - -If you pass a logger of your own and it declares no `warn`, add one; the kernel `Logger`, `ctx.logger` and `console` all satisfy the tightened shape unchanged. Consumers reach these types through `@objectstack/plugin-security`'s exported `ProjectionDeps`; `SweepLogger` is internal to `@objectstack/plugin-email`. - -The rule now has a checker of its own: `pnpm check:optional-error-sink` scans every sink type in `packages/**`, reports the population as a census on every run, and carries a shrink-only ledger of the 15 sinks that still permit silence. diff --git a/.changeset/optional-error-sink-paydown.md b/.changeset/optional-error-sink-paydown.md deleted file mode 100644 index c4fcdfbdd7..0000000000 --- a/.changeset/optional-error-sink-paydown.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/cloud-connection": minor -"@objectstack/metadata-protocol": minor -"@objectstack/plugin-approvals": minor -"@objectstack/plugin-audit": minor -"@objectstack/plugin-auth": minor -"@objectstack/plugin-email": minor -"@objectstack/plugin-reports": minor -"@objectstack/plugin-sharing": minor -"@objectstack/plugin-webhooks": minor -"@objectstack/service-knowledge": minor ---- - -**BREAKING** (compile-time only): twelve logger sink types that declared an -optional `error` now declare a **non-optional** `warn`, so a durability report -always has somewhere to land (#9754, #10556). - -`minor`, not `major`: during the launch window this stack ships breaking changes -as `minor` — every publishable package versions in lockstep, so a `major` would -promote the whole release. `patch` would be wrong in the other direction, because -this *can* break a consumer's build. - -`error` stays optional on every one of these types — hosts legitimately inject -reduced sinks, and requiring `error` was measured and rejected as #9754 option C. -What changes is that its *absence* now has a declared, guaranteed destination. -Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the -type cannot reach, so **no runtime behaviour changes**: nothing that printed -before stops printing, and nothing silent starts printing. - -### Who has to change, and what to do - -Only a caller that hands one of these sinks an object with **no `warn` method** — -for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no -rename, no removal, and no stored value or metadata key to rewrite. Every -construction site inside this repo already supplied one, so the in-repo cost was -zero; the compile error is reserved for the callers that were silently discarding -these reports. - -The affected types, by package: - -- `@objectstack/cloud-connection` — the internal `PluginContext['logger']` -- `@objectstack/metadata-protocol` — `IndexMigrationLogger` -- `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` -- `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` -- `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal - `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` -- `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` -- `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` -- `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, - `rule-hooks` and `record-share-cascade` -- `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` -- `@objectstack/service-knowledge` — `KnowledgeLogger` - -`AuthManagerOptions['logger']` is the one most likely to be reached from outside: -`AuthManager` is public surface, its `logger` option stays optional, and a logger -that *is* supplied must now carry `warn`. The only non-test construction site in -this repo passes the kernel `Logger`, whose `warn` is already required. - -`ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger -field to `{}`. The field is now honestly optional rather than holding an empty -object that declared it could report and discarded everything. Behaviour is -unchanged in both directions. - - diff --git a/.changeset/os-g-skill-scaffolder.md b/.changeset/os-g-skill-scaffolder.md deleted file mode 100644 index 1f59704fba..0000000000 --- a/.changeset/os-g-skill-scaffolder.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os g skill NAME` scaffolds an AI skill, and writes it as `NAME.skill.ts` so the loader can find it (#11025) - -Completes the second half of the ADR-0063 Option A ruling whose first half -retired `os g agent` (#10359). That retirement left authors told to write -`src/skills/NAME.skill.ts` by hand because no scaffolder existed; this adds it, -and `os g agent`'s refusal, the CLI README and the CLI docs now name the -command instead of apologising for its absence. - -The filename is the point, not a detail. `DEFAULT_METADATA_TYPE_REGISTRY` -declares `skill`'s file convention as `*.skill.ts` / `*.skill.yml`, while this -harness has always written `NAME.ts`. `skill` is `allowRuntimeCreate: true` — -a type the platform expects to discover — so a scaffold matching no pattern -would type-check, validate and publish with nothing anywhere reporting that it -had been skipped: the silent-strip shape the `agent` retirement closed, -re-entering through the scaffolder that replaced it. `skill` therefore -overrides the harness filename through a new per-generator hook, and the -barrel re-export is derived from the file that was actually written rather -than rebuilt from the metadata name. - -**The other six generators are unchanged** and still write `NAME.ts` with a -`'./NAME'` barrel line, pinned by a control assertion in the new test. -Converging the whole scaffolder on the registry's `NAME.TYPE.ts` convention — -the shape the example apps already author in — moves every generator's output -plus the docs and examples that show it, and is deliberately left as its own -decision. - -Three authoring choices the template makes, each written into the generated -file so the next author inherits the reasoning and not just the value: -`tools: []`, because under ADR-0064 an agent's tool set is the union of its -skills' tools with no global fall-through, so an empty list grants nothing -while a placeholder name would resolve to nothing and be reported by -`os validate` as `ai-skill-tool-unresolved`; `surface: 'ask'` written out -rather than left to the schema default, because the affinity it declares is -enforced at load and a default taken in silence is invisible to whoever edits -the file next; and `defineSkill` rather than a bare typed literal, so the -object is parsed at module load. The template is **not** copied from -`SkillSchema`'s or `defineSkill`'s `@example` blocks — both pass -`triggerPhrases`, a retired-key tombstone that rejects on parse (#11026). diff --git a/.changeset/packages-list-durable-read-refusal.md b/.changeset/packages-list-durable-read-refusal.md deleted file mode 100644 index aafdf60144..0000000000 --- a/.changeset/packages-list-durable-read-refusal.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -`GET /api/v1/packages` no longer absorbs a failed durable read into a 200 registry-only listing. - -The handler merged two sources — the in-memory registry and the durable `sys_packages` rows read through `PackageService.list()` — and wrapped the durable half in a bare `catch {}` commented "Database query failed — continue with registry-only packages". A read that could not happen was therefore reported as a read that found nothing: the door answered `200` with `{ packages, total }` built from the registry alone, `total` was presented as a COMPLETE count either way, and the registrar-sourced entries kept `source: 'registry'`, which reads as provenance rather than as a warning that the database half is absent. Nothing on the wire separated "these are all the packages" from "these are the packages I could still see". - -The durable read is no longer caught at this door. `PackageService.list()` still swallows its own driver faults and answers `[]`, and re-throws only the declared seam refusal introduced alongside it (`SERVICE_UNAVAILABLE` / 503, raised when the storage seam accepted the query and returned no result set) — so that refusal now travels to the client through the existing declared envelope, carrying the producer's own status and code. An undeclared throw becomes a `500 INTERNAL_ERROR` through the same envelope. A durable read that answers is unchanged: both sources still merge, `source` is still `registry` / `database` / `both`, and `total` is still the count of what was really read. - -This aligns the two read doors. `GET /api/v1/packages/:id` has no such inner catch and has answered that same refusal since the producer-side change; the list door answering `200` while the detail door refused was the inconsistency. - -**Bump level — why `patch` and not `minor` or `major`.** Nothing an author can write changes: no spec key, export, config field, request shape or response shape is added, removed or renamed, so this carries no migration and is not breaking. No capability is added either, so it is not a feature. What changes is that one door stops reporting a failure as a successful complete answer — a correctness fix to an existing contract, and the same disposition the producer-side half of this fix shipped under. Callers that treated a `200` from this door as "the complete package list" were already being told something untrue when the durable read failed; they now receive the declared refusal instead, exactly as they already did from the sibling detail route. diff --git a/.changeset/page-source-styling-primitive-prose.md b/.changeset/page-source-styling-primitive-prose.md deleted file mode 100644 index 3a178a19fa..0000000000 --- a/.changeset/page-source-styling-primitive-prose.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Name the real per-tier styling primitive in `PageSchema`'s `kind` and `source` -descriptions, replacing the "JSX/HTML+Tailwind" framing that ADR-0080's 2026-06-30 -amendment retracted on styling. - -A page's `source` is runtime metadata, so the console's build-time Tailwind never -scans it — authored utility `className`s silently produce no CSS. The descriptions -now say what each tier actually styles with: `kind:'html'` via the registered -components' structured props plus a JSON `style` object with `hsl(var(--token))` -theme colors, `kind:'react'` via inline `style` with the same token colors, and -neither with Tailwind classes. - -Text-only correction, no schema shape or acceptance change — the accepted page set -is unchanged, and every other claim in the two descriptions survives verbatim -(parse-never-execute, the compiler package per tier, `source` authoritative over -`regions`, the ADR-0081 `OS_PAGE_REACT=off` gating). - -- `packages/spec/src/ui/page.zod.ts` — the `kind` and `source` `.describe()` - strings and the `source` TSDoc block, which regenerate - `content/docs/references/ui/page.mdx`. diff --git a/.changeset/pause-ends-retry-segment.md b/.changeset/pause-ends-retry-segment.md deleted file mode 100644 index 1486b04e12..0000000000 --- a/.changeset/pause-ends-retry-segment.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Document the retry/durable-pause boundary on a flow's `errorHandling` block: a durable -pause (`approval`, `screen`, `wait` — ADR-0019) **ends the retry-governed segment**. -`errorHandling.strategy: 'retry'` describes one synchronous dispatch, so a run that pauses -and later resumes gets exactly one attempt for anything that fails after the pause. - -Prose only — no validation change. The accepted flow set is unchanged and every flow that -parsed before parses identically; what changes is that the boundary is now stated where an -author meets it (the `errorHandling` and `strategy` `describe()` text, which is what the -generated reference tables render) instead of having to be inferred from engine behaviour. - -The boundary is deliberate rather than a gap: the retry knobs (`backoffMs`, -`backoffMultiplier`, `jitter`) model an in-process loop, which a pause of arbitrary -duration is not, and the durable continuation carries no attempt counter. To protect the -half of a flow that runs after a pause, give that half its own failure handling in the -flow — a `try_catch` node with its own `retry` around the post-resume work, or a `fault` -edge to a handler node. `content/docs/automation/flows.mdx` carries the recipe. diff --git a/.changeset/per-item-publish-rebind-and-draft-scope.md b/.changeset/per-item-publish-rebind-and-draft-scope.md deleted file mode 100644 index e9d886a4e9..0000000000 --- a/.changeset/per-item-publish-rebind-and-draft-scope.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/objectql": patch ---- - -Per-item publish (`POST /api/v1/meta/:type/:name/publish`) now re-binds runtime consumers -and finds drafts authored env-wide — the two things the package-scoped publish door -already did. - -**A metadata publish now announces `metadata:reloaded` on BOTH doors.** The event that -tells boot-cached consumers to re-read had two announcers: the dev-artifact watcher and -the runtime dispatcher after `POST /packages/:id/publish-drafts`. Publishing item by item -— what AI authoring and the item-level Studio doors do — announced nothing, so a flow -published while the server ran stayed `state='active'` and completely inert (no trigger -bound, no execution) until the kernel was rebuilt. `publishMetaItem` now notifies its host -through a new `onMetaItemPublished` seam and `ObjectQLPlugin` turns that into the kernel -announce, so `service-automation`'s flow re-bind, the authored hook/action re-sync, -declarative connectors and authored translations all catch up without a restart. The -announce is awaited, so the publish's own 2xx means the re-bind was attempted; a -subscriber failure is logged and never fails the publish. The batch door is unchanged — -it keeps its single per-publish announce rather than gaining one per promoted draft. - -**A per-item publish now resolves the draft's own org scope.** For the types the registry -declares `allowOrgOverride: true` (`view`, `dashboard`, `report`, `translation`, -`email_template`) the REST seam threads the session's active organization into the -publish, while package/AI authoring writes the draft env-wide — so the strict org lookup -matched nothing and answered `404 [no_draft] … nothing to publish` over a draft the -console's pending-changes banner was listing and the batch button published fine. The -per-item door now discovers the draft's scope the way `publishPackageDrafts` has since -#3115, with the ADR-0005 precedence (an org holding its own draft publishes that one) and -the same `NO_DRAFT` refusal when no scope holds a draft. diff --git a/.changeset/per-organization-rbac-catalog.md b/.changeset/per-organization-rbac-catalog.md deleted file mode 100644 index 8adb280bda..0000000000 --- a/.changeset/per-organization-rbac-catalog.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/plugin-sharing": minor -"@objectstack/core": patch ---- - -Materialize the RBAC catalog **per organization**, so a walled deployment can -administer positions, permission sets and sharing rules again (#10103). - -On a walled deployment (`group` / `isolated`) every principal — an organization -owner and a platform admin alike — listed **zero** positions, permission sets -and sharing rules while the tables held rows. Nothing could be bound through -Setup, and a declared `hierarchy-security` could never be armed by an operator -however loudly an app declared it. - -Every row in those three tables was organization-less. plugin-security's Layer 0 -composes a strict `organization_id = :tenant` for a walled posture and the -middleware ANDs it into the read AST over the driver's -`(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the -two is the strict equality alone, so the driver's null arm was annihilated on -every authenticated read. - -**The wall is not changed, at either layer.** The rows get an owner instead: - -- `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`, - `bootstrapDeclaredPermissions` (plugin-security) and - `bootstrapDeclaredSharingRules` (plugin-sharing) upsert by - `(name, organization_id)` and run **one pass per organization** under a walled - posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`, - `guest`) included, matching `sys_user_position`, which is already - per-organization, and matching both objects' own `unique: 'organization'` name - index. -- Seeding also fires on **organization creation**, not only at `kernel:ready`, so - a tenant created after startup does not administer an empty catalog until the - next restart. -- `single` posture is **unchanged**: exactly one organization-less pass, which is - the correct shape there. - -An organization-less row is now invalid state under a walled posture. Nothing is -reaped — grants (`sys_user_position`, `sys_position_permission_set`, -`sys_user_permission_set`, `sys_record_share`) point at these rows by id, so -deleting them would revoke standing access with no signal at the moment of loss. -Instead a per-organization pass that meets pre-fix organization-less rows for -names it seeds **says so loudly**, naming the rows and the remedy, and still -creates that organization's own copies. The failure this closes is the silent -no-op: a tenant-threaded pass that sees the old row through the driver's -compatibility arm, reads the name as already represented, and creates nothing -while reporting success. - -Two enforcement-plane reads are scoped in the same change, because the exposure -they carry only exists once per-organization copies exist: - -- `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved - `sys_position` by name across **every** organization, so the junction read - behind it collected another organization's `everyone` binding — a cross-organization - grant bleed, and an O(organizations) read on the per-request path. It is now - threaded through the driver's tenant chokepoint, keeping per-request resolution - O(the caller's own organization's catalog). -- plugin-security's permission-set `dbLoader` resolved sets by name unscoped, - with a `limit` equal to the number of names — correct while one row existed per - name, a truncation the moment copies exist. It is now scoped to the caller's - organization and its bound widened. - -Boot reconciliation is O(changed declarations): each pass reads what its -organization already has and writes only where a declaration actually differs, so -the common boot performs no writes at all. Steady state rides the -organization-creation hook. - -Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization -sharing rules cheaper than the unscoped sweep they replace. diff --git a/.changeset/pg-json-binding-ddl-free-registration.md b/.changeset/pg-json-binding-ddl-free-registration.md deleted file mode 100644 index 65964f6655..0000000000 --- a/.changeset/pg-json-binding-ddl-free-registration.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/driver-sql": patch -"@objectstack/objectql": patch -"@objectstack/spec": patch ---- - -Fix JSON-field writes on Postgres deployments that manage DDL out-of-band -(`skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`): a non-empty array and a bare -string were rejected with a 500, and an empty array was **silently stored as an -empty object** (#10995). - -The SQL driver does `JSON.stringify` a JSON field's value on every non-SQLite -dialect — but only for fields listed in its per-object `jsonFields` registry, -and that registry (like the boolean / numeric / date / datetime / time / -auto_number registries and the tenant-isolation column) was filled **only** as -the first step of a DDL call. A deployment that skips boot schema sync therefore -served every write knowing nothing about its objects, and values reached -node-postgres to be encoded by its per-type defaults: - -- an **object** became JSON text — accidentally correct; -- an **array** became a Postgres ARRAY LITERAL (`{…}`) — `22P02 invalid input - syntax for type json`, a 500 on every write; -- **except `[]`**, whose array literal `{}` is valid JSON, so an empty array was - accepted and stored as an empty **object** — corruption, not an error; -- a **bare string** was passed raw (`x` is not JSON text, `"x"` is) — a 500, - while a number survived because `42` already is valid JSON. - -SQLite never showed any of it: `formatInput` ends with a bind-safety net gated -on that dialect, so the same empty registry is invisible there — which is why -tenant environments on Turso/SQLite and the suites that run on them were blind -to a defect live on every Postgres deployment. - -The registration is now separable from the DDL, on the ruling #7737/#10629 -already made for federated objects — that flag is about DDL, and a binding that -is DDL-free must not ride on it: - -- `SqlDriver.registerObjectMetadata(objects)` installs a managed object's - coercion metadata with no `CREATE TABLE`, no `ALTER TABLE`, no existence probe - and no round-trip — the managed sibling of `registerExternalObject`, declared - optional on `IDataDriver` so drivers that don't need it omit it; -- a `skipSchemaSync` boot (and metadata reload) now takes that route instead of - doing nothing, keeping the cold-start budget the flag exists to protect; -- `initObjects` registers before the ADR-0015 DDL gate refuses, so objects on a - datasource ObjectStack is only a guest in are encoded from their declared - field types too. The refusal itself is unchanged. diff --git a/.changeset/plugin-auth-example-hono-server-dependency.md b/.changeset/plugin-auth-example-hono-server-dependency.md deleted file mode 100644 index 4a14a83071..0000000000 --- a/.changeset/plugin-auth-example-hono-server-dependency.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -Declare `@objectstack/plugin-hono-server` and put the published auth example in a -tsc program (#10869). - -`packages/plugins/plugin-auth/examples/basic-usage.ts` — the file -`content/docs/permissions/authentication.mdx` publishes as "Basic Auth Example" — -imports `HonoServerPlugin` from `@objectstack/plugin-hono-server` on line 12, and -this package declared that dependency in **none** of `dependencies`, -`devDependencies` or `peerDependencies`. (It declares `hono`, which is a different -package.) So the example could not resolve, compile or run for anyone who copied -it out of the docs: - -``` -examples/basic-usage.ts(12,34): error TS2307: Cannot find module -'@objectstack/plugin-hono-server' or its corresponding type declarations. -``` - -The declaration is now there (`devDependencies`, `workspace:*` — the example is -development material, and `files` ships only `dist`, so nothing new reaches a -published tarball). - -**The dependency alone would have been unverifiable, which is the other half of -this change.** `tsconfig.json` selects `include: ["src/**/*"]`, so `examples/` sat -in no tsc program at all — the type-check-coverage census's only instance of that -— and a manifest edit does not change an `include`. The fix would have had no -compile behind it and the defect could return unseen. So the directory now has a -program: `packages/plugins/plugin-auth/tsconfig.examples.json`, a non-emitting -sibling named in the package's `typecheck` script, following the precedent -`packages/spec/tsconfig.scripts.json` and `packages/objectql/tsconfig.scripts.json` -set. Strictness is inherited, not relaxed, and the directory enters with zero -recorded debt — the example type-checks clean under `strict`, which also measures -that every API it demonstrates (`ObjectKernel.use`/`bootstrap`/`getService`, -`HonoServerPlugin({ port })`, and every `AuthPluginOptions` key it passes) still -exists as written, so it is a working reference rather than a stale one. - -Because the directory is now read, `packages/plugins/plugin-auth/examples` leaves -`UNCHECKED_SOURCE_DEBT` in `scripts/check-type-check-coverage.mjs` — the ratchet -shrinks because the thing was repaired, and `RECONCILED` required the deletion in -the same change. diff --git a/.changeset/plugin-teardown-reached-by-kernel.md b/.changeset/plugin-teardown-reached-by-kernel.md deleted file mode 100644 index 3bc89ff57a..0000000000 --- a/.changeset/plugin-teardown-reached-by-kernel.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/metadata": patch -"@objectstack/runtime": patch -"@objectstack/plugin-email": patch -"@objectstack/plugin-webhooks": patch ---- - -Five `Plugin` implementations now release their resources from `destroy()`, the -only teardown hook the kernel calls (#10772). - -`Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and -`destroy?()`. `ObjectKernel.performShutdown()` and `LiteKernel.destroy()` walk -the plugins in reverse calling `plugin.destroy()` — and nothing anywhere calls -`stop()`, `dispose()`, `close()` or `shutdown()` on a plugin. Each of these five -spelled its teardown with one of those names instead, so what it released was -still held after `await kernel.shutdown()` had **resolved**: - -| package | class | was spelled | what outlived shutdown | -|:--|:--|:--|:--| -| `@objectstack/metadata` | `MetadataPlugin` | `stop` (arrow property) | artifact watcher, `manager.dispose()`, repository handle | -| `@objectstack/runtime` | `AppPlugin` | `stop` (arrow property) | the `app:unregistered` catalog event, never emitted | -| `@objectstack/runtime` | `ExternalValidationPlugin` | `stop` (arrow property) | every armed drift-check `setInterval` | -| `@objectstack/plugin-email` | `EmailServicePlugin` | `dispose` | two metadata subscriptions, the SMTP transport, an engine binding | -| `@objectstack/plugin-webhooks` | `WebhookOutboxPlugin` | `dispose` | the auto-enqueuer (2 realtime subscriptions + a refresh interval) and two engine hooks | - -`ExternalValidationPlugin` is the one with teeth: it is one of only two `Plugin` -implementations in the tree that own `setInterval` directly, it is mounted on -the real `os serve` path, and its `stop()`'s only caller anywhere was the class -itself re-arming. Measured against a real kernel, its drift checker performed -five further reads in the five intervals after a resolved shutdown — the #9371 -mechanism verbatim. `WebhookOutboxPlugin.dispose()` had **zero** callers in the -entire repo, so its teardown had never run in any process at all. - -**Nothing is removed and no signature narrows.** Each old name is retained as a -delegating alias, because it is public API of an exported class and an embedder -may have learned to call it directly precisely BECAUSE the kernel never did. -`stop` stays an arrow property where it was one (so a detached -`const { stop } = plugin` keeps working) and stays synchronous on -`ExternalValidationPlugin` (so a non-awaiting call site is unaffected). The two -`stop(ctx)` aliases widen their parameter to optional. - -One behavioural note for direct callers, since `destroy()` takes no context: -`MetadataPlugin.stop(ctx)` and `AppPlugin.stop(ctx)` now use the context -captured in `init()` and ignore the argument. In a real composition these are -the same object. The visible difference is confined to a plugin whose `init()` -never ran — for `MetadataPlugin` a dropped `warn` line, for `AppPlugin` a -catalog event that is no longer emitted for an app that was never registered. diff --git a/.changeset/plugin-teardown-reaches-destroy.md b/.changeset/plugin-teardown-reaches-destroy.md deleted file mode 100644 index 8958c81545..0000000000 --- a/.changeset/plugin-teardown-reaches-destroy.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/plugin-reports": patch -"@objectstack/connector-openapi": patch -"@objectstack/connector-rest": patch -"@objectstack/connector-slack": patch -"@objectstack/plugin-approvals": patch -"@objectstack/service-knowledge": patch ---- - -Release these plugins' resources from `destroy()`, the teardown hook the kernel -actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and -`destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and -`LiteKernel.destroy()`, which walk the plugins in reverse calling -`plugin.destroy()`, walked straight past every plugin whose teardown was spelled -`stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still -armed, the REST/OpenAPI/Slack connectors still registered on the automation -engine, the approvals SLA escalation job still scheduled, and the knowledge -event-sync subscription still open. - -Each teardown body now lives in `destroy()`. `stop()` is retained as a -delegating alias with its parameter made optional, so an embedder that learned -to call it directly — precisely because the kernel never did — keeps working -unchanged. No export is removed and the `Plugin` interface is untouched. - -Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as -fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted -from the merge queue. diff --git a/.changeset/position-permissions-column-retired.md b/.changeset/position-permissions-column-retired.md deleted file mode 100644 index a11c7d7be5..0000000000 --- a/.changeset/position-permissions-column-retired.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/spec': minor ---- - -fix(security): **BREAKING** — `sys_position` retires the `permissions` column (ADR-0049 enforce-or-remove, #9885) - -Maintainer ruling 2026-08-20: **REMOVE**. The column — a "JSON-serialized array -of permission strings" textarea — was declared on the platform position table -while **no producer ever wrote it and no runtime path ever read it**. The -object-scoped census (every `sys_position`-naming file, with same-object -positive controls resolving `active` / `delegatable` / `is_default` / `name` -to real readers) measured it at zero on both sides: the builtin and declared -position bootstrappers set `label` / `description` / `managed_by` / `active` / -`is_default` only, and position→grant resolution consults -`sys_position_permission_set` rows plus the position `name` — never this -column. Its only reference was the `clone_position` action copying it between -rows (a copy of a value nothing writes), removed in the same stroke. objectui -was searched under the same discipline: no console surface names the column. -A free-text grant catalogue on a security object that no runtime enforces -tells an author — human or AI — that direct position-level permission strings -are a platform capability; they are not. This is an **accept-set narrowing**: -the platform stops declaring, projecting and accepting the column. - -Migration (FROM → TO): - -| Wrote | Write instead | -|---|---| -| `permissions` on a `sys_position` seed row or data-door write | Delete the key. Capability reaches a position **only** through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder); prose that was documenting intent belongs in `description`. | - -One-line fix: delete `permissions` from any authored `sys_position` row. - - - -Enforcement after the removal is loud, not silent: the engine's schema -preflight refuses an undeclared field with `400 INVALID_FIELD` before the -driver or any hook runs, and `PositionSchema`'s strict parse now rejects a -declared-position `permissions` key with guidance naming the binding table. -Physical columns on already-deployed databases are untouched (ADR-0045 schema -sync is additive). If position-level direct grants ever become a real need, -the column is re-declared **with a runtime reader in the same PR** — -declare-and-enforce or don't declare. diff --git a/.changeset/publish-batch-closure-carries-pending-drafts.md b/.changeset/publish-batch-closure-carries-pending-drafts.md deleted file mode 100644 index fab2fbdd63..0000000000 --- a/.changeset/publish-batch-closure-carries-pending-drafts.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix: a package publishes as a self-consistent unit — `publishPackageDrafts` judges each draft against the batch's own pending declarations - -The batch publish door built the author-time validation context from -`engine.registry` alone, i.e. the ALREADY-LIVE universe. A draft is not in that -registry, and the batch's own promotions do not put it there either: the -registry write-through runs in Phase 2, after the Phase-1 transaction that gates -and promotes every draft. So while a batch was being judged, no member of it was -visible to any other member — in any order. - -Measured consequence: a package shipping `dataset/x` together with a `dashboard` -whose widget binds `x` could NEVER publish. `validateWidgetBindings` raises -`widget-dataset-unknown` at `severity: 'error'`, which refuses the promotion, -and the batch being all-or-nothing rolls the whole package back. Renaming the -dataset could not help, and neither could re-ordering the items. - -`publishPackageDrafts` now reads its own pending drafts once, before any -promotion, and folds them into all four context collections the closure carries -(`objects`, `permissions`, `books`, `datasets`) — pending declarations replace a -live one of the same name, never sit beside it. A binding that resolves to -neither the batch nor the live universe is still refused exactly as before. diff --git a/.changeset/publish-drafts-outcome-discriminant.md b/.changeset/publish-drafts-outcome-discriminant.md deleted file mode 100644 index 482250dbae..0000000000 --- a/.changeset/publish-drafts-outcome-discriminant.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor ---- - -Declare `outcome: 'published' | 'refused' | 'nothing_to_publish'` as a required -key on the `publishPackageDrafts` response (#10462) — the first-class -discriminant for WHICH exit answered, the fact `success` compresses into one -boolean. Before this field, a publish with nothing to promote and a genuine -refusal (pre-flight violation or ADR-0067 D2 rollback) were indistinguishable: -both answer `success: false` with `publishedCount: 0` on a 200, and the no-op -left no trace at all — an AI consumer graded the no-op as "refused and rolled -back" and burned two repair rounds on artifacts that were already correct -(cloud#1488; cloud#1492's patch discriminates on `failed.length > 0`, an -invariant the producer never stated). - -The producer invariants, now stated and pinned in the conformance suites, both -directions of each: `outcome === 'refused'` ⟺ `failed.length > 0`; -`outcome === 'nothing_to_publish'` ⟺ -`published.length === 0 && failed.length === 0`; -`success === (outcome === 'published')`. `success` keeps its exact pre-#10462 -value on every exit — a no-op still answers `success: false` — so consumers -reading only `success` see no change, and cloud#1492's `failed.length` -discrimination stays valid during its convergence onto `outcome`. The no-op -exit additionally logs one `info` line naming the package and both facts -(nothing pending, nothing refused), so that exit is no longer traceless. - -Additive for response consumers. A custom protocol implementation that serves -`publishPackageDrafts` must now emit `outcome` on every return — -`PublishPackageDraftsResponseSchema` declares it required, and the conformance -suites treat a producer return without it as a drifted seam. diff --git a/.changeset/publish-error-headline-issues.md b/.changeset/publish-error-headline-issues.md deleted file mode 100644 index ceb7e0afca..0000000000 --- a/.changeset/publish-error-headline-issues.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': patch -'@objectstack/runtime': patch ---- - -Publish refusals no longer render each validation finding twice (#10524) — declare-then-trim. - -**Declared (spec, additive):** `PublishPackageDraftsResponseSchema.failed[]` elements now -declare `issues[]` (the `RuntimeAuthoringIssueSchema` findings the producer has emitted -since #8333 but no declared parse could carry), and `seedApplied` declares `issues[]` -(`{ path, message, code? }`, the seed-body schema refusal's findings). Typed consumers — -the SDK's `PublishPackageDraftsResponse`, any `parse` through the schema — can now read -the structured findings back instead of having them silently stripped. - -**Trimmed (producers):** the #4463 author-time gate's 422 message and -`seedRequestValidationError`'s message are one-sentence headlines — total count plus up to -three `path [rule]` / `path [zod-code]` locators — instead of restating the issue prose -that `issues[]` carries on the same response. Consumers that render only `error` (CLI, -logs) keep what failed, where, under which rule, and how many; consumers that render both -channels stop repeating themselves. The old `(+N more)` tail is subsumed by the leading -count. Both catches that surface the seed refusal onto `seedApplied` now thread the -structured findings beside the headline. - -Error `code`/`status` vocabularies, `advisories`, the DESTRUCTIVE_CHANGE (409) message, -and `saveMetaItem`'s spec-validation 422 message are unchanged. Messages are not contract -(the machine-readable channels are `code` and `issues[]`), so this is not a breaking -change and registers no migration. diff --git a/.changeset/publish-failure-reads-error-message.md b/.changeset/publish-failure-reads-error-message.md deleted file mode 100644 index fae411509c..0000000000 --- a/.changeset/publish-failure-reads-error-message.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os package publish` now prints the reason a publish was refused instead of the -literal `[object Object]` (#10763). - -Both request helpers in `package/publish.ts` built their failure text the same -way: - -```ts -const errMsg = parsed?.error ?? response.statusText ?? `HTTP ${response.status}`; -return { ok: false, status: response.status, body: parsed, error: String(errMsg) }; -``` - -In the declared envelope `error` is an **object** — `{ code, message }` — so -`String(errMsg)` stringified the object. The `??` chain never reached -`statusText`, because an object is not nullish; there was no useful fallback to -reach. Every failed publish printed the same seven characters no matter what the -control plane had refused, at all three call sites: package registration, -version publish, and the icon upload. - -Both sites now read through a new `readErrorMessage` in -`packages/cli/src/utils/response-envelope.ts`, which returns the declared -envelope's `error.message`, degrades to `error.code` when a refusal carries no -message, and falls back to a non-blank `statusText` and then the status line. A -blank `statusText` counts as absent — HTTP/2 carries no reason phrase, and the -old `??` chain kept the empty string and printed nothing after the status code. - -The reader also accepts the flat `error: ''` shape, deliberately and -temporarily. That is a **measured** property of these routes rather than an -assumption: `/api/v1/cloud/**` is served by the sibling `cloud` repo, and the -closest first-hand reader of that same `service-cloud` family — objectui's -`readApiError` — records that it answers failures in both shapes while cloud#944 -converts it. A strict envelope-only read (the `readEnvelope` landed by #10675 -for the in-repo `/api/v1/datasources/**` routes) would have replaced today's -live flat dialect with a different unreadable failure, so it is not reused here; -the reasoning, and the condition under which the flat branch is deleted, are -recorded on the function. - -No request the CLI sends changes, and the server sends exactly what it sent -before — this is only how a failure is read and shown. diff --git a/.changeset/publish-meta-item-declares-package-id.md b/.changeset/publish-meta-item-declares-package-id.md deleted file mode 100644 index 9de2a09d93..0000000000 --- a/.changeset/publish-meta-item-declares-package-id.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/rest": patch ---- - -Declare `packageId` on `publishMetaItem`'s request type, and correct three -in-tree comments that claimed the per-item publish door "names no package" -(#10350). - -`publishMetaItem` declared `type / name / organizationId / actor / message / -_skipSeedApply` and no `packageId`, while since #10063 `POST -/meta/:type/:name/publish?package=PKG_ID` states one on every HTTP-driven -promotion that names a package — which is Studio's designer save-then-publish -loop. - -**No runtime behaviour changes, and nothing was broken at runtime.** The value -already flowed end to end: `publishMetaItem` forwards its whole request object, -the one transform in between (`canonicalizeMetaRequestType`) is a spread that -drops no key, and `promoteDraftForPublish` already declared -`packageId?: string | null` and threaded it into both the #9612 gate closure and -`repo.promoteDraft`. What was wrong was the *declared* contract: the binding was -invisible to every typed caller, and the only caller that states one reaches the -method through a cast, so it was enforced by nothing — one destructuring -refactor away from being dropped in silence. - -`packageId` is `string | null | undefined`, and the three states are distinct: -an **absent** key keeps the historical "match any package" resolution, `null` -pins the lookup to the unbound row, and a present-and-`undefined` key coerces to -`null` downstream and makes a package-bound draft unfindable. Spread it in -conditionally; never write `packageId: maybeUndefined`. - -`environmentId` is deliberately **not** added, though it sits in the same -cast-hidden position on the REST call site. It is the multi-kernel routing key -and is out of the protocol request shape by the maintainer ruling recorded -2026-08-18 on #9741 — `resolveProtocol(environmentId)` selects the kernel before -the call, and `request.environmentId` is read nowhere in -`@objectstack/metadata-protocol`. `packages/rest` types that one transport-level -member on top of the declared shape (`TransportScopedMetaRequest`) instead. - -Three pins land in `protocol-publish-drafts-package-scope.test.ts`, on the same -two-colliding-drafts fixture the #8907 batch-door cases use, so a promote that -loses the package dimension resolves the *wrong* row rather than merely -succeeding. diff --git a/.changeset/published-readme-member-existence.md b/.changeset/published-readme-member-existence.md deleted file mode 100644 index 85f758c082..0000000000 --- a/.changeset/published-readme-member-existence.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@objectstack/plugin-security": patch -"@objectstack/service-package": patch -"@objectstack/trigger-schedule": patch -"@objectstack/trigger-record-change": patch -"@objectstack/embedder-openai": patch -"@objectstack/driver-sqlite-wasm": patch -"@objectstack/spec": patch ---- - -docs: name packages that exist in seven published documents, and gate the class (#10893) - -A published README ships inside the npm tarball, so an install instruction in one -reaches every reader of the package. Nine `@objectstack/` names across seven -published documents named a package that is in **no directory of this repo**, and -five of those sat on `import` lines inside runnable fences. - -`check:published-readme-exports` could not see any of it, by construction. It -resolves a documented import against the package's built type surface through the -workspace member map, so a specifier that is not a member has no type entry to -compare against and the gate reads no further — strict about a member that exists, -silent about one that does not. The gate now makes the member-existence claim -first: an `@objectstack/`-scoped specifier that names no workspace member is a -finding, and the run header prints the scoped population as `N/N` so a recogniser -that stops matching shows up as a denominator that fell. - -What each dead claim now says, and why: - -- **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`** - each misnamed **themselves**. Both READMEs — including their `# ` titles and - every fenced import — said `@objectstack/plugin-trigger-…`, a name that has - never been published. The exported class names (`ScheduleTriggerPlugin`, - `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all - along; only the package name was wrong, so this is a rename pinned by each - package's own `name` field. -- **`@objectstack/plugin-security`** told readers to `install - @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No - such package exists. The organization wall ships as the enterprise - `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the - `org-scoping` service this plugin probes — the name `objectstack serve` and - `objectstack doctor` both print. Asking for the wall without it is a refusal to - boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The - tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the - cloud control-plane runtime from the separate `cloud` repository and not where - the wall comes from either. -- **`@objectstack/service-package`** described packages being "delivered to - runtime kernels that load them through `@objectstack/service-marketplace`". That - package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future - work. The loading half that does exist here is - `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`. -- **`@objectstack/embedder-openai`** had a fenced example importing - `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape, - because a reader pastes it. No knowledge adapter in this repository consumes an - `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder - option, and the adapters the contract is written for are not here. The example - is now the `embed()` surface that does exist, with the gap stated rather than - papered over with a substitute package name. -- **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against - `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has - ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite - through Knex, choosing the client from its optional peers. -- **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code - generators to write `import { User } from '@objectstack/protocol'`. The package - is `@objectstack/spec`, which the same sentence names as the path being - replaced. - -Four `@objectstack/` names that are **not** in this repo are deliberately left as -they are, because prose may name a package this repo does not build and a runnable -import may not: `@objectstack/security-enterprise` (the enterprise edition, whose -install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant` -(the cloud runtime), `@objectstack/framework` (the umbrella install name), and the -two names `service-datasource`'s README recalls as its own past. diff --git a/.changeset/published-readme-relative-target-existence.md b/.changeset/published-readme-relative-target-existence.md deleted file mode 100644 index ccffbf6eff..0000000000 --- a/.changeset/published-readme-relative-target-existence.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/runtime": patch -"@objectstack/hono": patch -"@objectstack/plugin-security": patch -"@objectstack/service-package": patch ---- - -docs: repair the dead repo-relative targets in four published READMEs (#10813) - -A published README ships inside the npm tarball, so a dead relative link in one -is shipped to every reader who installs the package. Nine of them were measured -across four packages, and nothing read them: `check:published-readme-links` -checked docs-site URLs, `check:published-readme-exports` checked fenced import -lines, and the lychee lane never sees `packages/**/README.md`. - -`@objectstack/runtime` carried six dead targets. Each was traced to where the -content actually went rather than deleted: - -- `MINI_KERNEL_GUIDE.md`, `MINI_KERNEL_ARCHITECTURE.md` and - `MINI_KERNEL_IMPLEMENTATION.md` were deleted from the repo root in January as - "redundant markdown files" (d709ecce68 — 14 files, 5051 deletions, nothing - added). The kernel reference they described is the docs site now, so the - Documentation section is the same footer eight sibling READMEs already use. -- `examples/host/` was renamed to `examples/app-host`, then `apps/server`, then - `apps/objectos`, and finally split out to `objectstack-ai/cloud`. In-repo, an - HTTP server in front of the runtime is `@objectstack/plugin-hono-server` plus - the `@objectstack/hono` adapter, so the bullet points there. -- `examples/msw-react-crud/` became `examples/app-react-crud`, then - `apps/console`, and now ships as `@object-ui/console` from another repo. -- `test-mini-kernel.ts` was a root-level scratch script; this package's suite is - 179 test files under `src/`. -- The section also ended on a truncated bullet with an unterminated backtick - (`` - `packages/runtime/src/ ``), which is now a real pointer to that suite. - -The other three packages: `@objectstack/hono` and `@objectstack/service-package` -still spelled `@objectstack/driver-sql` as `../../plugins/driver-sql`, stale -since the driver moved to `packages/drivers/` (#5618). `@objectstack/plugin-security` -and `@objectstack/service-package` linked three packages that are in no directory -of this repo (`plugin-org-scoping`, `service-tenant`, `service-marketplace`); -those links are dropped and the names kept as code spans, which is the spelling -those same files already use for a package they cannot point at in-tree. Whether -those three packages exist at all is a separate question, filed separately. diff --git a/.changeset/readme-count-it-yourself.md b/.changeset/readme-count-it-yourself.md deleted file mode 100644 index 740c3c0395..0000000000 --- a/.changeset/readme-count-it-yourself.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs: make the root README's example-app size claim and package-directory count agree with what they describe (#10320) - -Two numbers on the front door of the repo restated a fact that lived somewhere else, and had already drifted from it: - -- The `examples/app-crm` size blurb hard-coded **31 files, 1,792 lines, roughly 16k tokens**, then handed the reader the exact `find examples/app-crm/src -name '*.ts' -not -name '*.test.ts' | xargs cat | wc -l` command and invited them to verify it under "Count it yourself:". Running that command against `origin/main` returns **1,930 lines**, not 1,792 — a reader who took the invitation got a different number than the one two lines above it. -- The Package Directory's `
` summary claimed **72 published packages**; the table beneath it actually lists **45** rows (a curated set of highlights, not every package — three of those rows are the example apps, whose `package.json` is `"private": true` and never published at all), while the repo's true count of non-private `package.json` files is **69**. - -Rather than re-hardcoding a fresh pair of numbers that will silently drift again at the next merge to `examples/app-crm` or the next row added to the table, both passages now name their own source of truth explicitly and defer to it instead of duplicating it: the CRM blurb states its numbers "as of this writing" and says outright that the command below it, not the sentence, is authoritative; the package-directory summary's count is now the table's actual row count and says the table itself is the source of truth for that count. diff --git a/.changeset/readme-mcp-add-auth.md b/.changeset/readme-mcp-add-auth.md deleted file mode 100644 index 1192d70370..0000000000 --- a/.changeset/readme-mcp-add-auth.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs: note that the root README's `claude mcp add` one-liner needs a follow-up sign-in step (#10319) - -The "Your app is AI-operable, for free" section's copy-paste command -(`claude mcp add --transport http my-app http://localhost:3000/api/v1/mcp`) -registers the server correctly, but running it alone and then calling a tool -401s — measured live, at head, against a freshly booted `examples/app-crm`: -unauthenticated `initialize` returns -`401 {"code":"UNAUTHENTICATED","message":"Unauthorized: a valid OAuth access -token or API key is required"}`, exactly as the finding this closes reported. -The README gave no hint that a sign-in step follows the command. - -The linked docs page, [Connect an MCP -Client](https://objectstack.ai/docs/ai/connect-mcp), already carries the step -in full (interactive OAuth browser login, plus a headless API-key flow for -CI/containers) — confirmed by reading it and by reproducing both paths live: -the same unauthenticated call 401s with a `WWW-Authenticate` header -advertising OAuth metadata, and minting a key via `POST /api/v1/keys` with a -session cookie and sending it back as `x-api-key` returns `200` with a valid -`initialize` response. So the fix is a one-sentence pointer in the README, not -a rewrite of the docs page it already correctly delegates to. diff --git a/.changeset/record-highlights-icon-retired.md b/.changeset/record-highlights-icon-retired.md deleted file mode 100644 index 0a10fc0730..0000000000 --- a/.changeset/record-highlights-icon-retired.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `record:highlights` highlight-field `icon` — advertised on six surfaces, drawn by nothing (#10054, 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). - -`icon` on the object arm of `RecordHighlightsField` (`fields: [{ name, label?, -icon?, … }]` on a `record:highlights` component) was a real authoring surface -advertised on six author-facing surfaces — the union's own describe, the -`fields` describe, the lint entry-shape prose, the reference docs, and -objectui's input description — with ZERO read points, measured at the -2026-08-20 census in every direction: objectui's renderer normalizes the -authored object and carries `icon: f?.icon` into `HeaderHighlight`, whose chip -has no icon slot (its only `icon` occurrence is a button `size="icon"`); the -key is structurally unable to travel `useRegisterHighlightFields`, which -registers `names: string[]`; the Studio block designer publishes the field -list as a `string[]` input, so the key was never designer-publishable; and -every in-tree `record:highlights` producer authors bare string arrays. So an -authored `icon` parsed clean and was drawn by nothing — the #8691 -reference-rail-`icon` shape, on the highlight chip. - -**What is refused:** `icon` on an object-form highlight field. The arm is -`strictObject`, so the key is deleted from the shape and the unknown-key -rejection carries the retirement prescription via the arm's `guidance` entry -(fully-qualified key, why it was inert, the no-replacement guidance, the -`os migrate meta` pointer) — surfaced through the zod-4 union collapse by -`packages/lint/src/zod-issue-format.ts`'s arm unpacking. - -**What stays accepted:** bare-string entries and `{name, label?, type?, -readonly?}` objects parse byte-identically. `readonly` behaviour is untouched -— it is the arm's one enforced key (#5176, HeaderHighlight's inline-edit -gate). There is no replacement for `icon`: the highlight chip renders label -and value only. - -The retirement kit: - -- strict deletion + `guidance` prescription at the schema - (`packages/spec/src/ui/component.zod.ts`); the two advertising describes - (the union's and `RecordHighlightsProps.fields`') no longer spell the key -- ADR-0087 registration: retired-key entry `ui/RecordHighlightsField:icon` and - the D2 conversion `record-highlights-field-icon-removed` (protocol 18), - wired into the step-18 chain — `os migrate meta --from 17` strips the key - from the object entries of every `record:highlights` `fields[]` (pure - lossless delete; it never had an effect to lose) -- pin tests (`component.test.ts` — the old parse-survival pin respells to the - surviving surface; a refusal pin asserts the named `unrecognized_keys` - rejection and its prescription through the union collapse) -- generated baselines/docs follow the schema (spec-changes, upgrade guide, - reference docs); `packages/lint`'s entry-shape prose corrected -- objectui's plugin-detail input-description advertisement is cross-repo and - follows on its own card - -## FROM → TO - -```ts -// before — parsed green; the renderer normalized `icon` into a chip with no -// icon slot, so the strip rendered identically with or without it -{ - type: 'record:highlights', - properties: { - fields: ['status', { name: 'budget', label: 'Budget', icon: 'dollar-sign' }], - }, -} - -// after — delete the key; nothing replaces it (the chip renders label and -// value only) -{ - type: 'record:highlights', - properties: { - fields: ['status', { name: 'budget', label: 'Budget' }], - }, -} -``` diff --git a/.changeset/registry-shortname-index.md b/.changeset/registry-shortname-index.md deleted file mode 100644 index 4ed2d96918..0000000000 --- a/.changeset/registry-shortname-index.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -Index short name → FQN in `SchemaRegistry` so name lookups stop scanning the -whole registry (#10945). - -`SchemaRegistry.resolveObjectKey` answered the short-name direction by walking -**every** key of `objectContributors` and calling `parseFQN` on each. It is -reached from seven call sites — `getObject` among them — so a kernel boot that -registers N objects and resolves O(N) names did O(N²) string work, with -`parseFQN` the largest non-database entry in the CPU profile. - -The consequence was a silence rather than a failure: boot got slower purely by -an environment accumulating metadata, and once bootstrap outgrew the request -waiter every request answered `kernel_warming` and the environment could never -be opened — no error anywhere. - -`resolveObjectKey` now reads a short-name → FQN index `Map` maintained beside -`objectContributors`. Both containers are mutated only through two private -choke points, so they cannot drift apart: a caller cannot add a contributor -list and forget the index half. - -Resolution is deliberately unchanged. The index array holds the same members in -the same order as the list the scan built, so an ambiguous short name still -resolves to the **first** key registered under it, the ambiguity warning still -names every match, and the legacy `__` fallback still works. That -equivalence is pinned against the old loop itself, over every registration -order, rather than against a hand-written expectation. - -Measured on the same container, resolving one name per registered object: - -| registry | 32,000 lookups over 4,000 objects | scaling ratio at 8× input | -|---|---|---| -| full-registry scan | 4,888 ms | 62.5× (quadratic) | -| short-name index | 2.3 ms | 5.7× | diff --git a/.changeset/rest-published-501-message.md b/.changeset/rest-published-501-message.md deleted file mode 100644 index 19608b3220..0000000000 --- a/.changeset/rest-published-501-message.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -Reworded the `501 NOT_IMPLEMENTED` message on `GET /meta/:type/:name/published` (and its -compound-name arity) to state its true post-#8278 condition. Since #8278 put the -runtime-published overlay consult ahead of this arm, the 501 no longer means "this kernel -cannot answer `/published`" — it means "nothing is runtime-published for this item, and -this kernel has no code/package store" (i.e. `metadata.getPublished()` is unavailable). -The old message ("metadata.getPublished() is not available in this kernel") overstated -that condition. Status code, `error.code`, and routing order are unchanged — only the -message text changed. diff --git a/.changeset/retire-agent-generator.md b/.changeset/retire-agent-generator.md deleted file mode 100644 index b5345a8b3c..0000000000 --- a/.changeset/retire-agent-generator.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): **BREAKING** — the `agent` generator is retired, and `os g agent` now says why and points at skills (ADR-0063 §2, #10359) - -**⛔ If a script, a Makefile or a CI step in your project runs `os g agent`, it -will now exit 1.** That is the intended outcome and the one way this change can -interrupt you: the command is gone, deliberately, and the failure is how you -find out. Everything it used to produce was already being discarded — read on. - -`minor`, not `major`: during the launch window this stack ships breaking changes -as `minor` (pre-1.0 semantics under lockstep versioning — see -`scripts/check-changeset-no-major.mjs`). - -**What the command actually did.** `os g agent ` scaffolded a typed -`AI.Agent` into `src/agents/`. Per ADR-0063 §2 (which reversed ADR-0040 §3) the -kernel ships exactly **two** agents — `ask` and `build` — bound by surface and -never picked from a roster, and the runtime catalog **filters out every -non-platform agent record**. So the scaffolded file parsed, passed -`os validate`, published without complaint, and then never appeared anywhere. -No error at any step. An author who followed the documented example got a file, -a green validate, a successful publish, and nothing to show for it. - -**Why the roster entry was not simply deleted.** A deleted type falls through to -`Unknown type: agent` plus a list of what is left, which tells the author their -spelling is not on the list and invites them to hunt for the right spelling of -something that no longer exists — the same silence, one step earlier. `agent` is -now a **retirement ledger entry** instead, and the refusal carries both halves: -the decision that withdrew the surface, and the surface to author in its place. -What you see: - -``` - ✗ `os g agent` was retired — agents are platform-internal (ADR-0063 §2). - - The kernel ships exactly two agents, `ask` and `build`, bound by surface. - An agent you author still parses and still publishes — and the runtime - catalog then filters it out, so it never appears and nothing tells you. - This command scaffolded exactly that file, so it is retired, not repaired. - - Author a SKILL instead. Skills (plus tools / MCP) are the third-party - extension primitive ADR-0063 names — the live surface this one was not. - - Scaffold one — the file lands where the loader looks for it: - - os g skill -> src/skills/.skill.ts - - It writes a `defineSkill` template with `surface` and `tools` filled in - and explained, ready to edit. - - Docs: https://objectstack.ai/docs/ai/agents -``` - -**The call is not mechanically rewritable.** A skill is a different artifact -with a different schema, not a renamed agent, so delete the `os g agent` call -rather than renaming it — then run `os g skill` and fill the template in. (This -message originally said no scaffolder existed; `os g skill` shipped in the same -release, so the text above is what the command prints today.) - -`agent` leaves the generator roster, which is `object`, `view`, `action`, -`flow`, `dashboard`, `app` — plus `skill`, added in this same release. The docs -that advertised the retired one — the `os g agent support` -example, the `agent` / `src/agents/` row of the Available types table, and -`os g agent sales-assistant` in the Typical Workflow block — are gone from -`content/docs/deployment/cli.mdx`, which carries the retirement note instead; -`packages/cli/README.md`'s type roster follows. The quick-start project-layout -map, which listed `src/agents` as the directory an app author writes AI metadata -into, now names `src/skills`. - - diff --git a/.changeset/retire-capabilities-hook-directive.md b/.changeset/retire-capabilities-hook-directive.md deleted file mode 100644 index d8a934258d..0000000000 --- a/.changeset/retire-capabilities-hook-directive.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -**BREAKING** Retire the `@capabilities` hook-body directive comment (#10917). - -`os build` no longer reads a `@capabilities` line out of a handler body, and the -docs no longer teach one. A body's capabilities are either inferred from its -source, or declared as data in `body.capabilities` on the hook or action — the -route that is measured to survive the build, and now the only way to name a token -the code itself does not reveal. - -**Nothing an author wrote has to change.** The directive was read off the -handler's stringified source, and `loadConfig` runs every config through -`bundle-require` and esbuild, which strips `//` line comments before the handler -is ever a runtime function. Measured on all four ordinary authoring shapes — -`objectstack.config.ts`, `.js`, `.mjs`, and a handler imported from a local -module — it reached the extractor from none of them: the build exited 0, printed -nothing, and shipped the inferred capabilities alone. A config that still carries -the comment builds to the same artifact before and after this release, so -deleting it is optional and changes no output. What is gone is the wrong -convention it taught, silently, to everyone who copied it out of the docs — a -handler asking for more than inference derived was refused by the sandbox at -runtime, far from the cause. - -Ruled under ADR-0049 enforce-or-remove: a capability declaration nothing parses is -a false promise, and this one could not even be typed wrongly-but-visibly, because -every authoring path deleted it before the extractor looked. - -The retirement kit: the override branch in `extract-hook-body.ts` and the two unit -tests that pinned it are gone; the extractor header and -`content/docs/automation/hook-bodies.mdx` record the removal instead of the -spelling; the `os build`-level test keeps pinning both halves — the comment -contributing nothing, and `body.capabilities` surviving — and a unit pin standing -on the one shape where the override ever fired now asserts it grants nothing. - - diff --git a/.changeset/runtime-expected-read-refusal-noise.md b/.changeset/runtime-expected-read-refusal-noise.md deleted file mode 100644 index 478cfb4590..0000000000 --- a/.changeset/runtime-expected-read-refusal-noise.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -**Tests (log hygiene):** the sixteen remaining passing `@objectstack/runtime` -fixtures that printed expected `refused a read on` failures into the shared -shard log now **withhold and assert** that noise instead of emitting it -(#10629). No runtime behaviour changes and no test was skipped, loosened or -removed — the same 78 tests pass, and 268 lines of expected-failure output -(134 `[sql-driver] DATABASE_ERROR — the backend refused a read on ''` -envelopes plus their 134 matching `ERROR Find operation failed` engine frames) -leave the `Test Core` log. - -Why this is worth a release note at all: turbo interleaves package logs without -attribution, so an ERROR-shaped line from a **green** test is indistinguishable -from a real failure in a shard log. Lines of exactly this shape were once -lifted verbatim into a p1 flake signature (#10293) and sent a whole dispatch -cycle at the wrong mechanism. Expected-failure noise from a passing test is a -diagnosis tax on every future red shard. - -Each fixture provokes a **fail-soft probe** — a read the runtime issues to find -out whether something is installed, and whose missing-table answer it swallows -by design: `resolveUserAuthzGrants`' six `sys_*` `tryFind`s, -`ObjectQL.probeInstallOrganizations`, `SeedLoaderService.resolveSoleOrganizationId`, -the lifecycle governance snapshot, `runBuildProbes`' view read, and the boot -metadata load. Every one of them was judged expected rather than diagnostic; -none was silenced on the strength of "it looks like noise". - -⛔ This is not a mute. PR #10630 ruled the shape for this class on two files — -withhold only a line that names an expected table **and** carries that same -table's `no such table` reason, count what was withheld, and assert the counts — -and this applies that shape verbatim through one shared, test-only module, -`packages/runtime/src/expected-read-refusal-noise.ts`. A fixture that stopped -provoking its probe, or whose table started resolving, now goes **red** instead -of merely going quiet; the engine frame is withheld only when it sits directly -above a driver refusal the capture already recognised, so an identically-shaped -fault from any other cause still reaches the log with both halves intact. diff --git a/.changeset/runtime-readme-unread-call-site-audit.md b/.changeset/runtime-readme-unread-call-site-audit.md deleted file mode 100644 index 73e8b53457..0000000000 --- a/.changeset/runtime-readme-unread-call-site-audit.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -Repair six false API claims in the published `@objectstack/runtime` README -(#10368). The README is in the package's `files` array, so it is the page npm -renders — a reader following it wrote code that could not compile. - -Found by hand-adjudicating every call site in that document that -`check:published-readme-exports` reports under `NOT read:` — receivers built -from free variables, parameters and globals, which neither the gate nor a human -reader can type by looking. 30 sites on 17 receivers were read; the repairs below -are what came out. - -- `engine.update('user', user.id, { name: 'Jane' })` → `engine.update('user', - { id: user.id, name: 'Jane' })`. `IDataEngine.update` is - `(objectName, data, options?)`; there is no `id` parameter. A by-id update is - identified by a truthy scalar `data.id` (or `options.where.id`) — the rule - `resolveEngineUpdateDispatch` in `@objectstack/metadata-core` defines. -- `engine.delete('user', user.id)` → `engine.delete('user', { where: { id: user.id } })`. - `IDataEngine.delete` is `(objectName, options?)`; the id belongs in - `options.where.id` (`assertEngineDeleteDispatch`). Passing it positionally - landed the id in the options bag. -- The **Interface Methods** bullet list restated both wrong signatures, so it is - corrected in the same edit — a repaired example beside a bullet list that still - contradicts it is not a repair. -- `reply.code(429).send({ retryAfterMs })` in the rate-limiting recipe → - `res.status(429).json({ retryAfterMs })`. `reply.code()` is Fastify; this - package's HTTP contract is `IHttpResponse`, which spells the step - `status(code)` and whose `send` takes `string | Uint8Array | ArrayBuffer`, not - an object. The `docs/HARDENING.md` recipe the same section links to already - answers 429 through the framework's own JSON responder. -- `status: res.statusCode` in the middleware example → dropped. - `IHttpResponse` has no `statusCode`; a response's status is observed through - `IHttpServer.afterResponse` (`HttpResponseObservation.status`), not read off - the response inside middleware. -- The `PluginContext` interface block declared `logger: Console` and - `getKernel?(): any`. The real contract (`@objectstack/core`) is - `logger: Logger` and a required `getKernel(): ObjectKernel`. - -Documentation only — no runtime, type or export change. diff --git a/.changeset/scaffold-better-auth-utils-peer-skew.md b/.changeset/scaffold-better-auth-utils-peer-skew.md deleted file mode 100644 index 5c9db642fd..0000000000 --- a/.changeset/scaffold-better-auth-utils-peer-skew.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/cli": patch -"create-objectstack": patch ---- - -fix(cli): declare the four `@better-auth/utils` peer skews a freshly scaffolded project reports (#10931) - -Both scaffold paths emit a `peerDependencyRules.allowedVersions` block whose -stated purpose is that a brand-new project's first `pnpm install` does not open -with a peer-skew report. It declared two skews and left four showing: - -``` -├─┬ @better-auth/core 1.7.1 -│ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 - ├─┬ @better-auth/scim 1.7.0-rc.1 - │ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 - ├─┬ @better-auth/oauth-provider 1.7.1 - │ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 - └─┬ @better-auth/sso 1.7.1 - └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 -``` - -`@better-auth/core`, `/oauth-provider`, `/scim` and `/sso` each peer an **exact** -`@better-auth/utils@0.4.2`. The 0.5.0 they are handed comes from -`better-call@1.4.0` — better-auth's own HTTP layer — which *depends* on -`^0.5.0`; `@objectstack/plugin-auth` names the four as direct dependencies -without naming utils, so pnpm satisfies their peer from better-call's copy -instead of better-auth's own exact 0.4.2 dependency. - -**Measured compatible before widening, not assumed.** Those four import three -symbols in total: `base64`/`base64Url` (`@better-auth/utils/base64`), -`createHash` (`/hash`) and, in core only, `createRandomStringGenerator` -(`/random`). 0.5.0 declares all three with identical signatures; `/random` is -unchanged apart from formatting, `/base64` swaps `new Uint8Array(data)` for a -helper that *is* `new Uint8Array(data)` on non-strings, and `/hash` only widens -its input coercion for views not backed by a plain `ArrayBuffer`. Run against -the input shapes those call sites actually pass, the two versions agree on every -value; run end to end — better-auth with the `sso`, `oauth-provider` and `scim` -plugins — a tree where the four resolve 0.5.0 and one where they resolve 0.4.2 -produce the same transcript: sign-up, sign-in, session, both OAuth metadata -documents, the RFC 7636 PKCE challenge, and the SCIM and SSO endpoint outcomes. - -A resolution change was measured too, and rejected: pinning utils back to 0.4.2 -clears the four lines only by dragging `better-call@1.4.0` off its own declared -`^0.5.0` — manufacturing one real range violation to silence four benign ones. - -Four scoped entries, one per declaring package, matching the block's convention -that each rule widens exactly one declaration. `allowedVersions` suppresses the -report only: the lockfile a scaffold resolves is byte-identical with and without -the block. The version is spelled `0.5.0` exactly rather than `0.5`, so a future -`0.6.0` reports again instead of inheriting this finding. - -Both scaffold paths — `objectstack init` (rendered by the CLI) and -`npx create-objectstack` (a copied template file) — are changed together, and -`packages/cli/test/scaffold-workspace-consistency.test.ts` gains a limb that -compares the peer maps the two produce, so they cannot drift apart again. diff --git a/.changeset/scaffold-explicit-empty-workspace.md b/.changeset/scaffold-explicit-empty-workspace.md deleted file mode 100644 index a4a83c34b4..0000000000 --- a/.changeset/scaffold-explicit-empty-workspace.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/cli": patch -"create-objectstack": patch ---- - -Scaffolded projects declare an explicit empty `packages: []` in their -`pnpm-workspace.yaml` (#10933). Both scaffold paths render it — -`renderPnpmWorkspaceYaml` in `objectstack init`, and the bundled `blank` -template `npx create-objectstack` copies. - -The file was deliberately keyless so it would act purely as a settings file. -That intent is now written down rather than inferred from a missing key, and -writing it down is what fixes a first-command failure: pnpm 9.x and 10.0–10.4 -parse `pnpm-workspace.yaml` **before** they read `engines`, so they refused a -brand-new project outright with - -``` - ERROR packages field missing or empty -``` - -naming a file the user never wrote and giving no hint that the cause is their -pnpm version — and no `engines.pnpm` floor could reach them, because they never -got as far as the engines check. Measured, one clean install per pnpm version, -each with its own store: - -| pnpm | before | after | -|---|---|---| -| 9.15.9, 10.0.0, 10.4.0 | `ERROR packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming `>=10.15` | -| 10.5.0–10.14.0 | `ERR_PNPM_UNSUPPORTED_ENGINE` | unchanged | -| 10.15.0, 10.34.5, 11.22.0 | installs | installs, byte-identical `pnpm-lock.yaml` | - -So every unsupported pnpm now reports the same actionable cause, and supported -pnpm is unaffected: the empty key was measured equivalent to omission on -10.15.0, 10.34.5 and 11.22.0 — identical lockfile bytes, identical -`node_modules/.modules.yaml` once the run-local `prunedAt`/`storeDir` fields are -dropped, identical `pnpm ls -r --depth -1`, and an identical second-install -"Already up to date". - -The declaration is an **empty** list on purpose. `packages: ['.']` satisfies the -same parsers but declares the project root a workspace *member* — a monorepo -root — which a single-package scaffold is not, and which reads to the next -author (human or AI) as an invitation to add member packages to an app. - -`engines.pnpm` is unchanged at `>=10.15`. diff --git a/.changeset/scaffold-next-steps-package-manager.md b/.changeset/scaffold-next-steps-package-manager.md deleted file mode 100644 index 59723d8c2d..0000000000 --- a/.changeset/scaffold-next-steps-package-manager.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"create-objectstack": patch ---- - -Fix `create-objectstack`'s closing "Next steps" and install-failure remedy -hardcoding `npm` regardless of which package manager the run actually used -(#10322). `detectPackageManager()` already prefers `pnpm` and falls back to -`npm` only when `pnpm` is unreachable — confirmed still true at HEAD, and -confirmed empirically: a real run with `pnpm` on `PATH` installs with `pnpm` -(`pnpm-lock.yaml`, "Done in … using pnpm vX") and then told the newcomer to -run `npm run dev` / `npm run validate` next, a package manager the run never -touched. The detected package manager is now read once, up front, and reused -consistently for the install command, the install-failure remedy, and every -line of "Next steps" — so the printed guidance always names the tool the run -actually used, in both the `pnpm` and the `npm`-fallback case. - -Also names `validate` — the step the generated `AGENTS.md` calls -unskippable — in the "Getting started" section of the generated `blank` -template's README, not only in its later "Verify your changes" section, so a -newcomer reading top-to-bottom sees it at first touch. - -No install behaviour changes: the scaffolder still installs by default and -still supports `--skip-install`; this is a messaging-only fix. diff --git a/.changeset/scaffold-pnpm-floor.md b/.changeset/scaffold-pnpm-floor.md deleted file mode 100644 index e46ed9f53f..0000000000 --- a/.changeset/scaffold-pnpm-floor.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/cli": patch -"create-objectstack": patch ---- - -Declare a pnpm floor (`engines.pnpm: ">=10.15"`) in the `package.json` both -scaffolders write, so an unsupported pnpm reports its own version instead of an -error about a file the user never wrote. - -Both scaffold paths emit a settings-only `pnpm-workspace.yaml` with no -`packages:` key. Early pnpm 10 refuses that file outright — `pnpm install` exits -1 with `ERROR packages field missing or empty` before resolving a single -dependency, so a brand-new project could not be installed at all. Measured on -the rendered shape, one clean install per pnpm version, each with its own store: - -| pnpm | before | after | -| --- | --- | --- | -| 10.0.0 – 10.4.0 | `packages field missing or empty` | unchanged — see below | -| 10.5.0 – 10.14.0 | `packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming the expected range | -| >= 10.15.0 | installs | installs | - -The floor is a diagnosis, not a repair: pnpm 10.0.0–10.4.0 parse -`pnpm-workspace.yaml` *before* they read `engines`, so they still print the raw -workspace error. Closing that remaining sliver requires deciding what a -single-package scaffold should declare under `packages:`, which is tracked -separately and deliberately not decided here. - -`engines.pnpm` rather than a `packageManager` stamp: npm, yarn and bun ignore -`engines.pnpm` entirely, so the scaffold keeps working for all four package -managers `objectstack init` hands off to. A `packageManager: "pnpm@x.y.z"` stamp -would declare the project pnpm-only (corepack-driven yarn refuses to run in such -a project) and pin one exact version that goes stale on every pnpm release — and -it buys nothing on 10.0–10.4, which reach the workspace error before reading -that field either. - -No existing project is affected; this only changes what a newly scaffolded -`package.json` contains. diff --git a/.changeset/scaffold-pnpm-workspace-boundary-consistency.md b/.changeset/scaffold-pnpm-workspace-boundary-consistency.md deleted file mode 100644 index 18d27ba963..0000000000 --- a/.changeset/scaffold-pnpm-workspace-boundary-consistency.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"create-objectstack": patch ---- - -Correct the pnpm boundary the blank template states for `allowBuilds`, and gate -the two scaffold paths against each other (#10498, #10499). - -`packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml` is copied -verbatim into every scaffolded project, so its header comment is prose that -ships **inside the user's own repository**. It said `allowBuilds` needs -pnpm >= 10.31 and that `onlyBuiltDependencies` covers pnpm 10.0–10.30. Measured -on a probe depending on `esbuild@0.28.2`, with a workspace file carrying only -`allowBuilds`, one clean install per pnpm version and each with its own -`--store-dir` (isolation matters — pnpm's side-effects cache will otherwise hand -a later run a build an earlier run performed, and it reads as "the key worked"): - -| pnpm | `allowBuilds` alone | -|:--|:--| -| 10.15.0 – 10.25.0 | ignored — build not run | -| **10.26.0** | **honoured — build ran** | -| 10.28.0 – 10.33.0 | honoured — build ran | - -So the floor is 10.26.0 and the older-key band is 10.0–10.25. A user on pnpm -10.28 was being told by the file in front of them that their pnpm cannot read -the key it is in fact reading. Both load-bearing claims in that comment were -correct and are unchanged: both keys are needed, and pnpm 11 reads only -`allowBuilds`. No setting, no assertion and no install behaviour changes — the -rendered `onlyBuiltDependencies` / `allowBuilds` values are byte-identical. - -The reason it was wrong for so long is the second half of this change. -`objectstack init` renders the same file from `renderPnpmWorkspaceYaml()` in -`packages/cli`, it was corrected to the measured numbers separately, and each -package's ratchets are package-local — so neither could ever fail for the other -file's regression, and the two scaffold paths shipped contradictory prose about -the same rule with every gate green. `packages/cli/test/scaffold-workspace-consistency.test.ts` -now compares the two **rendered outputs**: the packages each key actually grants -a build to, and the pnpm versions each file actually names for each key. It was -confirmed failing against the live divergence before this correction landed. - -Bumped `patch` rather than left out: the corrected text is user-visible — it is -delivered into every new project — while nothing executable moves. diff --git a/.changeset/scaffold-summary-names-every-written-path.md b/.changeset/scaffold-summary-names-every-written-path.md deleted file mode 100644 index 46b180366d..0000000000 --- a/.changeset/scaffold-summary-names-every-written-path.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"create-objectstack": minor ---- - -`create-objectstack` now closes with a "Created files" summary derived from a -walk of the finished project directory, so it names everything the run wrote — -including the files written after the template copy (#10323). - -The old summary was the template copy's own list, printed before -` install` and before `npx skills add`. Measured against published -`create-objectstack@17.1.0` (`create-objectstack demo-app`, then a full walk of -the result): 12 entries printed, 18,045 paths on disk, **18,033 of them -unreachable from the summary** — `AGENTS.md`, `.github/copilot-instructions.md`, -`pnpm-lock.yaml`, `skills-lock.json`, `node_modules/`, and two ~968 KB trees of -agent instructions at `.agents/skills/` and `agent/skills/`. - -That mattered because the same run ends with the `skills` CLI printing *"Review -skills before use; they run with full agent permissions."* Advice to review -files the run never named, at paths it never showed, is advice a newcomer -cannot act on — the wrong failure direction for a security-flavoured warning. - -The list could not have been correct where it stood: two of the three write -phases belong to other processes, and the `skills` installer's destination set -moves with **its** releases, not ours. Reading the directory afterwards makes -the summary self-correcting instead. Large directories collapse to one line -carrying their path, entry count and size, so the bulk stays reviewable without -18,000 lines of output, and the paths the skills installer created are marked -`⚠ skills` with the permissions warning tied to them. - -Same run, after the change: 20 entries printed, **0 written paths unreachable**. diff --git a/.changeset/schema-free-meta-spelling.md b/.changeset/schema-free-meta-spelling.md deleted file mode 100644 index 742b44a503..0000000000 --- a/.changeset/schema-free-meta-spelling.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Schema-free `/meta` spelling entry, and the package becomes tree-shakeable (#10096, #10031). - -- New fine-grained export `@objectstack/spec/meta-spelling`: the `/meta/:type` - URL-spelling contract — `META_URL_TO_SINGULAR`, `canonicalMetaUrlType`, - `metaUrlSpellingRefusal`, `unrecognisedMetaTypeRefusal` — importable for a few - hundred bytes instead of the schema graph the same symbols cost through - `/shared` (measured +246.9 KB minified / +69.7 KB gzipped marginal on a graph - already carrying `/ui` + `/kernel`). `/shared` keeps all four symbols - (re-exported from the one declaration); nothing moves or breaks. -- The map is now materialized at build time (`gen:meta-url-spelling`, gated by - `check:meta-url-spelling`). The module-load `assertMetaUrlSpellingsAgree()` - moved into that gate — same assertion, build-time enforcement home. -- `package.json` declares `sideEffects: false` (module-scope evaluation purity - measured per entry), and emitted bundles carry `/* @__PURE__ */` on deferred - schema construction, so consumer bundlers can drop schemas an entry never - reaches instead of retaining a subpath's whole module graph. -- Standing principle recorded in the package docs: a browser-reachable spec - export surface must be schema-free (maintainer ruling 2026-08-20, #10096). diff --git a/.changeset/security-metadata-outage-unresolved-cause.md b/.changeset/security-metadata-outage-unresolved-cause.md deleted file mode 100644 index 5244dce750..0000000000 --- a/.changeset/security-metadata-outage-unresolved-cause.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -Report a metadata-store OUTAGE as an outage, not as an absent declaration -(#10424). When an object's security posture cannot be resolved, the refusal -now consumes the `degraded` verdict `IMetadataService.getDiagnosed` was already -computing and discarding (#5840), so a store that could not answer no longer -wears the sentence written for an object that was never declared — "Check that -the object is declared and published on this runtime" sent operators to -re-check a healthy declaration in the middle of an incident. The refusal now -names the store, says the declaration may well be fine, and the operator log -line carries a grep-able `DEGRADED` / `metadata-store OUTAGE`. - -Explanation and logging only. The deny is unchanged in every case — same -`PermissionDeniedError`, same `PERMISSION_DENIED`, same 403, still fail-closed -per #3545 — and the set of requests that are accepted or rejected does not -move: the resolving read is untouched and `getDiagnosed` is consulted as a -separate best-effort probe on the path that is already refusing. A metadata -service that does not implement the optional `getDiagnosed` reports `unknown` -and keeps the previous wording; it is never reported as an outage. diff --git a/.changeset/security-plugin-start-logger-above-bailouts.md b/.changeset/security-plugin-start-logger-above-bailouts.md deleted file mode 100644 index 6ebe38b9f3..0000000000 --- a/.changeset/security-plugin-start-logger-above-bailouts.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -`SecurityPlugin.start()` binds its report sink **above** the two bail-outs, so a -degraded boot no longer leaves the plugin permanently unable to report (#10706). - -`private logger … = {}` is an empty object from construction, and -`this.logger = ctx.logger` was its only assignment — sitting in the "capture -handles" block, **below** the two `return`s that fire when `objectql`/`metadata` -cannot be resolved, or when the engine carries no `registerMiddleware`. On -either path the field stayed `{}` for the **lifetime of the instance**. Every -report site is written `this.logger.warn?.(…)`, so an unbound sink is not a -state any caller can notice: the reports simply do not happen. The assignment -now runs immediately after the `Starting Security Plugin...` line, before either -bail-out can be taken. - -Boot behaviour is otherwise unchanged, and that is pinned rather than asserted: -both bail-outs still `return`, the middleware and the `security` service are -still **not** registered on those paths, and both bail-outs still report through -`ctx.logger` — which was always a real sink, so the bail-out itself was already -loud. What was silent was the plugin's own field afterwards. - -Scope note: this is independent of the open design call on #10556 about what the -default sink should be. Only the **placement** of the binding changes; the `= {}` -default itself is untouched, and the fix is correct under every option there. - -Reachability, measured rather than assumed: every in-repo caller of the two -public methods that report through the field (`checkAuthoredRowWrite`, -`getReadFilter`) reaches them through the registered `security` service, and -that service is registered *below* the bail-outs too — so on a bailed-out boot -there is no live consumer. The defect was latent, not live. It is still a defect -on its own terms: a sink that can never be bound after an early return is -unrepresentable as a state the code can notice. - -New pin: `start-logger-binding.test.ts`. diff --git a/.changeset/seed-tenancy-absent-seam.md b/.changeset/seed-tenancy-absent-seam.md deleted file mode 100644 index 4e727fcd40..0000000000 --- a/.changeset/seed-tenancy-absent-seam.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -`backfillSeedTenancy` no longer reports `no-split` over a driver it never queried -(#10789). The boot-time seed/API tenancy repair answered `status: 'no-split'` — -*"I looked, there is no split"* — on the memory driver, having looked at nothing, -and its own `absent` branch was unreachable there despite the branch's comment -saying *"Absent on a memory engine"*. - -`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` -and returns `null`. It neither throws nor is absent, so `resolveSeedTenancySeam`'s -shape test (`typeof d.execute === 'function'`) was satisfied and the `no-driver` -guard never fired; `normalizeRows(null)` is `[]`, which is also what a real driver -returns for a SELECT that matched nothing. Every branch of this migration reads -"no rows" as "healthy install, nothing to do", so the two collapsed into one -answer. - -The migration now separates the cases the guard used to conflate: **a seam that -cannot answer is absent, not empty.** Its READ probes are held to the standard -that actually distinguishes them — a driver that answers returns a RESULT SET — -so a probe that hands back no result set reports `absent` (with a `detail` naming -the reason) instead of being read as zero rows. Nothing names a driver: any host -with the same no-op shape is covered without an allowlist to maintain. This is the -consumer-side shape #10677 / PR #10788 landed for `os migrate duplicates`, applied -to this module's own probes. No driver package was modified. - -Three behaviours are deliberately unchanged: - -- **A real SQL install does not move.** An empty result set is an ANSWER in every - dialect spelling — a bare `[]`, `{ rows: [] }`, and the `[rows, fields]` tuple — - so a healthy install still reports `no-split`. The counter-table presence probe - is a `WHERE 1 = 0` SELECT that matches nothing by construction and runs on every - boot, which is exactly why "no rows" must stay distinct from "no answer". -- **Write statements are not held to "must answer".** An UPDATE or DELETE does not - return a result set on every dialect, so the repair's stamp and counter-merge - statements stay on the bare seam. -- **A seam that THROWS keeps its behaviour.** Throwing is a driver present and - refusing loudly, and step 1's `catch` already reported it as `absent`; only a - seam that RETURNS a non-answer was invisible. - -Boot-time behaviour is otherwise untouched: neither status logs anything, and -neither writes a ledger receipt, so a memory-driver boot logs exactly what it -logged before. What changes is the reported `status`, which is the value a caller -uses to tell "nothing to repair" from "could not look". diff --git a/.changeset/seed-tenancy-repair-receipt.md b/.changeset/seed-tenancy-repair-receipt.md deleted file mode 100644 index d7fdfd4301..0000000000 --- a/.changeset/seed-tenancy-repair-receipt.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/platform-objects": patch ---- - -fix(metadata-protocol): the seed/API tenancy repair now records each applied run in `sys_migration`, so "was my data rewritten, and when" survives the container being replaced (#9451) - -`backfillSeedTenancy` (#8686) is the platform's only row-rewriting repair that -runs unattended: it stamps `organization_id` onto business rows, merges one -autonumber counter and deletes another. It persisted nothing about having done -so. The only evidence was one `logger.info` line, and the healthy path is silent -by design — so once that line had scrolled, a silent boot and a boot that -rewrote data were indistinguishable. The operator most likely to need the record -(a fresh install, repaired during the first admin sign-up, where nobody is -reading server stdout) was the one least likely to have captured it. - -An `applied` run now writes one row into the **existing** `sys_migration` -deployment ledger — the face that already answers "has this deployment run this -data migration", and is already written at boot by the ADR-0104 attestation -path: - -```sql -SELECT last_run_at, advisory, details FROM sys_migration -WHERE id = 'seed-tenancy-backfill'; -``` - -`details` carries the run's status, the objects stamped, the organization -adopted and the identifiers that could not be adopted because they were already -minted on both sides of the split. - -Deliberately narrow: - -- **`applied` only.** `no-split` stays silent — a row per healthy boot would be - a ledger of non-events. -- **`verified_at: null`, `blocking: 0`, always.** This repair runs no self-check - and gates no consumer, so it claims no certificate; the collision count goes - to `advisory`, which never gates. Every reader of this ledger looks a row up - by `id`, so the new id cannot reach another migration's gate. -- **Best-effort, and loud when it fails.** A boot is never failed by - bookkeeping (2026-08-15 ruling), so a failed receipt write is reported at - `error` — naming that the rows *were* rewritten, that the repair is not - retried, and what to do — rather than rethrown. -- **No new schema, no new authoring surface, no new dependency.** The row is - written against the `@objectstack/spec/system` contract that - `metadata-protocol` already depends on. diff --git a/.changeset/serve-auth-base-url-loud.md b/.changeset/serve-auth-base-url-loud.md deleted file mode 100644 index 31b0345d3c..0000000000 --- a/.changeset/serve-auth-base-url-loud.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -**Bug fix (silent failure made loud):** `serve` now prints a boot-time diagnostic when the configured auth base URL cannot be parsed, instead of discarding the failure in an empty `catch` (#10202). - -The base URL was resolved through a `??` chain and parsed inside `try { new URL(baseUrl) } catch { /* ignore malformed baseUrl */ }`. That catch was the only place in the boot that learned the value was unusable, and it threw the knowledge away: the deployment's own origin never reached the `trustedOrigins` allow-list, boot continued normally, and the operator's first news of it was a browser-side `403 INVALID_ORIGIN` that names neither the variable nor the value. - -The shape that reaches it is ordinary env plumbing. `readEnvWithDeprecation` returns the preferred variable whenever it is `!== undefined`, so a **present-but-empty** variable resolves to `''` rather than `undefined`; `??` falls through only on `null`/`undefined`, so `OS_AUTH_URL=` on its own line in an env file (or a Helm/systemd/CI template rendering an absent key) consults neither `OS_BASE_URL` nor the `http://localhost:` default; and `new URL('')` throws. - -Measured on a real `os serve` boot with `NODE_ENV=production`, `OS_AUTH_URL=` set-but-empty and `OS_TRUSTED_ORIGINS` / `OS_ROOT_DOMAIN` / preview mode unset, probing `POST /api/v1/auth/sign-in/email` so a trusted origin answers `401 INVALID_EMAIL_OR_PASSWORD` and an untrusted one `403 INVALID_ORIGIN`: - -| Origin | `OS_AUTH_URL=` (empty) | `OS_AUTH_URL=https://app.example.com` | unset | -| --- | --- | --- | --- | -| `https://app.example.com` | 403 | **401** | 403 | -| `http://localhost:` | **401** | 403 | **401** | -| `http://tenant.localhost:` | **401** | 403 | 403 | -| `/api/v1/health`, `/api/v1/ready` | 200 | 200 | 200 | - -Two corrections to how this was expected to behave, both from that table. The allow-list does **not** come out empty: `serve` passes `trustedOrigins.length ? trustedOrigins : undefined`, and `AuthManager` substitutes a localhost wildcard trio for an absent list — so better-auth receives a non-empty list and localhost origins are trusted. Which makes set-but-empty strictly **more permissive than unset**: `http://tenant.localhost:` is trusted in the empty case and refused in the unset case, so an env template that renders an absent key to the empty string silently widens a production CSRF allow-list. - -**What changed is only what is said, never what is resolved.** The precedence chain, its order, and the `${protocol}//${host}` origin spelling are byte-for-byte identical; a set-but-empty `OS_AUTH_URL` still stops the chain exactly as before. Treating empty as unset inside the shared `readEnvWithDeprecation` would change behaviour for every caller of that helper and remains a separate, deliberate decision. The diagnostic is a warning, not a refusal to boot: a deployment running set-but-empty today keeps starting, and now says why authentication will not work. - -The resolution is exported as a seam — `resolveAuthBaseUrl()` and `formatUnusableAuthBaseUrlDiagnostic()`, alongside this file's sibling helpers — so the behaviour is reachable from tests without booting a server. diff --git a/.changeset/serve-banner-external-base-url.md b/.changeset/serve-banner-external-base-url.md deleted file mode 100644 index 5d12acb4d6..0000000000 --- a/.changeset/serve-banner-external-base-url.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -**Bug fix (wrong address printed):** the `os serve` / `os dev` ready banner now builds its API, Console and MCP links from the origin an operator can actually reach, instead of composing `http://localhost:` from the port the process happens to bind (#10646). - -Measured on the EE 4.1.0 published-image compose stack (moved from cloud#1507). The app container `expose`s `:3000` with no `ports:` mapping — unreachable from the host, and less so still under `--scale app=N` — while the published entry point is Caddy on `:80`, and compose has already resolved `OS_AUTH_URL` to `http://localhost`. The banner printed the container-internal address anyway: - -``` - ➜ API: http://localhost:3000/ - ➜ Console: http://localhost:3000/_console/ - ➜ MCP: http://localhost:3000/api/v1/mcp - connect an AI client (Claude Code, Cursor, …) · skill: http://localhost:3000/api/v1/mcp/skill -``` - -Following the Console link failed outright; after moving the deployment to a domain the banner still said `localhost:3000`; and the `MCP:` line is the address customers paste into an AI client, where a wrong absolute URL never fails loudly — it just never connects. - -**The origin is the runtime own answer, not a second one.** The banner resolves it through `resolveAuthBaseUrl` — the same function whose `baseOrigin` is pushed onto the CSRF allow-list a few hundred lines earlier in the same boot — so the banner and the origin the deployment actually trusts cannot drift apart. That chain is `OS_AUTH_URL` → legacy `BETTER_AUTH_URL` → `OS_BASE_URL` → `http://localhost:`; the legacy name sits in the middle and is easy to miss when the chain is restated from memory, which is one reason it is read rather than restated. Nothing about what the server listens on, binds to, or advertises to a client changed: the resolver reads `process.env` and the bound port, and this fix changes only printed text. - -**When no origin can be determined, the banner prints no absolute URL at all.** The chain yields nothing usable when a variable is set-but-empty (`OS_AUTH_URL=` stops the chain rather than falling through) or carries no scheme. The banner then prints the paths bare — - -``` - ➜ API: / - ➜ Console: /_console/ - ➜ MCP: /api/v1/mcp - connect an AI client (Claude Code, Cursor, …) · skill: /api/v1/mcp/skill - paths only — this deployment external base URL could not be resolved; - set OS_AUTH_URL to its public origin (e.g. https://app.example.com) -``` - -— because a missing address sends the operator to look one up, while a confident wrong one gets copied. `http://localhost:3000` was never a neutral default here; it was the wrong answer that shipped. - -The local dev loop is unchanged: with nothing set, the tail of the chain is still `http://localhost:` on the port that was actually bound (past any dev auto-shift), so `os dev` keeps its clickable Console link. - -Structurally, `ServerReadyOptions.port` is replaced by a required `externalBaseOrigin: string | null`. The banner no longer knows the port, so it cannot compose an address from one, and a caller that fails to resolve an origin is a compile error rather than a plausible-looking line of output. diff --git a/.changeset/serve-config-plugin-host-resolution.md b/.changeset/serve-config-plugin-host-resolution.md deleted file mode 100644 index b924379d32..0000000000 --- a/.changeset/serve-config-plugin-host-resolution.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os serve` now resolves a `plugins: [...]` entry the served app **declares** from -that app, instead of from the CLI (#10908). - -`plugins: [...]` in the app's own `objectstack.config.ts` is the documented way -to extend a deployment, but its string entries were loaded with a bare -`import()`, which Node ESM resolves against the CLI's realpath. An app that -wrote `plugins: ['@acme/my-plugin']` and declared `@acme/my-plugin` in its own -`package.json` could therefore only be served where that package happened to be -hoisted somewhere the CLI could see it — true in a dev checkout, absent on a -real distribution layout. Same mechanism as the cluster and organizations loads -fixed earlier. - -Only the **declared** case moves. A specifier the app does not declare still -resolves from the CLI exactly as before, and a path or `file://` URL keeps the -base it always had, so no deployment loses a plugin it is loading today. Which -plugins are *accepted* is unchanged — the declaration gate is untouched. - -One user-facing message changes: when a declared plugin cannot be loaded, the -`Failed to import plugin ''` error now carries the declaration remedy -("declare it in that app's `package.json`", or the install-problem text when the -app declares it but it is not installed) instead of a bare `Cannot find package`. diff --git a/.changeset/serve-host-importer-module-scope.md b/.changeset/serve-host-importer-module-scope.md deleted file mode 100644 index ee5ba5ea2f..0000000000 --- a/.changeset/serve-host-importer-module-scope.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os serve` now resolves **every** app-declarable optional package from the app -being served, not from the CLI, and the ordering hazard that broke it twice is -gone by construction (#10769). - -`serve.ts` reaches optional and enterprise packages through `createHostImporter`, -which anchors resolution at the host app. The helper was bound as a `const` -partway down one very long boot method, so it existed only *below* its own -binding — and a load written above that point was **not** a compile error. The -author simply wrote a bare `import()`, which resolves against the CLI's own -realpath and works fine in a dev checkout where everything is hoisted into one -`node_modules`. It breaks only in a real distribution layout, at boot, in -production. That shipped twice: - -- **cloud#1013** — the binding sat below the auth block, so the enterprise - `@objectstack/organizations` load resolved in the framework workspace, never - found the cloud-private package, and every walled-posture deployment hit the - ADR-0093 D5 fail-fast and exited 1. -- **#10645** — the binding sat below the cluster block, so on the published EE - image `OS_CLUSTER_DRIVER=redis` died at boot with `Cannot find package - '@objectstack/service-cluster'`, and compose's `service_completed_successfully` - took the whole stack down with it. - -Each was fixed by hoisting the binding, which left the class open: the next load -added above the new line reproduces it exactly, and no author has any reason to -know where that line is. `importFromHost` is now a **module-scope function -declaration**, hoisted over the whole module, so "above the definition" is no -longer a state the file can be in — every line of `serve.ts` reaches the same -host-anchored importer, in any order. - -Sweeping the file for the class then turned up one live instance: -`@objectstack/service-i18n` was loaded with a bare `import()`. `packages/cli` -does not declare it, so an app that declares its own copy could only be found by -accident of workspace hoisting — green in a dev checkout, absent on a real -install layout. It is now host-anchored like the rest. An app that does not -declare the package still falls back to the CLI's own resolution, so the quiet -"i18n not installed, use the kernel fallback" path is unchanged. - -Nothing about what `serve` binds, listens on, advertises, or *accepts* moves: -this changes only where a module resolves **from**. The `#4719` declaration gate -is untouched — a package the app has not declared is still refused rather than -picked up from a hoisted store. - -`serve-cluster-host-resolution.test.ts` is widened from the cluster pair to every -app-declarable optional load, classifying mechanically (a package is -app-declarable exactly when `packages/cli`'s own manifest does not declare it) so -a newly added optional package is covered without anyone remembering the test -exists. diff --git a/.changeset/settings-prebind-read-ordering.md b/.changeset/settings-prebind-read-ordering.md deleted file mode 100644 index 7e1bec1815..0000000000 --- a/.changeset/settings-prebind-read-ordering.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/service-sms": patch -"@objectstack/service-storage": patch -"@objectstack/service-settings": patch ---- - -Make the settings ordering contract **declared and enforced**, and make the -residual pre-bind READ audible (#10250). - -`SettingsServicePlugin` binds its data engine from a `kernel:ready` hook -registered in its `start()`. Three shipped plugins read a settings namespace -from a `kernel:ready` hook registered in *their* `start()` — `plugin-email` -(`mail`: SMTP/provider/from-address), `service-sms` (`sms`: provider -credentials and the daily cost ceiling) and `service-storage` (`storage`: -backend and credentials). Hooks fire in registration order, so a reader that -started before the settings plugin read `SettingsService`'s in-memory fallback, -which is empty at boot: the caller received the manifest **default** with -`source: 'default'` and `locked: false`, no diagnostic anywhere, while the -operator's saved row sat unread in `sys_setting`. - -Nothing constrained that order. None of the three declared any dependency on -`com.objectstack.service.settings`, so their position was pure `kernel.use()` -order. It was correct under `os serve` only because the always-on slate happens -to list `settings` first — and `serve` *prepends* an app's declared `requires`, -so an ordinary `requires: ['email']` produced email-before-settings and bypassed -that; cloud's per-tenant runtime mounts the slate from its own wiring. - -Three changes, one contract: - -- **Declared order.** Each of the three plugins now declares - `optionalDependencies: ['com.objectstack.service.settings']`. The kernel - resolves both the init and the start phase from that graph - (`resolvePluginOrder`, ADR-0116), so the bind is ordered ahead of the read - wherever the plugin is composed, in any host. Soft, not hard: a kernel with - no settings service still boots these plugins unchanged. -- **The residual is audible.** A settings read issued while a bind is - *declared but pending* now emits one operator-actionable `warn` per namespace - naming the repair. Deliberately not a refusal — an in-window read of a - setting with genuinely no persisted row must answer the manifest default, and - refusing would turn a correct startup sequence into an error. It stays silent - in every case that is not the window: after `bindEngine`, on a kernel with no - `objectql` at all (`settleWithoutEngine`), for a directly constructed - `SettingsService`, and for a read satisfied by an `OS_*` env override. -- **The slate pin now derives its boundary.** The foundational-prefix - assertion covered `slice(0, 6)` while `sms` — a settings reader — sits at - index 6, one past the end. The new pin - (`packages/cli/src/commands/serve-settings-ordering.pin.test.ts`) states the - rule instead of the count: every always-on entry that is not one of the - services others bind into at `kernel:ready` must be mounted after all of - them. An entry added tomorrow is covered wherever it lands. - -No behaviour changes for a deployment whose order was already correct. diff --git a/.changeset/settings-write-before-engine-bind.md b/.changeset/settings-write-before-engine-bind.md deleted file mode 100644 index aca2a1c876..0000000000 --- a/.changeset/settings-write-before-engine-bind.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/service-settings": patch -"@objectstack/spec": patch ---- - -**Behaviour change (tightening, boot-time only):** a settings write issued before `SettingsService`'s data engine is bound is now **refused loudly** instead of resolving successfully while nothing reaches `sys_setting` (#10159). - -`upsertRow` picks its store on `if (this.engine)`, and the engine is bound in exactly one place — `SettingsServicePlugin` registers a `kernel:ready` hook from its `start()` and calls `bindEngine` inside it. `kernel:ready` handlers run in registration order and every plugin's `init()` runs before any plugin's `start()`, so **every `kernel:ready` hook registered from an `init()` fires inside that window**. A `set()` from there landed in the in-process memory fallback, re-resolved off that same array, and handed the caller a fully resolved value; `sys_setting` received nothing, and neither audit ledger recorded anything (both sinks bind on the same `bindEngine` call). Nothing was logged at any level, because the write did not fail — it succeeded against the wrong store. - -**What an operator will now observe.** A write in that window throws `SettingsEngineNotBoundError` — code `SETTINGS_ENGINE_NOT_BOUND`, status **503** — whose message names the window, the reason, and the fix: move the write to `kernel:bootstrapped` (or later), which fires strictly after every `kernel:ready` handler has settled. Previously that same call returned a resolved value and the setting was silently absent after restart. - -**Nothing outside the window changes.** The refusal is armed only by the new opt-in `SettingsServiceOptions.engineBindPending`, which `SettingsServicePlugin` sets in `init()` and clears on both branches of its `kernel:ready` hook — by `bindEngine` when `objectql` is present, or by the new `SettingsService.settleWithoutEngine()` when it is not. So: - -- a `SettingsService` constructed directly (unit tests, bootstrap, control-plane mock) keeps the in-memory fallback exactly as before — it declares no pending bind, and the guard never arms; -- a lean kernel with no `objectql` keeps the plugin's deliberate degradation: once its `kernel:ready` hook has established that no engine is coming, writes resolve into the memory fallback again (now with a `warn` saying those values are lost on restart); -- reads are untouched in every state, so an ordinary boot-time read of a setting still resolves. - -No shipped caller wrote settings inside the window, so no existing startup sequence becomes an error. - -`SETTINGS_ENGINE_NOT_BOUND` is registered in `ERROR_CODE_LEDGER` per ADR-0112. The status is declared on the error class rather than at an HTTP door because no door can reach it: the window closes at `kernel:ready`, and HTTP servers open their socket at `kernel:listening`, strictly after. diff --git a/.changeset/sharing-logger-shape-dedup.md b/.changeset/sharing-logger-shape-dedup.md deleted file mode 100644 index 624aa94290..0000000000 --- a/.changeset/sharing-logger-shape-dedup.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -Collapse the two byte-identical `MinimalLogger` declarations in `plugin-sharing` -onto one shared `OptionalSharingLogger` (#10692). Internal types only — none of -the seven local `MinimalLogger` interfaces was exported, so no published surface -and no runtime behaviour changes. - -`plugin-sharing/src` declared **seven** module-local interfaces all named -`MinimalLogger`. The duplication was not the defect; divergence under one name -was. When #10556 made `bulk-recompute.ts`'s `warn` non-optional, `tsc` reported -the forwarding modules as: - -``` -Type 'MinimalLogger' is not assignable to type 'MinimalLogger'. - Two different types with this name exist, but they are unrelated. -``` - -`bu-tree-recompute.ts` and `primary-bu-projection.ts` were byte-identical, so -they now share one declaration in `logger-shapes.ts`. The new type is -deliberately given a DIFFERENT name: the next forwarding edge added between it -and a module that still declares its own `MinimalLogger` produces a diagnostic -naming two different types, instead of the same name twice. - -The other five declarations are left alone, each for a stated reason recorded in -`logger-shapes.ts`. Three are genuinely different contracts (`bulk-recompute.ts` -is the guaranteed sink; `rule-hooks.ts` and `record-share-cascade.ts` require -`warn` because they forward into it). Two are *not* the cheap unification the -card assumed: - -- `sharing-rule-provenance.ts` is `{ info?, warn? }` by optionality but carries a - stricter member signature, `(msg: string, meta?: Record)`. - Folding it onto the `(msg: any, ...rest: any[])` spelling would delete real - checking; folding the others onto its spelling would tighten two modules. -- `record-orphan-cleanup.ts`'s bare `Function` members **cannot** be tightened - here: `Function` is not assignable to any concrete signature ("Type 'Function' - provides no match for the signature"), and the two loggers handed to it — - `SharingServiceOptions['logger']` and `ShareLinkServiceOptions['logger']` — - are themselves spelled with bare `Function`. - -`check:optional-error-sink` (#9754) membership is unchanged and was verified -before and after: 37 sinks declare `error`, 2 permit silence, 2 baselined. The -shared shape declares no `error` and must not grow one — that would enrol every -module using it into that gate's population, which is a contract decision for -the #10556 family rather than a side effect of de-duplication. diff --git a/.changeset/sharing-rule-criteria-org-scope.md b/.changeset/sharing-rule-criteria-org-scope.md deleted file mode 100644 index 19d54f23a8..0000000000 --- a/.changeset/sharing-rule-criteria-org-scope.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -**Behaviour change (narrowing):** an **org-stamped** sharing rule's criteria sweep is now scoped to that rule's own organization, where it previously swept **every** organization's records (#10119). - -`SharingRuleService.findMatchingRecords` (the whole-rule evaluation pass) and `recordMatches` (the per-record write-hook pass) ran the rule's criteria query under a bare system context carrying no tenant, for every rule. The recipient half was already org-aware — `expandRecipient` threads `rule.organization_id` into the team / business-unit / position graph services — so a rule stamped with an `organization_id` expanded recipients inside its own organization and then matched records belonging to all the others. `reconcile` materialized the cross product: `sys_record_share` rows granting one organization's users access to another organization's records. - -Measured on `main` before the change, through a real `ObjectQL` on a real `SqlDriver`: an `org_a`-stamped rule matched **the same four records as a platform-global rule** (`deal_a1`, `deal_b1`, `deal_b2`, `deal_p1`) and materialized a grant on each; the per-record hook pass minted a grant on `org_b`'s record with `grantsCreated: 1`. - -What changes, and for whom: - -- **Org-stamped rules** (`organization_id` non-null — what any org admin mints through `defineRule`) now run their criteria query with `tenantId` set to the rule's organization. The platform's existing chokepoint does the rest: `ObjectQLEngine.buildDriverOptions` threads it to `DriverOptions.tenantId` and `SqlDriver.applyTenantScope` emits `(organization_id = ? OR organization_id IS NULL)`. So such a rule matches its own organization's records **plus** platform-owned null-org records, and no other tenant's. `SharingRuleEvaluationResult.matchedRecords` falls accordingly, and the next reconcile pass **revokes** the cross-org `sys_record_share` rows it previously created, through the existing revoke-the-remainder branch — no migration is needed. -- **Platform-global rules** (`organization_id = null`) are unchanged: they keep the full unscoped sweep, which is their declared behaviour (documented at the `deleteRule` platform-authority guard). Both directions are pinned. -- **No public contract changes.** No schema, route, error code or accept/reject set moves; the system elevation on the criteria read is retained (the evaluator still sees rows no individual recipient could), only the tenant axis is added. - -The cross-org rows this stops creating were **inert** under a walled posture — the Layer-0 tenant wall AND-composes over sharing's Layer-1 widening, so such a grant could not open a read across the wall. The costs were `sys_record_share` bloat (every org-stamped rule scanning the whole table at `limit: 5000`) and a population that is wrong at rest, which any consumer reading `sys_record_share` directly, or any future softening of the wall, would inherit. diff --git a/.changeset/showcase-react-page-adapter-query-contract.md b/.changeset/showcase-react-page-adapter-query-contract.md deleted file mode 100644 index 79288c37e5..0000000000 --- a/.changeset/showcase-react-page-adapter-query-contract.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/example-showcase": patch ---- - -Fix the showcase react pages' `useAdapter()` query contract, and pin it (#10288) - -`renewals-pipeline` passed `top: 500` and `crm-workbench` passed `limit: 200` to -`adapter.find`. Neither is a query option: `QueryParams` declares only `$`-prefixed keys -and `ObjectStackAdapter.convertQueryParams` copies exactly those, so the key reached no -branch and was dropped with no error. The consequence is the opposite of a truncated -read — the GET list route has **no default page size**, so an absent `top` returns the -ENTIRE match set, and the cap the author wrote never happened. - -The same effect then read its rows off `.records`. `find()` resolves to a normalized -`QueryResult` (`data` + `total`), never the REST envelope, so `pr.records` was -`undefined` on every call and the renewals KPI strip sat at `0 / 0 / 0` while the -`` beside it showed the same rows correctly. Measured on a 640-row account with -the real page source driven against a contract-faithful adapter double: before, -`$top` arrives `undefined` and the strip reads `{projects: 0, invoices: 0, openInvoices: 0}`; -after, the cap is applied and it reads `{projects: 640, invoices: 640, openInvoices: 100, -capped: true}`. - -Applying the cap is only half a fix, because `data.length` under a `$top` is exactly the -silently-capped count the card was filed about — so both pages now count the envelope's -`total` (the server's real count over the same `$filter` whenever a limit was applied). -The one number a cap genuinely bounds, "Open AR", is a per-row verdict over the fetched -window; it renders as `100+` rather than passing for a total. - -`test/react-page-adapter-query-contract.test.ts` executes the page's real rollup effect -and then sweeps every `kind:'react'` page in the app for both contracts, with an -extraction control, a census control, and a positive control on the scanners. diff --git a/.changeset/silver-pugs-tickle.md b/.changeset/silver-pugs-tickle.md deleted file mode 100644 index cb4b10f639..0000000000 --- a/.changeset/silver-pugs-tickle.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -Fix attachment tombstoning silently no-opping on a predicate (`multi: true`) delete - -Deleting `sys_attachment` join rows by PREDICATE left the file they referenced at -`status='committed'` with `deleted_at` NULL, even when the deleted row was the -file's last reference. The tombstone hooks handed file ids from `beforeDelete` to -`afterDelete` on the hook context itself, on the premise that the engine passes -the same `HookContext` to both events. Since ADR-0058 Addendum II (D1/D2) a -predicate write dispatches one FRESH context per matched row in each phase, so -that hand-off never arrived and no tombstone was written. - -The bytes were stranded permanently rather than late: `sys_file`'s declared -lifecycle nominates a sweep candidate only via `ttl { field: 'deleted_at' }` or -`retention { onlyWhen: { status: 'pending' } }`, and an untombstoned orphan -matches neither — so the reap guard was never asked about it. The by-id delete -verb, and both dispatch paths of the update verb, were unaffected. - -The departed id now comes from `ctx.previous.file_id`, which the engine binds on -both phases and both dispatch paths — the same slot the update verb's detach leg -already reads. - -**What an upgrader needs to know.** New predicate deletes tombstone correctly -from this version on. Files ALREADY stranded by the old behaviour are not -retro-actively tombstoned by this change: they sit at `status='committed'` with -live storage bytes and no join row, and nothing in the platform sweep will -nominate them. Recovering that existing backlog needs a one-off reconciliation -pass over `sys_file` (attachments-scope, `status='committed'`, zero -`sys_attachment` references) and is deliberately not part of this fix. diff --git a/.changeset/skill-tools-docblock-adr-0109.md b/.changeset/skill-tools-docblock-adr-0109.md deleted file mode 100644 index c733525762..0000000000 --- a/.changeset/skill-tools-docblock-adr-0109.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Docs:** the `skill.tools[]` docblock now states ADR-0109's authoring model instead of its rejected alternative (#10356). - -`SkillSchema.tools`' docblock told authors that "Tools should also be registered as first-class metadata (type: 'tool') unless they are dynamically materialised at runtime" — the shape ADR-0109 explicitly **rejected** ("a required tool record per exposed action": a second authoring step, a second namespace to keep consistent, and a second surface for AI authors to hallucinate into, for zero added capability). It also inverted the exemption, treating the materialised path as the exception when ADR-0109 makes it — together with the platform registry — the rule. The sibling docblock over `stack.zod.ts`'s `tools` already said the opposite, so the package shipped two contradictory answers to the same question. - -The text now mirrors the resolution universe `@objectstack/lint`'s `validate-ai-tool-references` actually implements: a `tool` record is never required and the default third-party path declares none; a `skill.tools[]` name resolves against the stack's own `stack.tools[]` names, `PLATFORM_PROVIDED_TOOL_NAMES`, and the `action_` family the runtime materialises from AI-exposed declarative actions (`ai.exposed` + `ai.description` on a headless action type, per ADR-0011). It also records that `stack.tools` is the optional Phase-2 AI-presentation refinement layer with no runtime reader until that phase lands — so a record authored today is inert, which the old sentence recommended authoring without saying. - -Prose only: no schema shape, no `.describe()` text, no runtime behaviour and no authorable-surface change (`check:authorable-surface` and the whole `check:generated` set are unmoved by this diff). It is graded rather than skipped because the text ships to consumers: `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts`, so this docblock travels in the npm tarball as source. It does **not** reach `dist/*.d.ts` — property-level comments inside the `z.object({ … })` literal are dropped from the emitted declarations, which is measurable in the built chunk (`tools: z.ZodArray;`, no comment). Published source is the surface that matters here anyway: this is the docblock an AI author reads while writing `skill.tools[]`, the exact surface ADR-0109 was written to keep clean. diff --git a/.changeset/sort-axis-provenance-warning.md b/.changeset/sort-axis-provenance-warning.md deleted file mode 100644 index f1ffefcdf8..0000000000 --- a/.changeset/sort-axis-provenance-warning.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -The SORT axis now asks the #8116 provenance question about a name the blanket -`SYSTEM_FIELDS` union told it not to flag — new rule `sort-field-unprovisioned` -(#10474), the twin of `searchable-field-unprovisioned` on the identical index -(#8404). - -`validate-sortable-fields` consulted the union and stopped there, so a list view -ordering by a registry-injected anchor on an ADR-0015 `external` object was -skipped in silence. The #8999 consumer census recorded that gap with the reason -that such an object never reaches the union branch at all — skip (2) was believed -to catch it. **That reason was measured wrong.** `declaredFieldTarget` returns -`null` on exactly one condition (`fields` missing, unreadable, or naming -nothing) and nothing in it tests `external`, so the shipped shape — a federated -object that declares a mapped field map, as `examples/app-showcase`'s -`showcase_ext_customer` does — is indexed like any other object and lands -squarely in the skip. The census ledger entry now carries the correction rather -than the inherited reason. - -Why the authoring gate is the only door available for it: both runtime doors on -this axis judge `formula` alone (`UNMATERIALIZED_SORT_TYPES`) — the REST ingress -`assertSortFieldsExist` (#6994) and the engine's `assertOrderByIsMaterializable` -(#7095). An injected anchor is a `datetime` or `lookup`, it *is* in `gate.known` -because the registry injected it into the served schema, and it is undotted, so -it clears every verdict and reaches the driver. Measured with a real `SqlDriver` -over better-sqlite3, the object declared exactly as the showcase declares it, -against a remote `customers` table carrying `[id, name, email, region, -lifetime_value]` and none of the seven injected anchors: - -``` -orderBy name asc -> [c1,c2,c3] desc -> [c3,c2,c1] (a real column: reverses) -orderBy created_at asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error -orderBy owner_id asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error -``` - -`asc` and `desc` byte-identical while the baseline reverses is what makes it a -dropped sort rather than a coincidence — the same signature this rule already -records for `formula`, reached by a second route, except that a formula sort is -refused at both doors and this one is not. A list view ordered by an anchor with -no storage answers `200` with the rows in the driver's arbitrary order, on the -view's first fetch and every fetch after it, which `limit`/`offset` then slice -into an arbitrary page. - -`warning`, never `error` and never gating (#4330's cost asymmetry, the call every -sibling makes): the remote schema is invisible to this pass, so the remote table -may genuinely carry a `created_at` of its own. Declaring that column — the first -remedy the shared hint prescribes — silences the finding, because -`unprovisionedInjectedColumnsFor` excludes an author-declared column of the same -name (#7859's security direction). The runtime publish gate sorts on severity, so -this lands as an advisory and refuses no write. - -Two deliberate narrowings, both pinned: - -- **Undotted names only** — the one place this axis departs from the SEARCH twin. - `resolveSearchFields` matches by exact string and drops a dotted entry like a - typo, but a dotted SORT name is refused by the ingress gate as its own verdict - (`400 INVALID_SORT`, loudly, on every fetch), so the silent degradation this - finding reports cannot happen there. Answering would give the SORT axis its own - dotted verdict, which is exactly the posture the rule shares with the FILTER - and PROJECTION axes (#4256 / #7532 / #7589) and declines to break. -- **`checkSortDeclaration`'s new anchor-index parameter is optional**, with the - same meaning `checkSearchableFieldList`'s carries: an out-of-repo caller that - never built the index keeps its pre-#10474 answers. Every in-repo caller passes - it. - -Also re-ruled, with fresh eyes and on evidence rather than inheritance: -`validate-translation-references` still correctly asks nothing. It reads the -union at exactly one site (the `fields.` orphan test), and the key it -decides about is derived from the *registered* metadata, into which the registry -injects the anchor on a federated object just as on a local one — so the key -resolves and the label renders. Warning there would flag a translation that -works. The blank-column consequence belongs to the surface that renders the -anchor (`validate-page-field-bindings`, #8340), not to the bundle that names it. diff --git a/.changeset/spec-browser-condition-pg-grammar.md b/.changeset/spec-browser-condition-pg-grammar.md deleted file mode 100644 index bec421ef52..0000000000 --- a/.changeset/spec-browser-condition-pg-grammar.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Declare the package's browser boundary in the `exports` map (#11072): the five -entries whose module graph reaches the driver-config validators (`.`, `./data`, -`./system`, `./kernel`, `./cloud`) now carry a `browser` export condition -pointing at bundles (`dist/browser/**`) in which the postgres `url` -refinement's pg-grammar arm is excluded. `pg-connection-string` — the parser -`pg` itself uses, and the one the #9091 refusal deliberately asks — statically -resolves `require('fs')`, so any browser bundler whose client graph reached one -of these entries failed on `Can't resolve 'fs'` (measured on objectui's docs -site, Next.js/Turbopack). - -Patch, not minor/major, because the change is additive resolution surface with -zero Node-side movement: Node's resolver never matches `browser`, every -existing `import`/`require` condition still points at the same files, and the -full #9091 DSN refusal (multi-host, non-numeric port, scheme-less non-URL) -still runs for every Node consumer — the existing `postgres.test.ts` pins hold -it. In the browser-conditioned bundles the refinement degrades to the -shape-only checks it already performs before `parse` (the unix-socket -short-circuit and the fs-reading `?sslcert=`/`?sslkey=`/`?sslrootcert=` -refusal); publish-time validation never legitimately runs in a browser. - -The boundary is enforced at this producer from now on: -`check:browser-reachable-entries` refuses any browser-resolvable bundle — -browser-conditioned or not — that links a Node builtin or a declared -server-only package, with a positive control on the Node side, so the next -Node-only import fails this package's own CI instead of a downstream bundler. diff --git a/.changeset/sqlite-composite-primary-key-introspection.md b/.changeset/sqlite-composite-primary-key-introspection.md deleted file mode 100644 index 0e7c3e0c39..0000000000 --- a/.changeset/sqlite-composite-primary-key-introspection.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -SQLite introspection now reports every member of a composite primary key, in -declared key order. `SqlDriver.introspectPrimaryKeys` filtered -`PRAGMA table_info` rows on `row.pk === 1`, but SQLite does not report `pk` as -a boolean — it is the column's **1-based position within the primary key** -(`0` = not part of the key, `1` = first key column, `2` = second, and so on). -The filter therefore kept only the first member of a composite key and silently -dropped the rest. - -Measured on in-memory SQLite, table declared `primary key (order_id, line_no)`: - -| signal | reported | reports instead | -| --- | --- | --- | -| `table.primaryKeys` | `['order_id']` | `['order_id', 'line_no']` | -| `column.isPrimary` for `line_no` | `false` | `true` | - -Both signals were wrong together and for the same reason: `introspectSchema` -derives `col.isPrimary` from `primaryKeys`, so a consumer could not recover the -dropped member by cross-checking the two. Fixing the list repairs the flag with -it. - -The rows are now also ordered by the `pk` ordinal rather than taken in -`table_info` row order (which is *column* order). The two differ whenever a key -is declared out of column sequence — a table with columns -`(carrier_code, shipment_id, leg_seq)` and `primary key (shipment_id, -carrier_code)` now reports `['shipment_id', 'carrier_code']` — and -`primaryKeys` is consumed as an addressing / upsert-conflict-target key, where -the order is load-bearing. - -Consumers affected: the federated-object codegen and the persisted -`external_catalog` (ADR-0015) recorded a partial addressing/upsert key, and -schema-drift comparison against a declared composite key read as drift on the -dropped member. `SqliteWasmDriver` and `TursoDriver` extend `SqlDriver` and -override neither method, so they inherit the repair. The Postgres and MySQL -arms did not have this defect and are unchanged. diff --git a/.changeset/sso-domain-verification-error-code-casing.md b/.changeset/sso-domain-verification-error-code-casing.md deleted file mode 100644 index f16dc597e2..0000000000 --- a/.changeset/sso-domain-verification-error-code-casing.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -The two SSO domain-verification admin routes now answer a registered ADR-0112 -error code. `POST /admin/sso/request-domain-verification` and -`POST /admin/sso/verify-domain` shape their failure as -`code: parsed?.code || `, and the default half — the code -ObjectStack itself authors when @better-auth/sso returns none — was lowercase -(#10716, found by #10658): - -| route | wrote | writes instead | -| --- | --- | --- | -| `POST /admin/sso/request-domain-verification` | `request_domain_verification_failed` | `DOMAIN_VERIFICATION_FAILED` | -| `POST /admin/sso/verify-domain` | `verify_domain_failed` | `DOMAIN_VERIFICATION_FAILED` | - -If you match on either lowercase spelling, match on `DOMAIN_VERIFICATION_FAILED` -instead — the two routes are distinguished by their path, as they already were -for every other failure they can answer. - -`DOMAIN_VERIFICATION_FAILED` is reused, not invented: it is already registered -for `@objectstack/plugin-auth` in the error-code ledger, so this PR adds nothing -to `packages/spec` and the emitted vocabulary gets no new member. A new spelling -(`VERIFY_DOMAIN_FAILED`) would have needed a ledger registration to be a legal -`error.code` at all, and — measured while fixing this — an unregistered code in -an `||` fallback slot is currently invisible to BOTH error-code gates, so it -would have shipped as exactly the silent fourth state ADR-0112 D3 exists to -prevent. - -**The vendor pass-through arm is unchanged.** `parsed?.code` still reaches the -caller verbatim, so @better-auth/sso's own diagnosis (`NO_PENDING_VERIFICATION`, -`DOMAIN_VERIFICATION_FAILED`) is never overwritten by ours — the half that would -be silently lost by a handler that stamped our code unconditionally, and it is -pinned in both directions by -`packages/plugins/plugin-auth/src/sso-domain-verification-error-codes.test.ts`. -Statuses and messages are untouched on every path. - -ADR-0087 disposition, in prose because the marker vocabulary has no slot for -this shape: nothing is registered and nothing needs to be. The declared wire -contract is `error.code ∈ StandardErrorCode ∪ ERROR_CODE_LEDGER`, and neither -lowercase spelling was ever a member of it — they were undeclared values a -blind gate let through, so this brings the implementation onto the published -contract rather than changing that contract. There is no metadata surface for -`objectstack migrate meta` to rewrite: error codes live in responses, not in -stored metadata. The table above is here for anyone who matched the undeclared -spelling anyway, which is why this ships as `minor` rather than `patch`. diff --git a/.changeset/sso-register-platform-admin-only.md b/.changeset/sso-register-platform-admin-only.md deleted file mode 100644 index f34bf8b56d..0000000000 --- a/.changeset/sso-register-platform-admin-only.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -**Behaviour change (tightening):** registering an SSO identity provider through the direct `POST /api/v1/auth/sso/register` endpoint now requires a **platform admin**. An organization **owner or admin** who is not a platform admin can no longer register an identity provider on any surface (#10009). - -Who loses access: an org owner/admin (a `sys_member` row graded owner/admin) with no org-less `admin_full_access` grant. They previously passed the ADR-0024 before-hook on the direct endpoint and now receive `403 SSO_REGISTER_FORBIDDEN`. Platform admins — an org-less `sys_user_permission_set` link to `admin_full_access`, per ADR-0068 D2 — are unaffected, as are anonymous callers, who still fall through to better-auth's `sessionMiddleware` (`401`). - -This closes a posture divergence: the four `/admin/sso/*` bridges the `sys_sso_provider` metadata actions call have gated on the platform-admin judge since #9653, while better-auth's own endpoint kept the wider ADR-0024 admit set — so the same principal was refused at one door and admitted at the other for the same underlying registration, leaving the bridge tightening as labelling rather than a boundary. Per the 2026-08-20 maintainer ruling, ADR-0068 D4 governs: registering an identity provider is a platform-operator action. If org-scoped IdP self-serve is ever wanted, it is a deliberate future decision rather than a vendor default inherited by omission. - -The direct endpoint also gains its first test pins; the now-callerless `isOrgOrPlatformAdmin` predicate was removed rather than left dead. diff --git a/.changeset/sso-verify-domain-disabled-answers-disabled-code.md b/.changeset/sso-verify-domain-disabled-answers-disabled-code.md deleted file mode 100644 index 77f100b382..0000000000 --- a/.changeset/sso-verify-domain-disabled-answers-disabled-code.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -`POST /admin/sso/verify-domain` now answers the DISABLED condition the way its -sibling always has. When SSO domain verification is off for an environment, -`@better-auth/sso` never mounts the inner endpoint and answers `404` with no -code. Both bridge routes recognise that shape, and they used to answer it -differently (#10859): - -| route | answered | answers instead | -| --- | --- | --- | -| `POST /admin/sso/request-domain-verification` | `400` `DOMAIN_VERIFICATION_DISABLED` | unchanged | -| `POST /admin/sso/verify-domain` | `404` `DOMAIN_VERIFICATION_FAILED` | `400` `DOMAIN_VERIFICATION_DISABLED` | - -`verify-domain` rewrote only the `message` for that branch and let the code fall -through to its generic failure default, so the response carried "the feature is -off" copy under a code that means "verification failed". A caller can only act -on the machine-readable half, and the two halves disagreed. The status moves -with the code: the inner `404` describes the INNER endpoint, which is unmounted, -whereas this bridge route is mounted unconditionally — passing that status -through said "no such endpoint" about a resource that exists. - -If you match on `DOMAIN_VERIFICATION_FAILED` (or on `404`) to detect the -disabled case on `verify-domain`, match on `DOMAIN_VERIFICATION_DISABLED` (or on -`400`) instead — the same pair `request-domain-verification` has always -answered. The distinction is worth having: `DISABLED` means "turn on -`OS_SSO_DOMAIN_VERIFICATION`", `FAILED` means "the DNS TXT record is not visible -yet, retry". - -**No `packages/spec` change, and the emitted vocabulary gains no member.** Both -codes are already registered for `@objectstack/plugin-auth` in the error-code -ledger, with exactly these meanings (`DOMAIN_VERIFICATION_DISABLED` — "domain -verification is off on this deployment"). This route was emitting a *declared* -code whose registered meaning is a different condition, so this is -declared-vs-enforced restoration rather than a new contract decision. - -**A genuine verification failure still answers the failure code, and the vendor -pass-through arm is untouched on both routes.** The rewrite is keyed to the -disabled shape specifically — `404` *without* a code. A `404` that carries -`@better-auth/sso`'s own code is the vendor's diagnosis and reaches the caller -verbatim, status included, as does every non-404 failure. That direction is the -load-bearing one — an implementation keyed to "any 404", or to `!resp.ok`, would -satisfy the disabled case while destroying the diagnosis a caller acts on — and -it is pinned in both directions in -`packages/plugins/plugin-auth/src/sso-domain-verification-error-codes.test.ts`. - -Shipped as `minor`, following the same call the casing rename on these two -routes made (#10716). The argument for it: the vocabulary is unchanged, and the -old pairing was self-contradictory rather than a contract anyone could have -relied on deliberately. The argument against it, stated here rather than -settled: unlike that rename — whose old spellings were undeclared values no -schema admitted — `DOMAIN_VERIFICATION_FAILED` *is* a declared, registered code, -so a client keyed to it for this case was keyed to something the published -contract admitted, and both halves of the answer change. A reviewer who reads -that as `major` is not reading it wrong; this PR does not decide it silently. diff --git a/.changeset/stack-themes-carrier-retired.md b/.changeset/stack-themes-carrier-retired.md deleted file mode 100644 index 9d715ee2b1..0000000000 --- a/.changeset/stack-themes-carrier-retired.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the `themes` carrier key and `ThemeSchema` — the authoring surface nothing ever applied (#10485, 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-21, recorded verbatim on #10485: 「B:退役授权面 — -收掉 `themes` 载体键与 schema,`app.branding` 留作唯一颜色面;objectui 引擎代码 -与单测保留。」 - -`defineStack({ themes })` was a real authoring surface — parsed strictly at the -authoring gate, ingested and stored by artifact ingest -(`ARTIFACT_FIELD_TO_TYPE`) — with ZERO consumers past that point, measured: -no non-test read of `.themes` or of stored `theme` items anywhere in -core/runtime/rest/services/plugins; `theme` never in `MetadataTypeSchema`, -`DEFAULT_METADATA_TYPE_REGISTRY` or `BUILTIN_METADATA_TYPE_SCHEMAS`; the only -mounted `ThemeProvider` is the app-shell chrome light/dark toggle (unrelated to -`ThemeSchema`); and no stack- or app-level key ever selected an active theme. -An author who wrote a theme shipped it through every green gate and the console -looked exactly the same. - -**What is refused:** the top-level `themes:` key. `ObjectStackDefinitionSchema` -is a `strictObject`, so the key is deleted from the shape and the unknown-key -rejection carries the retirement prescription via the schema's `guidance` entry -(removal citation, why it was inert, and the `app.branding` replacement). -`ThemeSchema`, `ColorPaletteSchema`, `TypographySchema`, `BorderRadiusSchema`, -`ShadowSchema`, `ThemeModeSchema`, `defineTheme` and the `Theme` / -`ThemeParsed` / `ColorPalette` / `Typography` / `BorderRadius` / `Shadow` / -`ThemeMode` types are removed from `@objectstack/spec` / `@objectstack/spec/ui` -(orphaned value schemas leave with their one consumer, #3950). `PUT -/api/v1/meta/theme/:name` now gets the #8421 unrecognised-type refusal — the -`themes: 'theme'` fold left `PLURAL_TO_SINGULAR` and with it the generated -URL-spelling contract — instead of the pre-#10194 store-anything branch. - -**What stays:** `app.branding.primaryColor` / `accentColor` — the one live -colour surface (objectui's `AppShell` reads it and derives `--primary`, -`--accent` and friends) — plus objectui's `ThemeEngine` / `ThemeContext` engine -code and their unit tests, explicitly retained by the ruling. Legacy stored -`theme` rows are untouched: reads still answer, DELETE still works, and -`applyConversionsToStoredItem` passes them through unchanged. - -The retirement kit: - -- strict deletion + `guidance` prescription at the stack schema - (`packages/spec/src/stack.zod.ts`); `packages/spec/src/ui/theme.zod.ts` - deleted whole -- ADR-0087 registration: retired-def entries `ui/Theme`, `ui/ThemeMode`, - `ui/ColorPalette`, `ui/Typography`, `ui/BorderRadius`, `ui/Shadow` and the - D3 **semantic** entry `stack-themes-carrier-retired` (protocol 18). Semantic - rather than a D2 conversion on the lossless-only scope guard: a stack may - declare N themes and M apps, so which palette entry becomes which app's - `branding.primaryColor` is a judgment the transform cannot make — the entry - prescribes the hand move instead of auto-deleting authored content -- ingest mapping removed (`packages/metadata/src/plugin.ts`), CLI stats row - removed, showcase example re-based on app branding -- pin tests: `stack-top-level-strict.test.ts` (refusal carries `#10485` + - `app.branding` + no rename suggestion; replacement parses green; no theme - export survives on `./ui`) and `protocol.unrecognised-meta-type.test.ts` - (`/meta/theme` refused with the ADR-0112 envelope, nothing stored) -- generated baselines/docs follow the schema (`authorable-surface/`, - `json-schema.manifest/`, api-surface, export-origins, meta-url-spelling, - spec-changes, upgrade guide, reference docs, skill references) - -## FROM → TO - -```ts -// before — parsed green, stored by artifact ingest, applied by NOTHING: -defineStack({ - themes: [{ name: 'corporate', label: 'Corporate', mode: 'light', - colors: { primary: '#7C3AED' } }], -}); - -// after — delete the key; colour the console where something reads it: -defineApp({ - name: 'my_app', - label: 'My App', - branding: { primaryColor: '#7C3AED', accentColor: '#06B6D4' }, -}); -// a custom CSS variable your own stylesheet consumed has no spec slot any -// more — move it into your own CSS. -``` - - diff --git a/.changeset/starter-comments-self-contained.md b/.changeset/starter-comments-self-contained.md deleted file mode 100644 index 32a2bdcb7c..0000000000 --- a/.changeset/starter-comments-self-contained.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"create-objectstack": patch ---- - -Rewrite the scaffolded project's starter comments so a newcomer can actually -follow them (#10324). `objectstack.config.ts` and `src/objects/note.object.ts` -are the first two files opened after scaffolding, and between them they cited -four ADR identifiers, one bare issue number and the path of a release-time -script in this monorepo — none of which ship in, or are linked from, a -scaffolded project. `// per ADR-0097` read as a reference the reader was -failing to follow rather than as the context it was meant to be. - -The explanations are kept and made self-contained; only the dead ends are -gone. Each now states the fact the identifier stood for — the protocol range -is checked before anything loads and was stamped to match the installed -version rather than hand-tuned; `automation` must stay whenever `plugins:` -lists a connector or the executors have nowhere to register; a declarative -`mcp` stdio transport is denied by default; the org-wide default is required -so the baseline is an authored decision — and points at the public docs page -that covers it in full. The blank `Dockerfile` likewise stops pointing at a -file in this repo and points at the self-hosting guide it already links. - -A pin (`starter-comments-self-contained.test.ts`) keeps it that way from both -sides: no shipped template file may cite an ADR identifier, a bare issue -number or a repo script path, and the facts those references carried must -still be stated — so the comments cannot be "fixed" by deleting them. It also -resolves every canonical-origin docs URL in the shipped tree against -`content/docs`, because a link that 404s is the same defect one level out. diff --git a/.changeset/stdio-mcp-execution-context-converge.md b/.changeset/stdio-mcp-execution-context-converge.md deleted file mode 100644 index 61bdd57a97..0000000000 --- a/.changeset/stdio-mcp-execution-context-converge.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/mcp": minor ---- - -fix(mcp): the stdio MCP transport assembles its ExecutionContext with the shared assembler, and resolves localization (#7279) - -`resolveStdioExecutionContext` was the last hand-written `ExecutionContext` -assembly on the platform. #6216 converged the dispatcher, REST and share-link -sites onto `assembleExecutionContext`; this face was not in that card's -inventory, so it kept building the envelope field-by-field — and fell behind it -in two ways. - -| field | before | after | -|---|---|---| -| `tabPermissions` | dropped | **carried** | -| `timezone` / `locale` / `currency` | **resolved not at all** | **carried** (workspace values) | -| `accessToken` | absent by omission | **withheld by decision, on the record** | -| `positions` / `permissions` / `systemPermissions` / `userId` / `tenantId` / `email` / `posture` / `org_user_ids` / `accessible_org_ids` | carried | carried, unchanged | - -## ⚠️ This changes output on the stdio surface — it is NOT a no-op - -**Formula fields evaluated during a stdio call move from `UTC` to the -workspace timezone.** The read path threads `ExecutionContext.timezone` into -`ExpressionEngine.evaluate`, which defaults to `UTC` when the context carries -none (`cel-engine.ts`: `ctx.timezone ?? 'UTC'`). Every stdio call previously -carried none. **A date-bucketing formula can therefore return a different -calendar day than it did before this change** — for a workspace whose timezone -is not UTC, that is the point: the same record read over REST and over stdio -now agree, where before they could disagree by a day. - -Two smaller shifts ride along: - -- **Denial messages localize.** A read refused by CRUD/FLS or RLS renders in the - workspace language (`userFacingDenialMessage`, `opCtx.context?.locale`) instead - of English. -- **Date-dependent driver generation on the write doors** (autonumber - `{YYYYMMDD}` tokens) resolves its calendar day from the workspace timezone. - `buildDriverOptions`' `hasTz` gate (`execCtx?.timezone !== undefined`) is one - of the few places where a field's ABSENCE is a meaningful state, and a stdio - call crosses it for the first time. Pinned in both directions by - `packages/objectql/src/engine-timezone-presence-gate.test.ts`. - -If a deployment's workspace timezone is unset, `resolveLocalizationContext` -falls back to `UTC` / `en-US` — the values this face effectively used before — -and nothing changes for it. - -## `accessToken` is withheld, deliberately, and now says so - -The stdio face's credential is a **long-lived `osk_` API key** read from -`OS_MCP_STDIO_API_KEY`, not a session bearer. `ExecutionContext.accessToken` is -a **published hook surface** (`session.accessToken`, `spec/data/hook.zod.ts`), -so handing every `beforeFind`/`afterFind` a credential with far longer life than -the session token that surface was designed around is a product decision nobody -has made. This face passes `accessToken: undefined` with the reason written -down, matching the REST precedent. (It is also unreachable here: the value is -assigned only inside `resolve-authz-context.ts`'s -`if (!userId && typeof input.getSession === 'function')` branch, and this call -passes no `getSession`. The test injects a sentinel token at the seam anyway, so -the *decision* is pinned rather than the accident.) - -## Cost, and where it is paid - -`resolveStdioExecutionContext` still re-resolves the **identity** on every call, -deliberately — ADR-0101 D1, so a revoked key stops working on the next one. -Localization is resolved **once, in `start()`**, and reused: the key's tenant -cannot change mid-session, and up to three settings reads per MCP call on a -long-lived process is not acceptable steady state. diff --git a/.changeset/strict-blueprint-namefield.md b/.changeset/strict-blueprint-namefield.md deleted file mode 100644 index ddac34d0e8..0000000000 --- a/.changeset/strict-blueprint-namefield.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Add `nameField` to the solution-blueprint strict mirror's object schema (required-but-nullable, matching the strict convention), so the design-stage structured output can author the ADR-0079 record-title choice instead of always deferring to the platform auto-pick. The key-parity pin between the strict mirror and the lenient schema is widened from the field schemas to the object schemas, so the next object-level divergence fails a test. diff --git a/.changeset/studio-header-boot-path-settled.md b/.changeset/studio-header-boot-path-settled.md deleted file mode 100644 index 428e42ddef..0000000000 --- a/.changeset/studio-header-boot-path-settled.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/studio": patch ---- - -Corrected the package header's transitional NOTE, which had drifted stale in -two opposite directions. - -Verified directly against the boot path at head, not against the header's -own narrative: `plugin-dev`'s `DevPlugin` boot loop and `cli`'s `os serve` -app-package loop both deliberately register only `@objectstack/setup` and -`@objectstack/account` — Studio's exclusion is a settled decision, not a -pending follow-up, because the console ships its own dedicated Studio -surface. `plugin-auth`'s manifest has likewise already stopped registering -Studio (ADR-0048); that removal is done, not "landing separately" as the -stale header implied. - -The header's other transitional claim is still accurate and was left -unchanged: `STUDIO_APP` is still imported from -`@objectstack/platform-objects/apps` rather than defined in this package. - -Comment-only: no export, behaviour, or boot path changed. diff --git a/.changeset/sys-session-ttl-spare-tombstones.md b/.changeset/sys-session-ttl-spare-tombstones.md deleted file mode 100644 index 5b38dad571..0000000000 --- a/.changeset/sys-session-ttl-spare-tombstones.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/platform-objects": minor ---- - -Declare an ADR-0057 lifecycle policy on `sys_session` (#7826): the object is -now `class: 'transient'` with -`ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }`. - -**Ordinary expired sessions are now reaped** by the LifecycleService Reaper one -day after `expires_at` passes — the same window `sys_device_code` uses. Until -now nothing swept this table: better-auth's only expiry-driven collector fires -inside `GET /get-session`, so it can never reach a row whose cookie is never -presented again, and an abandoned session was effectively immortal. - -**Revoked tombstones are deliberately spared.** The `onlyWhen` filter (#10165) -is load-bearing, not defensive: the #7732 revocation write backdates -`expires_at` to `now - 1000` and clears nothing, so an ADR-0069 D4 audit -tombstone looks *maximally* expired — a TTL on `expires_at` without the filter -would reap the audit trail first and hardest. - -Deliberate, known consequence: because tombstones are spared entirely, -`sys_session` still grows without bound on the revoked arm. How long a -revoked-session tombstone should be retained is compliance / audit-trail -policy and is not settled here. diff --git a/.changeset/sysmetadata-repository-contract-suite.md b/.changeset/sysmetadata-repository-contract-suite.md deleted file mode 100644 index 8cf1fa1231..0000000000 --- a/.changeset/sysmetadata-repository-contract-suite.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/metadata-core": minor ---- - -`runRepositoryContractTests` gains two narrow options so the shared invariant -table can be applied to `SysMetadataRepository` — the implementation that backs -every production metadata write, and the one that had never been handed to the -suite (#10420). Both are additive and optional; every existing call site is -unchanged. - -- **`primaryType` / `secondaryType`** move the suite's two *fixture* metadata - types (previously hard-coded `'view'` and `'object'`), defaulting to exactly - those. This is a fixture knob, not an invariant knob: no clause is added, - removed or weakened by moving it. It exists because an implementation may sit - behind a write-authorization door keyed on the type — - `SysMetadataRepository.assertAllowed()` refuses any type whose registry entry - lacks `allowOrgOverride`, `'object'` included — so a hard-coded fixture type - silently decided which implementations could be held to the table at all. -- **`declaredDivergences`** records an issue-tracked exception to the table. - It does **not** skip the clause it names — a skipped clause is - indistinguishable from coverage in a green run, which is the one failure a - shared contract suite must not have. It swaps in a clause that *pins the - divergent behaviour*, so the suite reds the day the implementation starts - conforming and whoever fixes it is told to delete the declaration in the same - PR. Shrink-only, audited in the fixing direction, like the repo's other - ledgers. The only member today is `resumableWatch` (contract invariant 6), and - the only declaration is `SysMetadataRepository` — see #10842. - -Publishable behaviour is otherwise untouched: `packages/metadata-protocol` gains -a test file only, and 32 of the suite's 34 clauses were already satisfied by -`SysMetadataRepository` on the first run. diff --git a/.changeset/team-approver-org-screen.md b/.changeset/team-approver-org-screen.md deleted file mode 100644 index 0f61ff3455..0000000000 --- a/.changeset/team-approver-org-screen.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -**Who loses access:** members of a team belonging to a *different* organization -than the record being approved. Concretely — a request raised in `org_a` routed -to a `team` approver whose `sys_team.organization_id` is `org_b` used to place -every `sys_team_member` of that team into `pending_approvers`, giving them the -approve/reject buttons on a record they are not a tenant of. They no longer -enter the slate, and the step falls back to the dead `team:` literal with -the existing `#3807` "expanded to nobody" warning — the same shape a cross-org -`position` approver has always produced (#10230). - -`team` was the last approver expansion that resolved people without asking -which organization was asking; `department`, `position`, `org_membership_level` -and (since #10153) `manager` all do. The screen reads the team's own -`organization_id`, so it costs one row and a team that fails it never fans out. - -**Who does not lose access**, deliberately: a team stamped with the request's -own organization; a team stamped with **no** organization (`organization_id: -null` on a platform object means "owned by no organization" — what a seed -writes, since a seed cannot know the id the runtime mints at boot); a team id -with no `sys_team` row at all; and any request that carries no organization — -all four leave routing exactly as it was, because the tenancy fact is absent -rather than negative. - -⚠️ One externally observable accept→reject change beyond the routing itself: -under the non-default `onEmptyApprovers: 'fail'` policy, a node whose *sole* -approver was a cross-org team used to open a request and now throws -`NO_APPROVERS`. Under the default (`admin_rescue`) the node still opens. diff --git a/.changeset/team-member-org-screen.md b/.changeset/team-member-org-screen.md deleted file mode 100644 index c284211817..0000000000 --- a/.changeset/team-member-org-screen.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -Screen expanded `team` approver members to the request's organization (#10547). - -#10230 made a `team` approver prove the TEAM's tenancy, and deferred the -members on purpose. `sys_team_member` carries `team_id` and `user_id` and no -organization column, so a team that passed that screen still routed every user -id it listed — including a user whose only `sys_member` row is in another -organization. Measured on a fixture, not read off the schema: an `org_a` -request against an `org_a` team returned `["u_outsider","u_insider"]` with zero -`sys_member` reads. - -The expansion now screens the members with the same provably-outside posture -the neighbouring screens pin, in ONE `$in` read for the whole slate: - -- membership rows exist for the user and none is the request's organization - (present and NEGATIVE) — dropped, with a warning naming the users, the team - and both organizations; -- no membership rows, an unreadable `sys_member`, a possibly-truncated read, or - a request carrying no organization (ABSENT) — routing is left exactly as it - was, and the no-organization case performs no read at all. - -Holding membership elsewhere is not disqualifying; holding none here is. - -⚠️ Behaviour change, confined to one non-default policy: a node whose only -approver is a team staffed entirely by users provably outside the organization -now resolves to no one. Under the default `onEmptyApprovers: 'admin_rescue'` it -still opens, routed to the dead `team:` literal as any unresolved slate is; -under `onEmptyApprovers: 'fail'` it now throws `NO_APPROVERS` where it -previously opened. - -Residual condition on the security value: the screen can only act on tenancy -facts that exist. A deployment that stamps an organization on its approval -requests but does not materialize `sys_member` rows sees no change — by design, -since #3807 recorded what treating an absent fact as a negative one costs. diff --git a/.changeset/tidy-cubes-count-their-columns.md b/.changeset/tidy-cubes-count-their-columns.md deleted file mode 100644 index f419deb259..0000000000 --- a/.changeset/tidy-cubes-count-their-columns.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -Analytics measures are now compiled from everything they declare — `aggregate`, `field` **and** `filter` — on both the dashboard path and `POST /api/v1/analytics/query`. - -**Reported figures change, and the new ones are the declared ones.** Two corrections, both of which move numbers a dashboard or an API consumer is already reading: - -- A measure written `{ aggregate: 'count', field: 'some_column' }` used to compile to `COUNT(*)` and count **rows**. It now compiles to `COUNT("some_column")` and counts **non-null values**. Any such measure will report the same number as before or a **smaller** one, and a rate built on top of it (a numerator over a total) will drop accordingly — a "100%" tile whose column was mostly empty was reading its own denominator. -- `POST /api/v1/analytics/query` used to drop every per-measure `filter`, and the dataset's definition-level `filter` with it, returning unfiltered aggregates under the author's measure names. It now applies both, so the endpoint answers what the dashboard already answered for the same cube. Figures pulled through the API — agent tools, exports, downstream reports — will move to the filtered values; a measure declaring `filter: { stage: 'closed_won' }` stops counting every row. - -Measures that declare no `field` still compile to `COUNT(*)`, and a cube that is not a compiled dataset (an inferred or manifest cube) emits byte-for-byte the statement it did before. Measure filters lower to portable `CASE WHEN` conditional aggregates rather than `FILTER (WHERE …)`, which MySQL does not have. - -If a saved figure or a screenshot disagrees with what the platform now reports, the new number is the one the metadata declares. diff --git a/.changeset/time-relative-dispatch-ledger.md b/.changeset/time-relative-dispatch-ledger.md deleted file mode 100644 index b8e6008c89..0000000000 --- a/.changeset/time-relative-dispatch-ledger.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/trigger-schedule': minor -'@objectstack/spec': patch ---- - -Time-relative sweeps are now idempotent per matched window (#10220). Previously the sweep -held no cross-tick memory, so every re-scan of the same window re-dispatched the same -records — a 5s-interval flow minted 15 duplicate reminders in ~70s, and even under a daily -cron a kernel rebuild re-dispatched the day's window. - -- `@objectstack/service-automation` — new platform object `sys_flow_dispatch`: a persisted - dispatch-claim ledger (ADR-0057 telemetry retention, 30 days), registered alongside - `sys_automation_run` and exposed as `AutomationEngine.claim(key): Promise` on - the automation service surface (check-and-record; a concurrent duplicate insert re-reads - and reports the key as already claimed). When no ObjectQL engine / registration is - available the engine degrades to in-process dedup and logs the weakened guarantee once; - when the ledger errors, the claim falls back to the in-process check for that key so a - store outage never blocks a dispatch (availability over strict-once). -- `@objectstack/trigger-schedule` — the time-relative sweep computes a dispatch key from - the MATCHED WINDOW's identity and claims it before launching: offset mode keys on - `(flowName, recordId, windowDay, offset)` — so a dateField edit that moves the window - legitimately re-fires — and range mode keys on `(flowName, recordId, sweepDay, - rangeSpec)`, preserving the documented `withinDays` semantic ("fires every day the - record stays in range") while never firing twice in one day. The trigger resolves the - claim surface structurally from the automation service; without one it dedups - in-process and warns once. -- `@objectstack/spec` — `sys_flow_dispatch` added to `PLATFORM_OBJECTS_BY_PACKAGE` under - `service-automation` (registry conformance). diff --git a/.changeset/ttl-onlywhen-null-predicate.md b/.changeset/ttl-onlywhen-null-predicate.md deleted file mode 100644 index 400ffc7014..0000000000 --- a/.changeset/ttl-onlywhen-null-predicate.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor ---- - -`lifecycle.ttl` now accepts an `onlyWhen` row filter mirroring `retention.onlyWhen`, and the shared `onlyWhen` value union gains the platform's canonical null predicate `{$null: boolean}` (on both blocks). A `transient` object that interleaves live rows with terminal audit tombstones can now spare rows defined by a value's absence — e.g. `ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }` — instead of the TTL reaping backdated tombstones first. The LifecycleService Reaper passes `ttl.onlyWhen` into the same reap scope `retention.onlyWhen` already rides; declaring `ttl.onlyWhen` together with rotation storage or archive is refused at parse time, mirroring retention's guards. diff --git a/.changeset/two-factor-backup-code-reveal.md b/.changeset/two-factor-backup-code-reveal.md deleted file mode 100644 index d1591a036e..0000000000 --- a/.changeset/two-factor-backup-code-reveal.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -Show 2FA backup codes on the surface a user can actually reach — the reachable -regeneration path was a lockout (#10681). - -`sys_user.generate_backup_codes` is mounted at Setup → People & Organization → -Users (Security tab, via `record:quick_actions { location: 'record_section' }` -in `pages/sys-user.page.ts`). It declared no `resultDialog`: it toasted "New -backup codes generated — save them somewhere safe", issued the request, and -dropped the response. The previous code set is invalidated wholesale the moment -that request succeeds, so the reachable path was *old codes destroyed, new codes -discarded* — with no way to get them back: - -- better-auth's `twoFactor()` defaults to `storeBackupCodes: 'encrypted'` and - `auth-manager.ts` passes no `backupCodeOptions`, so `sys_two_factor.backup_codes` - holds `symmetricEncrypt(JSON.stringify(codes))` — one opaque ciphertext. -- `auth-route-ledger.ts` publishes `generate-backup-codes` and **no** route that - reads codes back. There is no re-reveal endpoint, by design. - -So the API response is the user's one and only sight of those codes. -`generate_backup_codes` now declares the one-shot reveal -(`{ path: 'backupCodes', format: 'code-list' }`) and `enable_two_factor` the QR -equivalent (`totpURI` as `qrcode` + `backupCodes`), which suppresses the toast -and opens an acknowledge-only dialog instead. Both copy the shapes -`sys_two_factor.enable_two_factor` / `regenerate_backup_codes` already carried — -deliberately not a third and fourth spelling of the same declaration. - -**Why the correct declarations existed and still did not help.** `sys_two_factor` -carries them and is mounted in **no** app — it appears in no navigation -contribution — so the only 2FA surface a user can reach was the one missing them. -That is why the new pin -(`packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts`) -walks the Setup-navigation → page → quick-actions → action chain rather than -asserting a key is present, and holds coverage over a **derived** set: every -identity action targeting a route known to return an unrecoverable secret must -reveal it. A fifth 2FA surface added later is held to the same rule with no edit -to the test. It also fails a `successMessage` declared alongside a -`resultDialog` — the toast is suppressed, so such a message is dead text, and in -this case it was the very string that made the defect look handled. - -The declaration-to-response join is measured over a booted stack in -`packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts`: the -declared paths are resolved against the live route's real response, because a -path that stops matching better-auth's response shape opens an **empty** dialog -and loses the codes just as thoroughly, while every declaration-shape assertion -stays green. - -**Also corrected, same area:** `sys_two_factor.backup_codes` was described as -"JSON-serialized backup recovery codes". It is JSON *before* encryption; what the -column stores is the ciphertext above. The description now says so, since the -whole reason the reveal must happen at generation time is that this column -cannot be read back. - -**Not addressed here:** mounting `sys_two_factor` into navigation is a larger -product-surface decision and is only raised, not taken; `#10700` (re-enrolment -rotating the TOTP secret while keeping `verified=1`) is a separate defect and -remains open. diff --git a/.changeset/two-factor-reenrollment-verified-flag.md b/.changeset/two-factor-reenrollment-verified-flag.md deleted file mode 100644 index 1860c2d6b6..0000000000 --- a/.changeset/two-factor-reenrollment-verified-flag.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -`POST /api/v1/auth/two-factor/enable` no longer leaves `sys_two_factor.verified` -describing the enrollment *before* the secret it stores. - -better-auth's enable handler computes the row it writes as -`verified: existingTwoFactor != null && existingTwoFactor.verified === true` -(measured on the installed 1.7.1, `dist/plugins/two-factor/index.mjs`), and -`sys_two_factor` declares `user_id` unique — so a second `enable` on an account -that already has a confirmed factor rewrites that one row with a brand-new -secret while inheriting the old enrollment's flag. The flag then said -"user-confirmed" about a secret nobody had ever confirmed, and the sign-in -challenge honoured it. - -The vendor already gates the challenge on that flag, in both places it matters: -`totp/index.mjs` refuses an unconfirmed factor with `TOTP_NOT_ENABLED` before -any lockout bookkeeping, and the post-sign-in hook offers `totp` among -`twoFactorMethods` only when the flag is not `false`. That gate is exactly what -a *first* enrollment relies on. Re-enrollment was the one path that slipped past -it — not because the gate was missing, but because the value handed to it was -inherited. So the fix restores the flag rather than adding a second gate: -after a successful `method: 'totp'` enable, `verified` is set to `false`, and -the freshly issued secret becomes live only once the caller proves possession of -it through `/two-factor/verify-totp`. - -This is a tightening. The request body, the response shape and the status are -unchanged, a first-time enrollment is unaffected (better-auth already wrote -`false` there), and a rotation is still reachable and still completes — it now -takes the same confirmation step a first enrollment takes. What changes is that -a secret the endpoint hands out is no longer accepted at the next sign-in until -it has been confirmed. Clients that re-enroll and then rely on the new -authenticator working immediately at sign-in must call `/two-factor/verify-totp` -with the live session first, which is the flow first-time enrollment already -uses. diff --git a/.changeset/two-factor-verify-echoes-installed-session.md b/.changeset/two-factor-verify-echoes-installed-session.md deleted file mode 100644 index 91536eddc6..0000000000 --- a/.changeset/two-factor-verify-echoes-installed-session.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): a 2FA verification echoes the session it INSTALLED, not the one it deleted (#10701) - -`POST /api/v1/auth/two-factor/verify-totp` answered `200` with two credentials -that disagreed. The `Set-Cookie` named the caller's new session; the JSON -`token` named a session row the same request had just deleted. - -The cause is upstream and mechanical. better-auth's `verifyTwoFactor` helper -resolves the caller's session once, at entry, and closes over it: - -```js -valid: async (ctx) => ctx.json({ token: session.session.token, ... }) -``` - -On the enrolment lane — a signed-in user confirming a new TOTP factor — the -route rotates that session before it answers: it mints a new session, installs -it with `setSessionCookie`, and deletes the caller's original session row. Only -then does it call `valid(ctx)`, which still holds the pre-rotation session and -echoes the token of the row that no longer exists. (Measured on the installed -better-auth 1.7.1: `dist/plugins/two-factor/verify-two-factor.mjs` and -`dist/plugins/two-factor/totp/index.mjs`.) - -Every other auth response in this repo echoes `token` as the unsigned token of -a live session, and `bearer()` accepts exactly that — presented without a -signature it signs the value itself before verifying. Measured on -`/sign-up/email`, the body's `token` resolves to the user as a bearer. So a -client following that contract after enrolling in 2FA stored a revoked token. - -That did not merely fail to authenticate. `bearer()`'s before-hook OVERWRITES -the request's session cookie with whatever the `Authorization` header carries, -so a request presenting the still-valid rotated cookie *and* the dead token -resolved to nobody. Measured before the fix, on one enrolment: the cookie alone -resolved to the user (`get-totp-uri` `200`); the echoed token alone resolved to -nobody (`get-session` `200` and empty, `get-totp-uri` `401`); and the two -together also resolved to nobody (`401`). Fail-closed — no privilege was -available to gain — but a legitimate user was locked out of a session they -still held, which is the point of the report. - -The echoed value is now read back out of the response's own session cookie, so -the `token` names the session the response actually installed. This restores -the contract rather than changing it: the field keeps its shape (the unsigned -session token) and its meaning ("the session you now hold"), and only the value -moves, from a deleted row to the live one. Shipped as `patch` for that reason — -no consumer expression has to be rewritten, and the previous value was not a -usable credential for anything, so nothing could have depended on it. - -The repair is keyed on the mechanism, not on the enrolment branch: it applies -only when the response staged a session cookie whose token differs from the one -being echoed. On the sign-in-challenge lane, where the route mints the session -it echoes, the two agree and this is a no-op — pinned, along with the cookie -lane, so that fixing the broken lane could not quietly rewrite the others. -`/two-factor/verify-otp` carries the byte-identical rotate-then-answer block and -is covered by the same guard; `/two-factor/verify-backup-code` does not rotate -and is unaffected. - -Resolver precedence is deliberately untouched. Having the resolver fall back to -the cookie when a bearer is unusable was the other repair direction named in the -report, and it was ruled out of scope: it would stop an invalid credential from -failing loud. Two pins hold that line — anonymous is still refused, and a bogus -bearer still overrides a valid cookie and still fails closed — so an attempt to -loosen the resolver later reddens this suite instead of passing it. diff --git a/.changeset/unpublished-object-deny-message.md b/.changeset/unpublished-object-deny-message.md deleted file mode 100644 index 0df05a452c..0000000000 --- a/.changeset/unpublished-object-deny-message.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -**Message change (no behaviour change):** a data-plane read against an object that exists only as an **unpublished draft** now says so, instead of reporting an internal security step (#10401). - -The refusal itself is unchanged and stays fail-closed (#3545): same `PermissionDeniedError`, same `PERMISSION_DENIED` code, same HTTP 403, same `[Security] Access denied` prefix — which is a **matcher** the transports read as "this is a 403", not house style. Nothing here widens access, and no access decision branches on the new information. - -What changed is what the refusal *says*. One sentence — "the security posture of object 'X' could not be resolved for operation 'find'" — covered two conditions with two different remedies, and described neither: because it named a *security* step, every reader took it for a permissions problem and went looking for a sharing rule to change. Measured downstream (objectstack-ai/cloud#1481): an end-user AI turn asked "how many customers do I have?" against a draft-only object, spent seven tool calls oscillating between a metadata plane that said the object existed and this refusal, then told the user the object was "missing its sharing/visibility setting" — confident, professional, and wrong. On a free plan that one turn also exhausted the daily allowance. - -The two conditions are now separated: - -- **The object has a `sys_metadata` draft and no published row** → *"object 'X' is not published — a draft declaration exists but no published one … Publish the object to make it queryable. This is NOT a permissions problem …"*. -- **The declaration genuinely cannot be read** (never declared, or a metadata-store outage) → the pre-existing clause **verbatim**, so any surface matching `the security posture of object 'X' could not be resolved for operation 'Y'` keeps matching, followed by the remedy and the same explicit statement that permissions are not the lever. - -Both sentences, and the operator log line beside them, are derived from one module (`unresolved-posture.ts`) shared with the explain engine's `object_crud` layer detail. Enforcement and explanation stating one refusal in two drifting wordings is the defect shape this closes, so the wording is a single source rather than two literals. - -The discriminator comes from a **best-effort** `sys_metadata` probe that runs only on the path already refusing, reads under a system context (so it cannot re-enter the middleware), and fails safe in one direction only: any failure — no `sys_metadata` in the deployment, an unprovisioned store, a driver error — reports the both-conditions wording rather than a claim. A posture that resolves never probes at all. diff --git a/.changeset/v11-to-v16-session-alias-datings.md b/.changeset/v11-to-v16-session-alias-datings.md deleted file mode 100644 index 895e163d63..0000000000 --- a/.changeset/v11-to-v16-session-alias-datings.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Retarget four `roles` → `positions` action-session provenance strings from "v11" to -"v16" — the release that actually shipped the `#3280` deprecate → `#3290` remove -session-alias precedent they cite (`content/docs/releases/v16.mdx` is the only release -page citing `#3290`). - -Text-only provenance correction, no schema shape or acceptance change: - -- `ActionSessionSchema`'s `positions` and `roles` `.describe()` strings - (`packages/spec/src/ui/action-params.zod.ts`) — regenerates - `content/docs/references/ui/action-params.mdx`. -- The `action-session-roles-to-positions` migration rationale - (`packages/spec/src/migrations/registry.ts` and - `packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts`) - — regenerates `spec-changes.json` and `docs/protocol-upgrade-guide.md`. diff --git a/.changeset/validate-zero-apps-row.md b/.changeset/validate-zero-apps-row.md deleted file mode 100644 index 2dd4667266..0000000000 --- a/.changeset/validate-zero-apps-row.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os validate`'s summary now prints `UI: 0 Apps` instead of dropping the whole -`UI:` row when a stack declares zero apps (#10504). - -Measured on the `blank` scaffold (`create-objectstack my-app -t blank`, -published 17.1.0, reproduced unchanged at this branch's head): a project with -no navigable UI and a project whose summary simply does not report on UI at -all printed identically — the `UI:` row was *absent*, not printed as `0`, so -a newcomer whose Console comes up empty had no way to tell which of the two -they were looking at. Both cases exited `0`. - -`printMetadataStats` (`packages/cli/src/utils/format.ts`, shared by -`os validate`, `os info` and `os compile`) gains an opt-in `zeroFallback` per -summary section — the one item to force-print at `0` instead of dropping the -whole row when every item in that section is zero. It is set only on `UI` -(`Apps`), matching the triage ruling on #10504: the `blank`/`crud`/`full` -templates all ship zero apps deliberately, so a *warning* would fire on every -clean scaffold's first run. This is a legibility fix only — nothing about -what `validate` accepts, rejects, or exits with has changed, and `Data:`, -`Logic:`, `Security:` keep their existing drop-at-zero behavior (tracked -separately in #10952). - -The `--json` path already reported `"apps": 0` explicitly at zero — no change -needed there; a separate, unrelated `--json` warnings gap is tracked in -#10953. diff --git a/.changeset/view-door-list-view-field-rules.md b/.changeset/view-door-list-view-field-rules.md deleted file mode 100644 index 76be7c7877..0000000000 --- a/.changeset/view-door-list-view-field-rules.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -feat(lint): the two list-view field rules reach a standalone list view at the runtime publish gate — `view` writes are now judged by `validateSearchableFields` and `validateSortableFields` (#9313) - -An `active`-state `view` save through `saveMetaItem` (Studio, REST `/meta` item -CRUD, an MCP/AI author) is now refused with the existing 422 `invalid_metadata` -envelope when its list view declares a `sort` or `searchableFields` entry the -bound object cannot honor — an unknown field name, a virtual (`formula`) sort -target with no stored column to ORDER BY, or a search narrowing the #4254 -ingress gate would refuse on every toolbar search. Both rules already gated -`os validate` / `os build` / `os lint`; the runtime door — the only door a -Studio tenant or an MCP/AI author has — ran neither, and an author writing the -exact declaration these rules exist to refuse got it accepted. - -Two halves, because either alone is a silent no-op: the reference-integrity -suite's registry entry gains `runtimeTypes: ['view']`, and both rules' metadata -walks gain the SELF rung — a `views[]` entry that IS a flattened standalone -list overlay (`ViewMetadataSchema`'s list-overlay member: `viewKind: 'list'`, -no nested `config`), the shape a standalone list view takes on the wire and the -shape the gate snapshots as `views: [item]`. - -The suite dispatches per member on this door: a `view` snapshot reaches exactly -the two list-view field rules (`ReferenceIntegrityRule.runtimeTypes`, default -`['flow']`), never the members whose resolution universe the per-write snapshot -does not carry — `validateActionNameRefs` resolving against `stack.actions` -would otherwise refuse legitimate view writes. CLI behaviour is unchanged (the -commands run the full suite as before); `flow` snapshots keep every member. -Measured before crossing: 0 refusals and 0 advisories over 50 shipped -view-door bodies (11 containers + 39 console-shaped personalization overlays, -`sort[].id` decorations included) across four authoring lineages — a lower -bound, as every authored corpus is. Draft saves are untouched (D1), stored rows -keep being served (ADR-0087 asymmetry), and -`OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to a loud log. diff --git a/.changeset/view-door-viewitem-record-config-rung.md b/.changeset/view-door-viewitem-record-config-rung.md deleted file mode 100644 index 2ee1343555..0000000000 --- a/.changeset/view-door-viewitem-record-config-rung.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -feat(lint): a standalone ViewItem record's nested `config.sort` / `config.searchableFields` reach the runtime publish gate (#10001) - -An `active`-state `view` save through `saveMetaItem` (Studio, REST `/meta` -item CRUD, an MCP/AI author) whose body is a standalone ViewItem RECORD — -`ViewMetadataSchema`'s member 1, `{ name, object, viewKind: 'list', config }`, -the shape a Studio-saved view takes and the shape objectui's `updateView` -round-trips on every pin/reorder toggle — is now refused with the existing -422 `invalid_metadata` envelope when its `config.sort` / `config.searchableFields` -declares a field the bound object cannot honor: an unknown name, a virtual -(`formula`) sort target with no stored column to ORDER BY, or a search -narrowing the #4254 ingress gate would refuse on every toolbar search. #9313 -closed the same gap for the flattened list overlay, one union member over; -the record's declarations live one level down, inside `config`, and were -judged by neither list-view field rule — so a record write carrying -`config.sort: [{ field: '' }]` published in silence and answered -`400 INVALID_SORT` (#6994/#7095) on the view's first fetch, every load. - -Walk-only, by design: #9313 already widened the reference-integrity suite -entry and exactly these two members onto `view` writes, so this change adds -the RECORD rung to both twin walks — recognised by the wire union's own -member discrimination (`viewKind: 'list'` AND a record-shaped `config`; the -flattened-overlay rung keeps its `no nested config` guard, a strict container -carries neither key, and a `form` record has no list-field surface), judged -against `listViewObject(config) ?? record.object` at path -`views[i].config.sort[…]` / `views[i].config.searchableFields[…]`. The -per-member granularity split is unchanged: no further suite member crosses -onto `view`. Measured before shipping: 0 refusals and 0 advisories over 39 -record-shaped console round-trip bodies (one per shipped list surface, -`config.sort[].id` decorations and `isPinned`/`sortOrder` riding along, the -shape `saveMetaItem` really stores) across the four shipped stacks — a lower -bound, as every authored corpus is. Draft saves are untouched (D1), stored -rows keep being served (ADR-0087 asymmetry), and -`OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to a loud log. diff --git a/.changeset/views-lint-posture-one-voice.md b/.changeset/views-lint-posture-one-voice.md deleted file mode 100644 index fa7ea38b60..0000000000 --- a/.changeset/views-lint-posture-one-voice.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): read the unknown-key lint's posture from the schema the parse applies, and report each record exactly once (#10039) - -An otherwise valid view container carrying one undeclared key produced two -contradicting messages from `defineStack`: - -``` -WARN: defineStack: views.v1.bogusViewKey: 'bogusViewKey' is not a declared view - key, so its value is dropped at load. -THREW: ✗ views.0: Unrecognized key(s) on this view container: `bogusViewKey`. … -``` - -The warning promises a silent drop — the view loads, minus one key — and the -refusal one step later says nothing loads at all. An author who reads the -warning and stops there draws the opposite conclusion from the truth, and a -warn channel that is sometimes really an error trains readers to discount it. - -**Root cause: the lint read posture off a different schema than the parse.** -`lintUnknownAuthoringKeys` took each collection's unknown-key posture from -`getMetadataTypeSchema(type)`. That registry answers a different question — it -names the schema for a *persisted metadata body* of that type. What -`defineStack` applies to a *stack collection entry* is the element schema in -`ObjectStackDefinitionSchema`'s own shape, and for `view` the two are not the -same object: - -- `getMetadataTypeSchema('view')` → `ViewMetadataSchema`, a strip-mode **union** - over the three persisted runtime shapes; -- `ObjectStackDefinitionSchema.shape.views` → `z.array(ViewSchema)`, and - `ViewSchema` is the `.strict()` defineView **container**. - -`lintUnknownStackKeys` has always avoided exactly this at the top level, and its -own source says why: a schema that rejects loudly must make the lint go quiet -"rather than become a second, possibly disagreeing voice". The per-collection -walker read the same rule off the wrong schema. - -The posture source is now the stack schema's own slot for the collection. -Measured across all 29 collections `PLURAL_TO_SINGULAR` names, the registry and -the stack slot agree everywhere except: - -| collection | type registry | stack slot | effect | -| --- | --- | --- | --- | -| `views` | `strip` / 91 keys | `strict` / 15 keys | **leaves the lintable set** | -| `themes`, `analyticsCubes` | unregistered | `strict` | skipped either way | - -So `connectors` is the honest remainder — it genuinely warns and drops — and no -other collection changes. - -**Second defect, same walk: every finding on a union root was emitted twice.** -`lintUnknownKeysAgainstSchema` reported the root record itself and *also* handed -that same record to `descend`, whose object arm skipped `depth === 0` ("already -reported by the caller") while its union arm had no such guard. `view` was the -only union root in the wild, so `defineStack` never showed it — the warn-once -set in `warnUnknownAuthoringKeys` absorbed the second copy — while every other -consumer of the exported walker saw both. The root report now lives in `descend` -alone, so each record is reported by exactly one place. That also closes a -latent third copy: a discriminated-union root whose branch the author *did* pick -was reported once against the merged key set and again against the branch's, and -is now reported once, against the branch — the narrower and more accurate set. - -**Nothing about what `defineStack` accepts or rejects changes.** The parse is -untouched; only which of the two existing voices speaks. - -### API change - -`lintUnknownAuthoringKeys` and `listLintableAuthoringCollections` now take -`ObjectStackDefinitionSchema` as a **required** parameter, injected the same way -and for the same reason `lintUnknownStackKeys` already required it — -`stack.zod.ts` imports this module, so importing the schema back would close a -cycle. Required rather than optional deliberately: an omitted argument falling -back to the type registry would silently reinstate the bug, which is the -silent-loss shape this whole rule family exists to report. Every in-repo call -site (`defineStack`, `os validate`, `os compile`) already had the schema in hand -for the sibling call on the adjacent line. - -Marked `minor` rather than `patch` because of that signature, not because of any -behavioural widening — the fix itself only makes one voice go quiet. diff --git a/.changeset/watch-since-replays-from-history.md b/.changeset/watch-since-replays-from-history.md deleted file mode 100644 index 7338b76d67..0000000000 --- a/.changeset/watch-since-replays-from-history.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor -"@objectstack/metadata-core": minor ---- - -`MetadataRepository.watch()` — a numeric `since` now replays from the durable -log, and what a bare `watch(filter)` owes is written into the contract. - -**`SysMetadataRepository.watch(filter, since)`** read `since` only as a drop -filter on live events, so an event that had already committed was unreachable -through `watch()` however low `since` was set — even though the repository holds -a durable per-org `event_seq` log in `sys_metadata_history` and already reads it -org-wide in `nextEventSeq()`. Invariant 6 of the repository contract -("`watch(_, since)` MUST replay all events with `seq > since` before delivering -live events") was therefore unimplemented in the repository backing every -production metadata write. It now replays through that same query, using the -row-to-event mapping extracted out of `history()`. The live listener is -registered before the durable read is issued and a set of delivered `seq` -numbers closes the replay-to-live seam, so an event committing mid-read arrives -exactly once; a failed durable read is raised to the consumer rather than -degraded into a silent live-only tail. - -**No behaviour change for a `watch()` with no `since`** — deliberately. Both -in-repo production subscribers (`MetadataManager.startRepositoryWatch()` and -`MetadataCache.start()`) attach that way, and replaying for them would push an -org's entire history through cache invalidation and HMR as "this just changed" -at every attach. - -**Contract text (`@objectstack/metadata-core`, `repository.ts`).** Invariant 6 -now states its own boundary: a `watch()` with no `since` is owed **live events -only**; an implementation MAY additionally deliver events that had already -committed, but a caller MUST NOT rely on it, and a caller that needs the -already-committed prefix passes a numeric `since` or reads `history()`. That -half was previously unwritten and load-bearing — "no `since` replays -everything" existed only as `InMemoryRepository`'s implementation, and the -shared contract suite silently depended on it. - -**If you run `runRepositoryContractTests` from -`@objectstack/metadata-core/testing` against your own implementation**, one -clause changed shape. `watch filters by type and name` (which wrote twice, then -opened a watch and expected the match back) is replaced by `watch filters by -type and name — over the live stream`, which opens the subscription first and -writes after. FROM: an implementation passed by replaying its whole matching log -on a bare `watch(filter)`. TO: it passes by delivering, and filtering, the -events that commit after the subscription is established. An implementation that -replays as well still passes — the new clause asserts the floor, not the -maximum. If yours only replayed and never delivered live events, it was relying -on unspecified behaviour and now needs a live path. diff --git a/apps/docs/CHANGELOG.md b/apps/docs/CHANGELOG.md index cc884ea343..20b01dc4f8 100644 --- a/apps/docs/CHANGELOG.md +++ b/apps/docs/CHANGELOG.md @@ -1,5 +1,23 @@ # @objectstack/docs +## 4.2.2 + +### Patch Changes + +- 72d75eb: docs site: drop `output: 'standalone'` so the production build stops failing + + The production build of the docs site died at the end of `next build` with + `ENOENT: no such file or directory, open '.../apps/docs/.next/next-server.js.nft.json'`, + so nothing merged to `main` reached the site. + + That file is opened by the standalone packer (`writeStandaloneDirectory` -> + `copyTracedFiles`), which Next calls **only** when `output === 'standalone'`. + Nothing in this repo consumes `.next/standalone` — no Dockerfile, workflow, + script or config references it, and `docker/Dockerfile` does not build + `apps/docs` at all — and Vercel does its own serverless packaging. The setting + served no consumer and was the sole reason that read happened, so removing it + removes the only code path that can raise this error. + ## 4.2.1 ### Patch Changes diff --git a/apps/docs/package.json b/apps/docs/package.json index 746a3cccf5..6928d4d639 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/docs", - "version": "4.2.1", + "version": "4.2.2", "private": true, "description": "ObjectStack Protocol Documentation Site", "license": "Apache-2.0", diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index e165cfc70c..36d3121cf5 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -76,7 +76,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.1.0 + ghcr.io/objectstack-ai/objectstack:17.2.0 ``` (`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -94,7 +94,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.1.0 + ghcr.io/objectstack-ai/objectstack:17.2.0 ``` Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly @@ -145,7 +145,7 @@ COPY . . RUN npx os build # → dist/objectstack.json # ── Runtime: the official ObjectStack runtime image ────────────────── -FROM ghcr.io/objectstack-ai/objectstack:17.1.0 +FROM ghcr.io/objectstack-ai/objectstack:17.2.0 COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json ``` @@ -163,7 +163,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.1.0 +RUN npm install -g @objectstack/cli@17.2.0 WORKDIR /srv/app RUN chown node:node /srv/app diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 42df9410c1..510d215b40 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.1.0 +image: ghcr.io/objectstack-ai/objectstack:17.2.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 f1855f4de2..041d537695 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.1.0 +FROM ghcr.io/objectstack-ai/objectstack:17.2.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.1.0 + ghcr.io/objectstack-ai/objectstack:17.2.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.1.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.2.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 1d566502dc..d1c6e7fdd8 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/example-crm +## 4.0.94 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [128684d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [d806081] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b39785] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [047ac86] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [13a6cb4] +- Updated dependencies [9f483d9] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/runtime@17.2.0 + ## 4.0.93 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index c7e63345da..36d9602e52 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.93", + "version": "4.0.94", "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 9a1e94c79d..3d18cc411a 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,98 @@ # @objectstack/example-showcase +## 0.3.16 + +### Patch Changes + +- 6cca75c: Fix the showcase react pages' `useAdapter()` query contract, and pin it (#10288) + + `renewals-pipeline` passed `top: 500` and `crm-workbench` passed `limit: 200` to + `adapter.find`. Neither is a query option: `QueryParams` declares only `$`-prefixed keys + and `ObjectStackAdapter.convertQueryParams` copies exactly those, so the key reached no + branch and was dropped with no error. The consequence is the opposite of a truncated + read — the GET list route has **no default page size**, so an absent `top` returns the + ENTIRE match set, and the cap the author wrote never happened. + + The same effect then read its rows off `.records`. `find()` resolves to a normalized + `QueryResult` (`data` + `total`), never the REST envelope, so `pr.records` was + `undefined` on every call and the renewals KPI strip sat at `0 / 0 / 0` while the + `` beside it showed the same rows correctly. Measured on a 640-row account with + the real page source driven against a contract-faithful adapter double: before, + `$top` arrives `undefined` and the strip reads `{projects: 0, invoices: 0, openInvoices: 0}`; + after, the cap is applied and it reads `{projects: 640, invoices: 640, openInvoices: 100, + capped: true}`. + + Applying the cap is only half a fix, because `data.length` under a `$top` is exactly the + silently-capped count the card was filed about — so both pages now count the envelope's + `total` (the server's real count over the same `$filter` whenever a limit was applied). + The one number a cap genuinely bounds, "Open AR", is a per-row verdict over the fetched + window; it renders as `100+` rather than passing for a total. + + `test/react-page-adapter-query-contract.test.ts` executes the page's real rollup effect + and then sweeps every `kind:'react'` page in the app for both contracts, with an + extraction control, a census control, and a positive control on the scanners. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [128684d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [e634ecf] +- Updated dependencies [95437e7] +- Updated dependencies [46cfa5b] +- Updated dependencies [f76fe42] +- Updated dependencies [4257e4e] +- Updated dependencies [3e26359] +- Updated dependencies [d806081] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b39785] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [e222a53] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [047ac86] +- Updated dependencies [6d5c4fa] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [13a6cb4] +- Updated dependencies [9f483d9] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [f59035c] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/driver-sql@17.2.0 + - @objectstack/runtime@17.2.0 + - @objectstack/service-datasource@17.2.0 + - @objectstack/cloud-connection@17.2.0 + - @objectstack/connector-openapi@17.2.0 + - @objectstack/connector-rest@17.2.0 + - @objectstack/connector-slack@17.2.0 + - @objectstack/connector-mcp@17.2.0 + ## 0.3.15 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 4920fbe26c..14e8337712 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.15", + "version": "0.3.16", "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 237fda1fd0..6310248c96 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,74 @@ # @objectstack/example-todo +## 4.0.94 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [128684d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [7bf3fb7] +- Updated dependencies [02b3b07] +- Updated dependencies [2570ab0] +- Updated dependencies [95437e7] +- Updated dependencies [d806081] +- Updated dependencies [d23e3a0] +- Updated dependencies [f3a8134] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b39785] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [e222a53] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [a037f7c] +- Updated dependencies [047ac86] +- Updated dependencies [6d5c4fa] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [13a6cb4] +- Updated dependencies [9f483d9] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [502dc6f] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/client@17.2.0 + - @objectstack/runtime@17.2.0 + - @objectstack/service-knowledge@17.2.0 + - @objectstack/metadata@17.2.0 + - @objectstack/driver-sqlite-wasm@17.2.0 + - @objectstack/mcp@17.2.0 + - @objectstack/knowledge-memory@17.2.0 + ## 4.0.93 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index e2f14a1e5e..f5efb426c6 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.93", + "version": "4.0.94", "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 d3b3f542a8..b4f1c2c4a8 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/example-embed-objectql +## 0.0.34 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [2570ab0] +- Updated dependencies [95437e7] +- Updated dependencies [d23e3a0] +- Updated dependencies [f3a8134] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/driver-memory@17.2.0 + ## 0.0.33 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index fa9e19db11..7aeb7d194e 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.33", + "version": "0.0.34", "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 09686fc1e4..8e1bed7227 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,59 @@ # @objectstack/hono +## 17.2.0 + +### Patch Changes + +- 145ba75: docs: repair the dead repo-relative targets in four published READMEs (#10813) + + A published README ships inside the npm tarball, so a dead relative link in one + is shipped to every reader who installs the package. Nine of them were measured + across four packages, and nothing read them: `check:published-readme-links` + checked docs-site URLs, `check:published-readme-exports` checked fenced import + lines, and the lychee lane never sees `packages/**/README.md`. + + `@objectstack/runtime` carried six dead targets. Each was traced to where the + content actually went rather than deleted: + + - `MINI_KERNEL_GUIDE.md`, `MINI_KERNEL_ARCHITECTURE.md` and + `MINI_KERNEL_IMPLEMENTATION.md` were deleted from the repo root in January as + "redundant markdown files" (d709ecce68 — 14 files, 5051 deletions, nothing + added). The kernel reference they described is the docs site now, so the + Documentation section is the same footer eight sibling READMEs already use. + - `examples/host/` was renamed to `examples/app-host`, then `apps/server`, then + `apps/objectos`, and finally split out to `objectstack-ai/cloud`. In-repo, an + HTTP server in front of the runtime is `@objectstack/plugin-hono-server` plus + the `@objectstack/hono` adapter, so the bullet points there. + - `examples/msw-react-crud/` became `examples/app-react-crud`, then + `apps/console`, and now ships as `@object-ui/console` from another repo. + - `test-mini-kernel.ts` was a root-level scratch script; this package's suite is + 179 test files under `src/`. + - The section also ended on a truncated bullet with an unterminated backtick + (`` - `packages/runtime/src/ ``), which is now a real pointer to that suite. + + The other three packages: `@objectstack/hono` and `@objectstack/service-package` + still spelled `@objectstack/driver-sql` as `../../plugins/driver-sql`, stale + since the driver moved to `packages/drivers/` (#5618). `@objectstack/plugin-security` + and `@objectstack/service-package` linked three packages that are in no directory + of this repo (`plugin-org-scoping`, `service-tenant`, `service-marketplace`); + those links are dropped and the names kept as code spans, which is the spelling + those same files already use for a package they cannot point at in-tree. Whether + those three packages exist at all is a separate question, filed separately. +- Updated dependencies [128684d] +- Updated dependencies [d806081] +- Updated dependencies [b03a880] +- Updated dependencies [914c413] +- Updated dependencies [5b39785] +- Updated dependencies [67630c4] +- Updated dependencies [047ac86] +- Updated dependencies [a79bd35] +- Updated dependencies [145ba75] +- Updated dependencies [13a6cb4] +- Updated dependencies [9f483d9] + - @objectstack/runtime@17.2.0 + - @objectstack/plugin-hono-server@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 1890a357e7..a3915889a1 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.1.0", + "version": "17.2.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 9d9f9877aa..a64b8c00f0 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/account +## 17.2.0 + +### Patch Changes + +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index d187c8f90b..ea2bc77c7f 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.1.0", + "version": "17.2.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 9ceef3ec8c..97fd9386e7 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,68 @@ # @objectstack/setup +## 17.2.0 + +### Patch Changes + +- 02b3b07: Point every runtime-emitted documentation URL at the canonical host, and retarget the + metadata-protection `docsUrl` at a page that actually exists. + + Two defects, one string. The host half: `docs.objectstack.ai` is an alias that redirects + to `https://objectstack.ai` path-preservingly, so nothing here was a broken link — it was + the unratified spelling sitting in the places a user copies from. The CLI's spec-version + advisory, the Setup and Studio in-app overview pages (English and Chinese alike), and a + showcase demo action now all name the canonical host. + + The path half is the real fix. All 29 `protection.docsUrl` values on the platform's + system objects and apps pointed at `/adr/0010-metadata-protection`, and `/adr/...` is not + a route on any host: the docs site mounts `content/docs` under `/docs`, `docs/adr/` is + not published, and no redirect source lives outside the `/docs` space. The slug was wrong + too — the record is `0010-metadata-protection-model.md`. Studio renders this URL as a + link in the lock banner, so an operator asking why an item is locked was being sent + nowhere. They now point at `https://objectstack.ai/docs/references/shared/protection`, + the published reference for the very schema that carries the field. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index 8c13355071..00bdf16fcb 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.1.0", + "version": "17.2.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 5e4965a622..06a7b3ce17 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,85 @@ # @objectstack/studio +## 17.2.0 + +### Patch Changes + +- 02b3b07: Point every runtime-emitted documentation URL at the canonical host, and retarget the + metadata-protection `docsUrl` at a page that actually exists. + + Two defects, one string. The host half: `docs.objectstack.ai` is an alias that redirects + to `https://objectstack.ai` path-preservingly, so nothing here was a broken link — it was + the unratified spelling sitting in the places a user copies from. The CLI's spec-version + advisory, the Setup and Studio in-app overview pages (English and Chinese alike), and a + showcase demo action now all name the canonical host. + + The path half is the real fix. All 29 `protection.docsUrl` values on the platform's + system objects and apps pointed at `/adr/0010-metadata-protection`, and `/adr/...` is not + a route on any host: the docs site mounts `content/docs` under `/docs`, `docs/adr/` is + not published, and no redirect source lives outside the `/docs` space. The slug was wrong + too — the record is `0010-metadata-protection-model.md`. Studio renders this URL as a + link in the lock banner, so an operator asking why an item is locked was being sent + nowhere. They now point at `https://objectstack.ai/docs/references/shared/protection`, + the published reference for the very schema that carries the field. +- 28ad84a: Corrected the package header's transitional NOTE, which had drifted stale in + two opposite directions. + + Verified directly against the boot path at head, not against the header's + own narrative: `plugin-dev`'s `DevPlugin` boot loop and `cli`'s `os serve` + app-package loop both deliberately register only `@objectstack/setup` and + `@objectstack/account` — Studio's exclusion is a settled decision, not a + pending follow-up, because the console ships its own dedicated Studio + surface. `plugin-auth`'s manifest has likewise already stopped registering + Studio (ADR-0048); that removal is done, not "landing separately" as the + stale header implied. + + The header's other transitional claim is still accurate and was left + unchanged: `STUDIO_APP` is still imported from + `@objectstack/platform-objects/apps` rather than defined in this package. + + Comment-only: no export, behaviour, or boot path changed. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index 414b2f34f9..c8573cbc5c 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.1.0", + "version": "17.2.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 5bc67b1a63..120aee507c 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,1126 @@ # @objectstack/cli +## 17.2.0 + +### Minor Changes + +- 2866d5f: `os migrate duplicates` now reports the rows blocking the three `kernel:ready` + NULL-safe index tightenings, and the three migrations' conflict messages point + there instead of at `os migrate plan` (#8725). + + **The gap.** Three migrations replace a declared UNIQUE index with the NULL-safe + — and sometimes active-rows-only — form it was always meant to have, at + `kernel:ready` on a serving boot: + + | table | index(es) | migration | + | --- | --- | --- | + | `sys_metadata` | overlay `active` + `draft` | `ensureMetadataOverlayIndexes` | + | `sys_view_definition` | `idx_sys_view_def_active` | `ensureViewDefinitionActiveIndex` | + | `sys_setting` | the declared row identity | `ensureSysSettingIdentityIndex` | + + Each is a tightening, so rows an installation already holds can block it. The + migration then refuses — previous index kept, no row touched, boot continues — + and reports at `error` on the boot channel. That channel was the only one: + these indexes are invisible to `os migrate plan` **by construction**, twice + over. After the tightening, `isRuntimeManagedIndex` excludes the index (without + that exclusion a boot would propose rebuilding away the guarantee it had just + created); before it, each migration deliberately reuses the *declared* index's + name, so the reconciler's name-matched slot reads as filled whichever physical + form is really there. Measured with a matched control — one database carrying + the same duplicate damage under a declared index and under + `sys_view_definition`'s runtime one — `plan` named the declared one in full and + said nothing whatsoever about the runtime one. + + **What is new.** The report gains a `runtimeIndexPreflight` section, one entry + per index, each `blocked` (with every colliding key group and its row count), + `clear`, `table-absent` (`sys_setting` arrives with the optional settings + service) or `unreadable` (with the driver's own message), plus + `summary.runtimeIndexesBlocked` and `summary.runtimeIndexBlockingRows`. + `reportVersion` moves `1` → `2`. Every `1` field keeps its name, shape and + meaning; the bump says there is more in the document, for consumers that + validate it strictly. + + The probes are the migrations' own duplicate-listing statements — + `@objectstack/metadata-protocol` exports `collectRuntimeIndexPreflight` and + `runtimeIndexProbes`, which read those builders rather than restating the keys, + so the pre-flight and the boot report cannot describe different duplicates. On + MySQL the `sys_setting` probe uses the migration's MySQL spelling, where the + bare form is `ERROR 1064` on the reserved word `key`. + + **The referral, repointed rather than deleted** (maintainer ruling, 2026-08-22). + All three conflict messages told the operator to "run `os migrate plan`" as an + alternative way to list the blocking rows, and that instruction was false: they + now name `os migrate duplicates`, which answers it. The six doc comments that + state the same referral as part of the ADR-0120 D4 disposition are updated with + them. + + **Nothing about a migration's behaviour changes.** No tightening is armed, + deferred or altered, and `os migrate plan`'s drift contract is untouched. The + pre-flight only makes the refusal's evidence readable one command before the + restart — from a command that boots read-only and writes nothing, which is + pinned logically (schema plus every row, ordered) rather than by a file hash: a + raw hash over a SQLite file moves on any read-write open and would accuse this + command of mutating the install it exists to describe. +- 1c3a46f: feat(cli): `os g skill NAME` scaffolds an AI skill, and writes it as `NAME.skill.ts` so the loader can find it (#11025) + + Completes the second half of the ADR-0063 Option A ruling whose first half + retired `os g agent` (#10359). That retirement left authors told to write + `src/skills/NAME.skill.ts` by hand because no scaffolder existed; this adds it, + and `os g agent`'s refusal, the CLI README and the CLI docs now name the + command instead of apologising for its absence. + + The filename is the point, not a detail. `DEFAULT_METADATA_TYPE_REGISTRY` + declares `skill`'s file convention as `*.skill.ts` / `*.skill.yml`, while this + harness has always written `NAME.ts`. `skill` is `allowRuntimeCreate: true` — + a type the platform expects to discover — so a scaffold matching no pattern + would type-check, validate and publish with nothing anywhere reporting that it + had been skipped: the silent-strip shape the `agent` retirement closed, + re-entering through the scaffolder that replaced it. `skill` therefore + overrides the harness filename through a new per-generator hook, and the + barrel re-export is derived from the file that was actually written rather + than rebuilt from the metadata name. + + **The other six generators are unchanged** and still write `NAME.ts` with a + `'./NAME'` barrel line, pinned by a control assertion in the new test. + Converging the whole scaffolder on the registry's `NAME.TYPE.ts` convention — + the shape the example apps already author in — moves every generator's output + plus the docs and examples that show it, and is deliberately left as its own + decision. + + Three authoring choices the template makes, each written into the generated + file so the next author inherits the reasoning and not just the value: + `tools: []`, because under ADR-0064 an agent's tool set is the union of its + skills' tools with no global fall-through, so an empty list grants nothing + while a placeholder name would resolve to nothing and be reported by + `os validate` as `ai-skill-tool-unresolved`; `surface: 'ask'` written out + rather than left to the schema default, because the affinity it declares is + enforced at load and a default taken in silence is invisible to whoever edits + the file next; and `defineSkill` rather than a bare typed literal, so the + object is parsed at module load. The template is **not** copied from + `SkillSchema`'s or `defineSkill`'s `@example` blocks — both pass + `triggerPhrases`, a retired-key tombstone that rejects on parse (#11026). +- 15b63e8: fix(cli): **BREAKING** — the `agent` generator is retired, and `os g agent` now says why and points at skills (ADR-0063 §2, #10359) + + **⛔ If a script, a Makefile or a CI step in your project runs `os g agent`, it + will now exit 1.** That is the intended outcome and the one way this change can + interrupt you: the command is gone, deliberately, and the failure is how you + find out. Everything it used to produce was already being discarded — read on. + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` (pre-1.0 semantics under lockstep versioning — see + `scripts/check-changeset-no-major.mjs`). + + **What the command actually did.** `os g agent ` scaffolded a typed + `AI.Agent` into `src/agents/`. Per ADR-0063 §2 (which reversed ADR-0040 §3) the + kernel ships exactly **two** agents — `ask` and `build` — bound by surface and + never picked from a roster, and the runtime catalog **filters out every + non-platform agent record**. So the scaffolded file parsed, passed + `os validate`, published without complaint, and then never appeared anywhere. + No error at any step. An author who followed the documented example got a file, + a green validate, a successful publish, and nothing to show for it. + + **Why the roster entry was not simply deleted.** A deleted type falls through to + `Unknown type: agent` plus a list of what is left, which tells the author their + spelling is not on the list and invites them to hunt for the right spelling of + something that no longer exists — the same silence, one step earlier. `agent` is + now a **retirement ledger entry** instead, and the refusal carries both halves: + the decision that withdrew the surface, and the surface to author in its place. + What you see: + + ``` + ✗ `os g agent` was retired — agents are platform-internal (ADR-0063 §2). + + The kernel ships exactly two agents, `ask` and `build`, bound by surface. + An agent you author still parses and still publishes — and the runtime + catalog then filters it out, so it never appears and nothing tells you. + This command scaffolded exactly that file, so it is retired, not repaired. + + Author a SKILL instead. Skills (plus tools / MCP) are the third-party + extension primitive ADR-0063 names — the live surface this one was not. + + Scaffold one — the file lands where the loader looks for it: + + os g skill -> src/skills/.skill.ts + + It writes a `defineSkill` template with `surface` and `tools` filled in + and explained, ready to edit. + + Docs: https://objectstack.ai/docs/ai/agents + ``` + + **The call is not mechanically rewritable.** A skill is a different artifact + with a different schema, not a renamed agent, so delete the `os g agent` call + rather than renaming it — then run `os g skill` and fill the template in. (This + message originally said no scaffolder existed; `os g skill` shipped in the same + release, so the text above is what the command prints today.) + + `agent` leaves the generator roster, which is `object`, `view`, `action`, + `flow`, `dashboard`, `app` — plus `skill`, added in this same release. The docs + that advertised the retired one — the `os g agent support` + example, the `agent` / `src/agents/` row of the Available types table, and + `os g agent sales-assistant` in the Typical Workflow block — are gone from + `content/docs/deployment/cli.mdx`, which carries the retirement note instead; + `packages/cli/README.md`'s type roster follows. The quick-start project-layout + map, which listed `src/agents` as the directory an app author writes AI metadata + into, now names `src/skills`. + + +- 7940de5: **BREAKING** Retire the `@capabilities` hook-body directive comment (#10917). + + `os build` no longer reads a `@capabilities` line out of a handler body, and the + docs no longer teach one. A body's capabilities are either inferred from its + source, or declared as data in `body.capabilities` on the hook or action — the + route that is measured to survive the build, and now the only way to name a token + the code itself does not reveal. + + **Nothing an author wrote has to change.** The directive was read off the + handler's stringified source, and `loadConfig` runs every config through + `bundle-require` and esbuild, which strips `//` line comments before the handler + is ever a runtime function. Measured on all four ordinary authoring shapes — + `objectstack.config.ts`, `.js`, `.mjs`, and a handler imported from a local + module — it reached the extractor from none of them: the build exited 0, printed + nothing, and shipped the inferred capabilities alone. A config that still carries + the comment builds to the same artifact before and after this release, so + deleting it is optional and changes no output. What is gone is the wrong + convention it taught, silently, to everyone who copied it out of the docs — a + handler asking for more than inference derived was refused by the sandbox at + runtime, far from the cause. + + Ruled under ADR-0049 enforce-or-remove: a capability declaration nothing parses is + a false promise, and this one could not even be typed wrongly-but-visibly, because + every authoring path deleted it before the extractor looked. + + The retirement kit: the override branch in `extract-hook-body.ts` and the two unit + tests that pinned it are gone; the extractor header and + `content/docs/automation/hook-bodies.mdx` record the removal instead of the + spelling; the `os build`-level test keeps pinning both halves — the comment + contributing nothing, and `body.capabilities` surviving — and a unit pin standing + on the one shape where the override ever fired now asserts it grants nothing. + + + +### Patch Changes + +- 02b3b07: Point every runtime-emitted documentation URL at the canonical host, and retarget the + metadata-protection `docsUrl` at a page that actually exists. + + Two defects, one string. The host half: `docs.objectstack.ai` is an alias that redirects + to `https://objectstack.ai` path-preservingly, so nothing here was a broken link — it was + the unratified spelling sitting in the places a user copies from. The CLI's spec-version + advisory, the Setup and Studio in-app overview pages (English and Chinese alike), and a + showcase demo action now all name the canonical host. + + The path half is the real fix. All 29 `protection.docsUrl` values on the platform's + system objects and apps pointed at `/adr/0010-metadata-protection`, and `/adr/...` is not + a route on any host: the docs site mounts `content/docs` under `/docs`, `docs/adr/` is + not published, and no redirect source lives outside the `/docs` space. The slug was wrong + too — the record is `0010-metadata-protection-model.md`. Studio renders this URL as a + link in the lock banner, so an operator asking why an item is locked was being sent + nowhere. They now point at `https://objectstack.ai/docs/references/shared/protection`, + the published reference for the very schema that carries the field. +- 5a90c56: Stop claiming the `os serve` capability loop loads a "host copy first" (#10909). + Two module-header comments in `packages/cli/src/commands/serve.ts` — above the + `@objectstack/plugin-email` and `@objectstack/service-sms` imports — described + the capability loop (`Serve.CAPABILITY_PROVIDERS`, the `for (const cap of + requires)` block) as resolving `EmailServicePlugin`/`SmsServicePlugin` "host + copy first". Measured at head, the loop does a bare `await import(spec.pkg)` / + `await import(ex.pkg)` — no `importFromHost` in either path — which Node ESM + resolves against **this CLI's own** realpath, so the CLI's bundled copy always + wins; the host app's copy is never consulted. The comments described a + behaviour the code does not have. + + The corrected comments also name the contrast the file now actually contains: + `Serve.importConfigPlugin` (the served app's own `plugins: [...]` entries) IS + host-anchored — an app-declared package wins there — while the capability + loop is not. Making that split legible is the point of the fix, so the next + reader does not assume one resolution rule governs the whole file. + + Comment-only: no runtime path, resolution order, or accepted specifier changes. + All 21 `CAPABILITY_PROVIDERS` packages remain CLI-declared, so bare resolution + still finds every one of them today — this only corrects what the comment + claims about *how* that resolution happens. +- 675ab57: **First-run polish:** a brand-new scaffold's very first `pnpm install` no longer reports two unmet peer dependencies (#10326). + + Reproduced on a clean scaffold from published `create-objectstack@17.1.0` — no lockfile, `node_modules` removed, nothing configured by the user — and again on the second scaffold path, `objectstack init`. Both printed the same two: + + ``` + ✕ unmet peer better-call + Installed: 1.4.0 + Wanted: + 1.3.7: + @better-auth/scim@1.7.0-rc.1 + + ✕ unmet peer better-sqlite3 + Installed: 13.0.3 + Wanted: + ^12.0.0: + better-auth@1.7.1 + ``` + + Nothing was broken — but it is the first screen a newcomer sees, and there is nothing they did to cause it or can do about it. + + **`better-sqlite3`: the pin is right and the upstream range is stale — so it is widened, not corrected.** better-auth 1.7.1 declares `better-sqlite3` as an **optional** peer at `^12.0.0`, and it governs exactly one configuration: a raw better-sqlite3 `Database` handed to better-auth's `database` option, which its Kysely dialect then drives. ObjectStack never takes that path — `AuthManager.createDatabaseConfig()` returns `createObjectQLAdapterFactory(dataEngine)`, and every `better-sqlite3` use under `plugin-auth` is knex's `client: 'better-sqlite3'` beneath ObjectQL. Measured anyway on the configuration the range *does* govern: better-auth 1.7.1 with `database: new Database(':memory:')`, running `getMigrations().runMigrations()`, `signUpEmail`, `signInEmail` and adapter `findOne`/`update`/`delete`, is green on **better-sqlite3 13.0.3** and byte-for-byte equivalent on **12.11.1**. The same probe with `Database.prototype.prepare` neutered fails, so that green is the driver's and not an unexercised path. Pinning our own `^13.0.3` declarations back to `^12` would downgrade a native module across the platform to satisfy a range measurement shows is simply behind. + + **`@better-auth/scim`: the rc pin stays, and one `better-call` copy is the correct tree.** `npm view @better-auth/scim dist-tags` reads `latest: '1.7.1'`, but stable 1.7.x ships the rc.2 whole-model rewrite, so adopting it is a separate migration rather than a version bump; the exact `1.7.0-rc.1` pin is deliberate. The rc peers an exact `better-call@1.3.7` while better-auth 1.7.1 depends on `1.4.0` — and a better-auth plugin has to share the **host's** better-call instance, so the single 1.4.0 copy every install already resolves is right, not a skew to repair. This declaration retires together with the rc pin. + + **What changed, and what deliberately did not.** Both remedies are pnpm `peerDependencyRules.allowedVersions` entries, scoped `>` so each widens exactly one declaration. They ship *inside* the scaffold — the bundled `pnpm-workspace.yaml` template and the one `objectstack init` renders — because a block in this repo's own workspace file does not travel with published packages. `allowedVersions` changes what pnpm **reports**, never what it resolves: measured on both scaffold paths, the lockfile is byte-identical with and without it (0 lines of diff), and no dependency version, range or resolution moved anywhere. This repo's own resolutions are untouched. +- 8d1fa00: Two ways to invoke the CLI wrong used to present as a crashed boot. Both now say what they are. + + `node packages/cli/dist/index.js` — the package `main`, which is a re-export barrel — ran to completion, printed nothing and exited 0. Backgrounded, that is indistinguishable from a server that came up and died. It now writes two lines to stderr, the first saying that running this file starts nothing and the second naming `bin/run.js` as the CLI entry point, and exits 1. + + A rejected invocation such as `objectstack dev --no-ui` answered with oclif's error line followed by a full usage dump, and in a background log the dump is what the eye lands on. One line now goes to stderr ahead of it: + + ``` + objectstack: INVOCATION ERROR — Nonexistent flag: --no-ui. The command never ran: nothing was started and nothing is listening. Invoked as: objectstack dev --no-ui + ``` + + No flag surface changed: `dev` still rejects `--no-ui` (only `serve` declares `ui` with `allowNo`). What changed is what the CLI says when it rejects an invocation. +- eee2b65: docs(cli): drop the phantom `os codemod v2-to-v3` claim and stale `projects/` tree node (#10881) + + `packages/cli/README.md` is this package's published README, and it carried two + false claims about what the CLI can do. + + Its "Code Transforms" section tabled `os codemod v2-to-v3` in the exact same + format as the ~60 real commands above it, and the Architecture source-tree + listing showed a matching `src/commands/codemod/v2-to-v3.ts`. Neither the + command nor the file has ever existed: `packages/cli/src/commands/` has no + `codemod/` directory, and oclif (which resolves commands by globbing + `dist/commands/**/*.js`) returns `command codemod:v2-to-v3 not found` (exit 2). + Removed rather than marked "not yet available", because a reader-facing + row/node with that exact name and shape would still misdescribe the one + concrete plan for this space: #9591 (`os migrate meta --write`, on hold, + targeting v18) is a differently-scoped, differently-named command over the + mechanical retired-key set, not a "v2 config to v3 format" transform — so there + is no accurate future command to point the row at. The "not yet available" + information already lives in `content/docs/protocol/backward-compatibility.mdx` + and `docs/DX_ROADMAP.md`; this brings the package README in line with the tool + itself, which lost the same false prescription in #10882. + + The same source-tree listing also showed a `projects/` node under + `src/commands/` with `list/show/create/switch/bind` — stale since the v5.0 + `project` → `environment` rename (ADR-0006, no aliases). Renamed to + `environments/`, matching the real directory; the subcommand file list was + already accurate and is unchanged. +- 65c4a13: docs(cli): correct three more stale `os projects` mentions in the README prose (#10927) + + Follow-up to #10881, which renamed the Architecture source-tree node. This + covers the three remaining places in `packages/cli/README.md` that described + an `os projects` command surface as if it still resolved: + + - The Cloud command table (`:75`) claimed `os projects create` was a + registered **alias** of `os environments create`. It is not: none of the + five files in `packages/cli/src/commands/environments/` (`list.ts`, + `show.ts`, `create.ts`, `switch.ts`, `bind.ts`) declares an `aliases` static + field, and neither does the `oclif` block in `packages/cli/package.json`. + Reworded to name it as the pre-rename spelling instead: "was `os projects + create` before the v5.0 project → environment rename (ADR-0006, no + aliases)". + - The Plugin Management prose (`:98`) called `os projects bind ...` a + "legacy" path that "still binds" an artifact — implying a working + fallback. Replaced with the real current invocation, `os environments + bind ...`. + - The Typical Workflow example (`:278`) used `os projects bind ...` directly, + with no caveat at all. Same replacement, and the trailing comment ("Bind to + a Cloud Project") is updated to "Cloud environment" to match — "Project" + now means only the npm/monorepo sense post-rename (ADR-0006). + + Verified against the built binary (`packages/cli/bin/run.js`), matching the + falsification standard from triage: the old spellings still fail — + `Error: Command projects:create not found.` / `Error: Command projects:bind + not found.` (exit 2, both) — and the new spellings resolve — `os environments + create --help` and `os environments bind --help` both exit 0 and print their + real flag/argument help. +- bde0ab9: Remove the abandoned tsup build path from `packages/cli` (#10185): the + `tsup.config.ts`, the orphaned `src/bin.ts` it was the only referrer of, and + the now-unused `tsup` devDependency. + + The package has built with `tsc -p tsconfig.build.json` since the oclif + migration, which also introduced `oclif.commands.target: "./dist/commands"` + and moved the `bin` field onto `bin/run.js`. The tsup config was left behind + by that commit and never invoked again — but it was not inert. It declared + `clean: true` with only `src/bin.ts` and `src/index.ts` as entries, so anyone + running the obvious `tsup` next to a `tsup.config.ts` would wipe `dist/` and + emit no `dist/commands/**` at all, leaving a CLI that resolves zero commands. + Deleting it removes the trap rather than documenting it. + + No published behaviour changes: the resolved oclif command surface is + identical before and after (60 commands, 68 topics). The only build-output + difference is that `dist/bin.js` — a re-export of `execute` from + `@oclif/core` that nothing imported — is no longer emitted. +- 53428b8: Fix `os serve` failing to boot with `OS_CLUSTER_DRIVER=redis` when the app + declares `@objectstack/service-cluster` (#10645). The cluster gate and its + driver were reached through a bare dynamic `import()`, which Node ESM resolves + against the CLI's own realpath — inside the framework workspace — so packages + installed under the host app were invisible to it and boot died with + `Cannot find package '@objectstack/service-cluster'`. Both loads now go through + the host-anchored importer `serve` already uses for its other optional and + enterprise packages, so any package the app declares resolves the way the app + declares it. The host importer is now defined at the top of the boot sequence + rather than partway down, which is what made these two loads fall back to bare + resolution in the first place. No change to what `serve` accepts or refuses: + an undeclared package is still refused by the same declaration gate. +- 63d603e: **Fix:** `os datasource list-tables`, `os datasource introspect` and + `os datasource validate` now read the response envelope the server actually + emits, so all three work against a live server for the first time (#10675). + + The three commands read the pre-#3843 **flat** shape — `body.tables`, + `body.draft`, `body.results`, and `body.error` as a string — while every REST + body the platform sends is the declared envelope written by `sendOk` / + `sendError`: `{ success: true, data: { … } }` or + `{ success: false, error: { code, message } }`. Nothing failed loudly, because + each payload simply read `undefined` and every command reported that as an + ordinary empty result: + + - `list-tables` printed `No remote tables found.` while the server was + returning two tables. + - `introspect` printed `Failed to generate draft` for drafts the server had + generated. + - `validate` printed `No federated objects to validate.` and exited **0** + against drift the server had flagged `missing_column … severity:error` — a + schema gate green-lighting a CI-breaking condition it had never read. + - An unknown datasource crashed with `TypeError: first argument must be a + string or instance of Error`, because the error **object** was handed to + oclif's `this.error()` instead of `error.message`. + + `validate`'s exit code is the behaviour change to note: a datasource whose + federated objects have drifted now exits **1** where it previously exited 0. If + you have a pipeline that treats this command as advisory, it starts failing on + drift that was always there. + + A body that is **not** the declared envelope is now a loud failure rather than + an empty payload. That distinction is the point: "nothing found" is reachable + only from a server that really said so, never from a response the CLI could not + read. The legacy flat shape is deliberately *not* also accepted — a + consumer-side fallback would re-create the divergence as a second de-facto + contract. +- 0d0fcaf: Read `doc.tags` from `src/docs/*.md` frontmatter, so a book group's + `include: { tag }` can match on the documented authoring path (#10486). + + `DocSchema.tags` was declared in 17.0.0 (#4509, ADR-0049) as the *enforce* half + of enforce-or-remove: the resolver side already compared against it + (`matchesInclude` in `book.zod.ts`) and the REST book-tree route already + forwarded it. But `collect-docs.ts` parsed frontmatter with `frontmatterScalar` + alone — single-line scalars — and had no case for `tags` at all. On the flat + `src/docs/*.md` path the docs actually recommend, a `tags:` block was therefore + dropped without a word: every doc reached `resolveBookTree` with + `tags === undefined`, and a group declaring `include: { tag: 'tutorial' }` + matched nothing and rendered as an empty section. + + Two halves: + + - **A minimal `frontmatterList`** reading the two ordinary YAML sequence + spellings — inline `tags: [tutorial, beginner]` and the block form of `- item` + lines — wired through `DocItem.tags`. The block sequence ends at the next + frontmatter key, so `group:` after a `tags:` block still parses. An authored + `tags: []` parses and means what it says: no tags. + + - **A loud `docs/frontmatter-tags` warning** whenever `tags:` is present in a + spelling the reader cannot parse — a bare scalar, an unterminated inline + sequence, a key with nothing under it. The reader is deliberately minimal and + is **not** growing into a YAML engine; this is what keeps that minimalism + honest, by converting the next unanticipated spelling from a silent drop into + a visible report. The same warning fires when a locale variant + (`..md`) declares `tags:`, since tags belong to the doc rather + than to one translation and a `DocTranslationItem` carries no such field. + + Warnings surface through the paths that already print `DocIssue`s: `os lint`, + `os validate`, and `os compile`. No schema change — `DocSchema.tags` already + declared the key; only the collector could not produce it. +- 9faa9bc: `os doctor --scan-deprecations` no longer prescribes a command that does not exist. After listing its hits the report used to print ``Run `objectstack codemod v2-to-v3` to auto-fix``, but no `codemod` command has ever been registered — following the advice returned oclif's exit 2, `command codemod:v2-to-v3 not found`, after the operator had already spent time on it. The hint is not repointed at `os migrate meta` either: that command replays the protocol migration chain over an authored stack config and declines the source rewrite by design ("does not silently rewrite TS config source"), writing only an `--out` JSON snapshot, so it cannot fix the `src/**` TypeScript the scan reports on. It now names the count and the remedy that really exists — the per-finding replacement, printed under `--verbose`. The scanner is unchanged: same file:line attribution, same `→ replacement` detail, still advisory with exit 0 either way. +- a7ea328: `os doctor` no longer prints `✓ Test coverage` / `✓ Deprecations` about a tree it + never examined, and no longer warns `@objectstack/spec Not built` about a + workspace that is not part of the tree (#10679). + + `findMissingTests()` and `findDeprecatedUsages()` both walk + `/packages/spec/src` — a path that exists in this monorepo and in no + application built with the framework. Both answered "that directory is not here" + with the same value they return for "I walked it and found nothing wrong" (an + empty array), so in a stock `create-objectstack -t blank` scaffold every run + printed, verbatim: + + ``` + ✓ Test coverage All *.zod.ts files have matching tests + ✓ Deprecations No @deprecated tags found + ``` + + about files doctor never opened. The command exits 0 either way, so "no problems + found" and "I never looked" were byte-identical to every downstream reader. + + Doctor already refuses to do this one screen down: the ADR-0120 D5e advisory's + `✓ Unique scope` is withheld unless `ledgerReadingIsComplete()` says the ledger + half was read in full. These two checks escaped that discipline; this restores + it, in the same shape #5413 used for the ledger — whether the tree was examined + is now a fact in the return type rather than an absence, so the print site + cannot reach the `✓` from the unexamined arm. Where the tree is absent doctor + prints an informational, named-reason skip instead: + + ``` + ℹ Test coverage Skipped — no packages/spec/src in this directory (monorepo-only check) + ℹ Deprecations Skipped — no packages/spec/src in this directory (monorepo-only check) + ``` + + `--verbose` adds the resolved directory it looked for. The skip is deliberately + not a warning: nothing is wrong in an application that has no + `packages/spec/src`, and withholding a false `✓` must not manufacture a false + `⚠`. + + The adjacent `⚠ @objectstack/spec Not built` probe read `/packages/spec/dist` + with no check that the workspace it names exists, so in an application it warned + about an absent package and prescribed `pnpm --filter @objectstack/spec build`, a + command that cannot succeed there. It is now gated on `packages/spec/package.json` + being present. Inside the monorepo the row is unchanged; outside it there is no + row, and an application's spec dependency stays covered by the `Dependencies` + check and by the spec-version-gap advisory. + + Exit codes are untouched — 1 exactly when an error row exists, warnings never + flip it. One visible consequence: a stock scaffold with no other findings now + ends on `✅ Environment is healthy and ready for development!` instead of + `⚠️ Environment is functional but has some warnings`, because the warning it + used to carry was about a workspace that was never there. +- 9990319: Make the hook-body build gates report only what they establish (#10678). Three + defects, one shape — a gate reporting something it never established. The + enforcement net was never the gap and is unchanged: no forbidden body ever + shipped as `body.source`, and every forbidden or free-identifier hook is still + refused under `--strict-body`, at the same exit codes as before. + + **The default build no longer warn-and-bundles in silence.** A hook body + containing a forbidden pattern made `os build` exit 0 with no output at all: the + extraction failure was recorded in `bodyExtractionWarnings` and then printed + nowhere, so the only way to learn a handler had *not* become a metadata body was + to diff the artifact. The recorded warnings now reach a human — on stdout, + naming the hook and the pattern, with a pointer at `--strict-body` — and in + `--json` under a new `bodyExtractionWarnings` key. That key is separate from + `warnings` on purpose: `warnings` carries author-time rule advisories in the + shape `os validate --json` also reports, and these are a different record + (`{origin, reason}`). It is an empty array on a clean build, so a CI consumer can + read it unconditionally. + + The build still exits 0 in this case. Making a forbidden pattern fatal by default + would change what `os build` accepts and is not part of this change. + + **The `require()` refusal reason now fires on the real authoring path.** A + TypeScript config is loaded through `bundle-require` → esbuild, whose ESM interop + shim rewrites `require('node:os')` to `__require("node:os")` before `String(fn)` + runs — so the `require()`-specific reason could never match, and the refusal + arrived instead as the generic free-identifier message naming `__require`, an + identifier the author never typed. Both spellings now carry the one reason, which + also explains the rewrite. Accept behaviour is unchanged: the body was already + refused, already bundled, at the same exit code; only the wording moved. + + **The `// @capabilities` directive is documented at its real reach.** It is read + off `String(fn)`, and esbuild strips `//` line comments before the handler is ever + a runtime function — so through `os build` it reaches the extractor from no + ordinary authoring shape. Measured on all four: `objectstack.config.ts`, `.js`, + `.mjs`, and a handler imported from a local `./handlers.js` all silently drop it + and ship the inferred capabilities alone. `hook-bodies.mdx` and the extractor + header now say so, and point at `body.capabilities` — data rather than a comment — + as the escape hatch that does survive. Whether the directive should gain a real + authorable surface or be retired is left open. + + The extractor header claimed a forbidden pattern "makes the build **fail** … + no silent fallback"; docs described warn-and-bundle. The code agreed with the + docs, so the header was the outlier and has been rewritten to describe both + outcomes. + + A new `os build`-level test (`hook-body-build-reach.e2e.test.ts`) spawns the real + CLI and pins all three behaviours against the artifact and the shell's exit code. + The existing extractor unit tests could not have caught any of this: they feed raw + JS function literals, which keep their comments and their `require(` spelling + because nothing transformed them. +- 818e027: Fix `objectstack init`'s closing "Created files" summary omitting `pnpm-lock.yaml` / `package-lock.json` and `node_modules/` (#10557). + + The summary used to be printed from a list accumulated while the template + files were written — before ` install` ran — so it could never name what + the package manager wrote. `init` now prints it after the install attempt + (succeeded or failed) from a walk of the finished project directory, reusing + `create-objectstack`'s `created-summary.ts` (now published as the + `create-objectstack/created-summary` subpath) instead of a second copy of the + same renderer. +- 13fa51e: `objectstack init` now writes both build-approval keys into the scaffolded + `pnpm-workspace.yaml`, so a brand-new project's first `pnpm install` succeeds + on pnpm 11 (#10405). + + The renderer emitted only `onlyBuiltDependencies`. pnpm 11 does not read that + key at all, and it turned an unapproved dependency build script from a warning + into a hard error — so `objectstack init my-app && cd my-app && pnpm install` + exited 1 with `ERR_PNPM_IGNORED_BUILDS`, on the very first command after + scaffolding. The rendered file now also carries `allowBuilds`, built from the + same source list, which is the only key pnpm 11 reads. Measured one clean + install per pnpm version, each with its own store: pnpm 10.0.0-10.25.0 read + `onlyBuiltDependencies`, 10.26.0-10.34.x read either key, and 11.x reads + `allowBuilds` only — so both keys are load-bearing and neither is redundant. + + Build permission is still granted to exactly the two packages that need it and + nothing else: `esbuild` (a `postinstall` that installs its platform binary, + used to compile `objectstack.config.ts`) and `better-sqlite3` (ships a + `binding.gyp`, which pnpm treats as a native build; without it `objectstack + serve` can fail with "Could not locate the bindings file"). No wildcard. + + Existing scaffolds are unaffected — `init` never overwrites a + `pnpm-workspace.yaml` that is already there. To fix a project scaffolded by an + earlier CLI, add to its `pnpm-workspace.yaml`: + + ```yaml + allowBuilds: + better-sqlite3: true + esbuild: true + ``` +- 3a7ec2d: `os migrate duplicates` no longer reports a clean bill of health over a driver it + could not query (#10677). The `no_sql_seam` refusal #8928 mandated was dead code + for the memory driver, so the exact outcome the ruling exists to forbid was + reachable: + + ``` + os migrate duplicates --database-url memory://qa + -> exit 0 {"duplicates":[],"skipped":[],"counters":{"status":"read"}} + ``` + + `InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` + and returns `null` — it neither throws nor is absent. The seam resolver asks + whether the driver has the SHAPE of a seam (`typeof d.execute === 'function'`), + which that satisfies, so the `if (!exec)` guard never fired; and + `normalizeRows(null)` is `[]`, which is also what a real driver returns for a + SELECT that matched nothing. Three statements were swallowed and the report said + the install was clean. + + The command now separates the two cases the guard used to conflate: **a seam + that cannot answer is absent, not empty.** It asks the resolved seam one trivial + statement before the scan starts and refuses when the answer is not a result + set, and it holds every individual probe to the same standard, so a probe that + returns no result set becomes a `skipped` entry with its reason instead of zero + findings. + + ``` + os migrate duplicates --database-url memory://qa + -> exit 1 {"error":"no_sql_seam","detail":"The active driver exposes no + usable raw SQL seam — it is either absent, or present but + returning no result set — …"} + ``` + + Nothing here names a driver: a seam is judged by what it returns, so any host + with the same no-op shape is covered without an allowlist to maintain. No driver + package was modified. + + Two behaviours are deliberately unchanged. A seam that **throws** is a driver + present and refusing loudly, and the per-probe `skipped` path already reports + that honestly — claiming it here would swallow a transient connection error as + "no seam" and would invent a refusal #8928 never mandated. And a real SQL driver + is unaffected: every shape the new check rejects is one `normalizeRows` already + flattened to `[]`, so no row that used to be reported can be lost. +- be30ca7: Correct a false verb in `os migrate meta`'s own source comments: the `--from` + arm **lists** the mechanical edits an author's source needs; it rewrites no file + (#10831). + + The `pendingDataMigrations` docblock in + `packages/cli/src/commands/migrate/meta.ts` opened with "this command rewrites an + author's source" — 74 lines above the command header that says the opposite + ("The command does not silently rewrite TS config source (that AST rewrite is + unsafe and lossy)"). Both `writeFileSync` calls in the file are guarded by + `if (flags.out)`, so the only file the `--from` arm ever writes is the `--out` + JSON snapshot. The in-place codemod is a separate, unbuilt piece of work. + + The contrast the docblock was drawing — metadata migration's subject is the + author's *source*, the two data migrations' subject is a deployment's *rows* — + is correct and is preserved; only the verb on the first half changed. The + `--stored` arm genuinely does rewrite `sys_metadata` rows and its wording is + untouched. + + No runtime behaviour changes: comment-only. +- c2b97c2: `os package publish` now prints the reason a publish was refused instead of the + literal `[object Object]` (#10763). + + Both request helpers in `package/publish.ts` built their failure text the same + way: + + ```ts + const errMsg = parsed?.error ?? response.statusText ?? `HTTP ${response.status}`; + return { ok: false, status: response.status, body: parsed, error: String(errMsg) }; + ``` + + In the declared envelope `error` is an **object** — `{ code, message }` — so + `String(errMsg)` stringified the object. The `??` chain never reached + `statusText`, because an object is not nullish; there was no useful fallback to + reach. Every failed publish printed the same seven characters no matter what the + control plane had refused, at all three call sites: package registration, + version publish, and the icon upload. + + Both sites now read through a new `readErrorMessage` in + `packages/cli/src/utils/response-envelope.ts`, which returns the declared + envelope's `error.message`, degrades to `error.code` when a refusal carries no + message, and falls back to a non-blank `statusText` and then the status line. A + blank `statusText` counts as absent — HTTP/2 carries no reason phrase, and the + old `??` chain kept the empty string and printed nothing after the status code. + + The reader also accepts the flat `error: ''` shape, deliberately and + temporarily. That is a **measured** property of these routes rather than an + assumption: `/api/v1/cloud/**` is served by the sibling `cloud` repo, and the + closest first-hand reader of that same `service-cloud` family — objectui's + `readApiError` — records that it answers failures in both shapes while cloud#944 + converts it. A strict envelope-only read (the `readEnvelope` landed by #10675 + for the in-repo `/api/v1/datasources/**` routes) would have replaced today's + live flat dialect with a different unreadable failure, so it is not reused here; + the reasoning, and the condition under which the flat branch is deleted, are + recorded on the function. + + No request the CLI sends changes, and the server sends exactly what it sent + before — this is only how a failure is read and shown. +- afe1c4e: fix(cli): declare the four `@better-auth/utils` peer skews a freshly scaffolded project reports (#10931) + + Both scaffold paths emit a `peerDependencyRules.allowedVersions` block whose + stated purpose is that a brand-new project's first `pnpm install` does not open + with a peer-skew report. It declared two skews and left four showing: + + ``` + ├─┬ @better-auth/core 1.7.1 + │ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 + ├─┬ @better-auth/scim 1.7.0-rc.1 + │ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 + ├─┬ @better-auth/oauth-provider 1.7.1 + │ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 + └─┬ @better-auth/sso 1.7.1 + └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 + ``` + + `@better-auth/core`, `/oauth-provider`, `/scim` and `/sso` each peer an **exact** + `@better-auth/utils@0.4.2`. The 0.5.0 they are handed comes from + `better-call@1.4.0` — better-auth's own HTTP layer — which *depends* on + `^0.5.0`; `@objectstack/plugin-auth` names the four as direct dependencies + without naming utils, so pnpm satisfies their peer from better-call's copy + instead of better-auth's own exact 0.4.2 dependency. + + **Measured compatible before widening, not assumed.** Those four import three + symbols in total: `base64`/`base64Url` (`@better-auth/utils/base64`), + `createHash` (`/hash`) and, in core only, `createRandomStringGenerator` + (`/random`). 0.5.0 declares all three with identical signatures; `/random` is + unchanged apart from formatting, `/base64` swaps `new Uint8Array(data)` for a + helper that *is* `new Uint8Array(data)` on non-strings, and `/hash` only widens + its input coercion for views not backed by a plain `ArrayBuffer`. Run against + the input shapes those call sites actually pass, the two versions agree on every + value; run end to end — better-auth with the `sso`, `oauth-provider` and `scim` + plugins — a tree where the four resolve 0.5.0 and one where they resolve 0.4.2 + produce the same transcript: sign-up, sign-in, session, both OAuth metadata + documents, the RFC 7636 PKCE challenge, and the SCIM and SSO endpoint outcomes. + + A resolution change was measured too, and rejected: pinning utils back to 0.4.2 + clears the four lines only by dragging `better-call@1.4.0` off its own declared + `^0.5.0` — manufacturing one real range violation to silence four benign ones. + + Four scoped entries, one per declaring package, matching the block's convention + that each rule widens exactly one declaration. `allowedVersions` suppresses the + report only: the lockfile a scaffold resolves is byte-identical with and without + the block. The version is spelled `0.5.0` exactly rather than `0.5`, so a future + `0.6.0` reports again instead of inheriting this finding. + + Both scaffold paths — `objectstack init` (rendered by the CLI) and + `npx create-objectstack` (a copied template file) — are changed together, and + `packages/cli/test/scaffold-workspace-consistency.test.ts` gains a limb that + compares the peer maps the two produce, so they cannot drift apart again. +- 568de19: Scaffolded projects declare an explicit empty `packages: []` in their + `pnpm-workspace.yaml` (#10933). Both scaffold paths render it — + `renderPnpmWorkspaceYaml` in `objectstack init`, and the bundled `blank` + template `npx create-objectstack` copies. + + The file was deliberately keyless so it would act purely as a settings file. + That intent is now written down rather than inferred from a missing key, and + writing it down is what fixes a first-command failure: pnpm 9.x and 10.0–10.4 + parse `pnpm-workspace.yaml` **before** they read `engines`, so they refused a + brand-new project outright with + + ``` + ERROR packages field missing or empty + ``` + + naming a file the user never wrote and giving no hint that the cause is their + pnpm version — and no `engines.pnpm` floor could reach them, because they never + got as far as the engines check. Measured, one clean install per pnpm version, + each with its own store: + + | pnpm | before | after | + |---|---|---| + | 9.15.9, 10.0.0, 10.4.0 | `ERROR packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming `>=10.15` | + | 10.5.0–10.14.0 | `ERR_PNPM_UNSUPPORTED_ENGINE` | unchanged | + | 10.15.0, 10.34.5, 11.22.0 | installs | installs, byte-identical `pnpm-lock.yaml` | + + So every unsupported pnpm now reports the same actionable cause, and supported + pnpm is unaffected: the empty key was measured equivalent to omission on + 10.15.0, 10.34.5 and 11.22.0 — identical lockfile bytes, identical + `node_modules/.modules.yaml` once the run-local `prunedAt`/`storeDir` fields are + dropped, identical `pnpm ls -r --depth -1`, and an identical second-install + "Already up to date". + + The declaration is an **empty** list on purpose. `packages: ['.']` satisfies the + same parsers but declares the project root a workspace *member* — a monorepo + root — which a single-package scaffold is not, and which reads to the next + author (human or AI) as an invitation to add member packages to an app. + + `engines.pnpm` is unchanged at `>=10.15`. +- 9d101d2: Declare a pnpm floor (`engines.pnpm: ">=10.15"`) in the `package.json` both + scaffolders write, so an unsupported pnpm reports its own version instead of an + error about a file the user never wrote. + + Both scaffold paths emit a settings-only `pnpm-workspace.yaml` with no + `packages:` key. Early pnpm 10 refuses that file outright — `pnpm install` exits + 1 with `ERROR packages field missing or empty` before resolving a single + dependency, so a brand-new project could not be installed at all. Measured on + the rendered shape, one clean install per pnpm version, each with its own store: + + | pnpm | before | after | + | --- | --- | --- | + | 10.0.0 – 10.4.0 | `packages field missing or empty` | unchanged — see below | + | 10.5.0 – 10.14.0 | `packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming the expected range | + | >= 10.15.0 | installs | installs | + + The floor is a diagnosis, not a repair: pnpm 10.0.0–10.4.0 parse + `pnpm-workspace.yaml` *before* they read `engines`, so they still print the raw + workspace error. Closing that remaining sliver requires deciding what a + single-package scaffold should declare under `packages:`, which is tracked + separately and deliberately not decided here. + + `engines.pnpm` rather than a `packageManager` stamp: npm, yarn and bun ignore + `engines.pnpm` entirely, so the scaffold keeps working for all four package + managers `objectstack init` hands off to. A `packageManager: "pnpm@x.y.z"` stamp + would declare the project pnpm-only (corepack-driven yarn refuses to run in such + a project) and pin one exact version that goes stale on every pnpm release — and + it buys nothing on 10.0–10.4, which reach the workspace error before reading + that field either. + + No existing project is affected; this only changes what a newly scaffolded + `package.json` contains. +- 621a487: **Bug fix (silent failure made loud):** `serve` now prints a boot-time diagnostic when the configured auth base URL cannot be parsed, instead of discarding the failure in an empty `catch` (#10202). + + The base URL was resolved through a `??` chain and parsed inside `try { new URL(baseUrl) } catch { /* ignore malformed baseUrl */ }`. That catch was the only place in the boot that learned the value was unusable, and it threw the knowledge away: the deployment's own origin never reached the `trustedOrigins` allow-list, boot continued normally, and the operator's first news of it was a browser-side `403 INVALID_ORIGIN` that names neither the variable nor the value. + + The shape that reaches it is ordinary env plumbing. `readEnvWithDeprecation` returns the preferred variable whenever it is `!== undefined`, so a **present-but-empty** variable resolves to `''` rather than `undefined`; `??` falls through only on `null`/`undefined`, so `OS_AUTH_URL=` on its own line in an env file (or a Helm/systemd/CI template rendering an absent key) consults neither `OS_BASE_URL` nor the `http://localhost:` default; and `new URL('')` throws. + + Measured on a real `os serve` boot with `NODE_ENV=production`, `OS_AUTH_URL=` set-but-empty and `OS_TRUSTED_ORIGINS` / `OS_ROOT_DOMAIN` / preview mode unset, probing `POST /api/v1/auth/sign-in/email` so a trusted origin answers `401 INVALID_EMAIL_OR_PASSWORD` and an untrusted one `403 INVALID_ORIGIN`: + + | Origin | `OS_AUTH_URL=` (empty) | `OS_AUTH_URL=https://app.example.com` | unset | + | --- | --- | --- | --- | + | `https://app.example.com` | 403 | **401** | 403 | + | `http://localhost:` | **401** | 403 | **401** | + | `http://tenant.localhost:` | **401** | 403 | 403 | + | `/api/v1/health`, `/api/v1/ready` | 200 | 200 | 200 | + + Two corrections to how this was expected to behave, both from that table. The allow-list does **not** come out empty: `serve` passes `trustedOrigins.length ? trustedOrigins : undefined`, and `AuthManager` substitutes a localhost wildcard trio for an absent list — so better-auth receives a non-empty list and localhost origins are trusted. Which makes set-but-empty strictly **more permissive than unset**: `http://tenant.localhost:` is trusted in the empty case and refused in the unset case, so an env template that renders an absent key to the empty string silently widens a production CSRF allow-list. + + **What changed is only what is said, never what is resolved.** The precedence chain, its order, and the `${protocol}//${host}` origin spelling are byte-for-byte identical; a set-but-empty `OS_AUTH_URL` still stops the chain exactly as before. Treating empty as unset inside the shared `readEnvWithDeprecation` would change behaviour for every caller of that helper and remains a separate, deliberate decision. The diagnostic is a warning, not a refusal to boot: a deployment running set-but-empty today keeps starting, and now says why authentication will not work. + + The resolution is exported as a seam — `resolveAuthBaseUrl()` and `formatUnusableAuthBaseUrlDiagnostic()`, alongside this file's sibling helpers — so the behaviour is reachable from tests without booting a server. +- 22f6629: **Bug fix (wrong address printed):** the `os serve` / `os dev` ready banner now builds its API, Console and MCP links from the origin an operator can actually reach, instead of composing `http://localhost:` from the port the process happens to bind (#10646). + + Measured on the EE 4.1.0 published-image compose stack (moved from cloud#1507). The app container `expose`s `:3000` with no `ports:` mapping — unreachable from the host, and less so still under `--scale app=N` — while the published entry point is Caddy on `:80`, and compose has already resolved `OS_AUTH_URL` to `http://localhost`. The banner printed the container-internal address anyway: + + ``` + ➜ API: http://localhost:3000/ + ➜ Console: http://localhost:3000/_console/ + ➜ MCP: http://localhost:3000/api/v1/mcp + connect an AI client (Claude Code, Cursor, …) · skill: http://localhost:3000/api/v1/mcp/skill + ``` + + Following the Console link failed outright; after moving the deployment to a domain the banner still said `localhost:3000`; and the `MCP:` line is the address customers paste into an AI client, where a wrong absolute URL never fails loudly — it just never connects. + + **The origin is the runtime own answer, not a second one.** The banner resolves it through `resolveAuthBaseUrl` — the same function whose `baseOrigin` is pushed onto the CSRF allow-list a few hundred lines earlier in the same boot — so the banner and the origin the deployment actually trusts cannot drift apart. That chain is `OS_AUTH_URL` → legacy `BETTER_AUTH_URL` → `OS_BASE_URL` → `http://localhost:`; the legacy name sits in the middle and is easy to miss when the chain is restated from memory, which is one reason it is read rather than restated. Nothing about what the server listens on, binds to, or advertises to a client changed: the resolver reads `process.env` and the bound port, and this fix changes only printed text. + + **When no origin can be determined, the banner prints no absolute URL at all.** The chain yields nothing usable when a variable is set-but-empty (`OS_AUTH_URL=` stops the chain rather than falling through) or carries no scheme. The banner then prints the paths bare — + + ``` + ➜ API: / + ➜ Console: /_console/ + ➜ MCP: /api/v1/mcp + connect an AI client (Claude Code, Cursor, …) · skill: /api/v1/mcp/skill + paths only — this deployment external base URL could not be resolved; + set OS_AUTH_URL to its public origin (e.g. https://app.example.com) + ``` + + — because a missing address sends the operator to look one up, while a confident wrong one gets copied. `http://localhost:3000` was never a neutral default here; it was the wrong answer that shipped. + + The local dev loop is unchanged: with nothing set, the tail of the chain is still `http://localhost:` on the port that was actually bound (past any dev auto-shift), so `os dev` keeps its clickable Console link. + + Structurally, `ServerReadyOptions.port` is replaced by a required `externalBaseOrigin: string | null`. The banner no longer knows the port, so it cannot compose an address from one, and a caller that fails to resolve an origin is a compile error rather than a plausible-looking line of output. +- 9cc6777: `os serve` now resolves a `plugins: [...]` entry the served app **declares** from + that app, instead of from the CLI (#10908). + + `plugins: [...]` in the app's own `objectstack.config.ts` is the documented way + to extend a deployment, but its string entries were loaded with a bare + `import()`, which Node ESM resolves against the CLI's realpath. An app that + wrote `plugins: ['@acme/my-plugin']` and declared `@acme/my-plugin` in its own + `package.json` could therefore only be served where that package happened to be + hoisted somewhere the CLI could see it — true in a dev checkout, absent on a + real distribution layout. Same mechanism as the cluster and organizations loads + fixed earlier. + + Only the **declared** case moves. A specifier the app does not declare still + resolves from the CLI exactly as before, and a path or `file://` URL keeps the + base it always had, so no deployment loses a plugin it is loading today. Which + plugins are *accepted* is unchanged — the declaration gate is untouched. + + One user-facing message changes: when a declared plugin cannot be loaded, the + `Failed to import plugin ''` error now carries the declaration remedy + ("declare it in that app's `package.json`", or the install-problem text when the + app declares it but it is not installed) instead of a bare `Cannot find package`. +- 3d7deb7: `os serve` now resolves **every** app-declarable optional package from the app + being served, not from the CLI, and the ordering hazard that broke it twice is + gone by construction (#10769). + + `serve.ts` reaches optional and enterprise packages through `createHostImporter`, + which anchors resolution at the host app. The helper was bound as a `const` + partway down one very long boot method, so it existed only *below* its own + binding — and a load written above that point was **not** a compile error. The + author simply wrote a bare `import()`, which resolves against the CLI's own + realpath and works fine in a dev checkout where everything is hoisted into one + `node_modules`. It breaks only in a real distribution layout, at boot, in + production. That shipped twice: + + - **cloud#1013** — the binding sat below the auth block, so the enterprise + `@objectstack/organizations` load resolved in the framework workspace, never + found the cloud-private package, and every walled-posture deployment hit the + ADR-0093 D5 fail-fast and exited 1. + - **#10645** — the binding sat below the cluster block, so on the published EE + image `OS_CLUSTER_DRIVER=redis` died at boot with `Cannot find package + '@objectstack/service-cluster'`, and compose's `service_completed_successfully` + took the whole stack down with it. + + Each was fixed by hoisting the binding, which left the class open: the next load + added above the new line reproduces it exactly, and no author has any reason to + know where that line is. `importFromHost` is now a **module-scope function + declaration**, hoisted over the whole module, so "above the definition" is no + longer a state the file can be in — every line of `serve.ts` reaches the same + host-anchored importer, in any order. + + Sweeping the file for the class then turned up one live instance: + `@objectstack/service-i18n` was loaded with a bare `import()`. `packages/cli` + does not declare it, so an app that declares its own copy could only be found by + accident of workspace hoisting — green in a dev checkout, absent on a real + install layout. It is now host-anchored like the rest. An app that does not + declare the package still falls back to the CLI's own resolution, so the quiet + "i18n not installed, use the kernel fallback" path is unchanged. + + Nothing about what `serve` binds, listens on, advertises, or *accepts* moves: + this changes only where a module resolves **from**. The `#4719` declaration gate + is untouched — a package the app has not declared is still refused rather than + picked up from a hoisted store. + + `serve-cluster-host-resolution.test.ts` is widened from the cluster pair to every + app-declarable optional load, classifying mechanically (a package is + app-declarable exactly when `packages/cli`'s own manifest does not declare it) so + a newly added optional package is covered without anyone remembering the test + exists. +- ff5733e: `os validate`'s summary now prints `UI: 0 Apps` instead of dropping the whole + `UI:` row when a stack declares zero apps (#10504). + + Measured on the `blank` scaffold (`create-objectstack my-app -t blank`, + published 17.1.0, reproduced unchanged at this branch's head): a project with + no navigable UI and a project whose summary simply does not report on UI at + all printed identically — the `UI:` row was *absent*, not printed as `0`, so + a newcomer whose Console comes up empty had no way to tell which of the two + they were looking at. Both cases exited `0`. + + `printMetadataStats` (`packages/cli/src/utils/format.ts`, shared by + `os validate`, `os info` and `os compile`) gains an opt-in `zeroFallback` per + summary section — the one item to force-print at `0` instead of dropping the + whole row when every item in that section is zero. It is set only on `UI` + (`Apps`), matching the triage ruling on #10504: the `blank`/`crud`/`full` + templates all ship zero apps deliberately, so a *warning* would fire on every + clean scaffold's first run. This is a legibility fix only — nothing about + what `validate` accepts, rejects, or exits with has changed, and `Data:`, + `Logic:`, `Security:` keep their existing drop-at-zero behavior (tracked + separately in #10952). + + The `--json` path already reported `"apps": 0` explicitly at zero — no change + needed there; a separate, unrelated `--json` warnings gap is tracked in + #10953. +- Updated dependencies [8f04d9a] +- Updated dependencies [4d7c564] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [07bd1ca] +- Updated dependencies [9f05b7d] +- Updated dependencies [b47ba2c] +- Updated dependencies [f0d7647] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [da891e0] +- Updated dependencies [a38c3ff] +- Updated dependencies [76deca2] +- Updated dependencies [163a162] +- Updated dependencies [128684d] +- Updated dependencies [cec9d23] +- Updated dependencies [5337ef1] +- Updated dependencies [3a3f209] +- Updated dependencies [7d2d112] +- Updated dependencies [03bdd14] +- Updated dependencies [5fa0d72] +- Updated dependencies [7bf3fb7] +- Updated dependencies [02b3b07] +- Updated dependencies [dd41df3] +- Updated dependencies [675ab57] +- Updated dependencies [7d81c88] +- Updated dependencies [2570ab0] +- Updated dependencies [e85182d] +- Updated dependencies [5886ee6] +- Updated dependencies [aea1e64] +- Updated dependencies [bbe643c] +- Updated dependencies [e634ecf] +- Updated dependencies [8163a1c] +- Updated dependencies [cdaa72f] +- Updated dependencies [02d56b4] +- Updated dependencies [95437e7] +- Updated dependencies [46cfa5b] +- Updated dependencies [82cb6e8] +- Updated dependencies [b20c8d2] +- Updated dependencies [f76fe42] +- Updated dependencies [4257e4e] +- Updated dependencies [3e26359] +- Updated dependencies [6ce58a7] +- Updated dependencies [d806081] +- Updated dependencies [d23e3a0] +- Updated dependencies [9a1ed7a] +- Updated dependencies [f3a8134] +- Updated dependencies [b03a880] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b0af2b] +- Updated dependencies [818e027] +- Updated dependencies [5b39785] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [78818ec] +- Updated dependencies [9d7d2de] +- Updated dependencies [13f533a] +- Updated dependencies [795ea05] +- Updated dependencies [900e489] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [9e04c3e] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [2866d5f] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [57e4571] +- Updated dependencies [112a8c6] +- Updated dependencies [13a3dca] +- Updated dependencies [ab47f69] +- Updated dependencies [5acb58d] +- Updated dependencies [a16ff50] +- Updated dependencies [e222a53] +- Updated dependencies [acb4dbc] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [c49007a] +- Updated dependencies [047ac86] +- Updated dependencies [6d5c4fa] +- Updated dependencies [3ee8ddf] +- Updated dependencies [0c24898] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [490879a] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [4389fe9] +- Updated dependencies [13a6cb4] +- Updated dependencies [9f483d9] +- Updated dependencies [afe1c4e] +- Updated dependencies [568de19] +- Updated dependencies [8d21f7a] +- Updated dependencies [9d101d2] +- Updated dependencies [6d441e4] +- Updated dependencies [5a616d5] +- Updated dependencies [923c424] +- Updated dependencies [b419135] +- Updated dependencies [88e32a8] +- Updated dependencies [38bc74e] +- Updated dependencies [0ab81d1] +- Updated dependencies [a24b7fa] +- Updated dependencies [1ec36b7] +- Updated dependencies [93304c2] +- Updated dependencies [bc400af] +- Updated dependencies [9e93fc6] +- Updated dependencies [5f2e54c] +- Updated dependencies [e2bb237] +- Updated dependencies [189373b] +- Updated dependencies [f59035c] +- Updated dependencies [af1636c] +- Updated dependencies [86a8ec9] +- Updated dependencies [d9353b9] +- Updated dependencies [35ad101] +- Updated dependencies [ecd06f6] +- Updated dependencies [502dc6f] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [aa765b9] +- Updated dependencies [c5d0c2f] +- Updated dependencies [6439f8b] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [45204a5] +- Updated dependencies [9b0172d] +- Updated dependencies [24ba050] +- Updated dependencies [f399618] +- Updated dependencies [adbcbfd] +- Updated dependencies [f1b5ad3] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/platform-objects@17.2.0 + - @objectstack/plugin-auth@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/driver-sql@17.2.0 + - @objectstack/driver-turso@17.2.0 + - @objectstack/driver-mongodb@17.2.0 + - @objectstack/driver-memory@17.2.0 + - @objectstack/client@17.2.0 + - @objectstack/plugin-approvals@17.2.0 + - @objectstack/service-storage@17.2.0 + - @objectstack/plugin-audit@17.2.0 + - @objectstack/runtime@17.2.0 + - create-objectstack@17.2.0 + - @objectstack/plugin-security@17.2.0 + - @objectstack/service-analytics@17.2.0 + - @objectstack/service-automation@17.2.0 + - @objectstack/service-cache@17.2.0 + - @objectstack/service-job@17.2.0 + - @objectstack/setup@17.2.0 + - @objectstack/metadata-protocol@17.2.0 + - @objectstack/rest@17.2.0 + - @objectstack/service-datasource@17.2.0 + - @objectstack/service-messaging@17.2.0 + - @objectstack/plugin-webhooks@17.2.0 + - @objectstack/plugin-email@17.2.0 + - @objectstack/plugin-hono-server@17.2.0 + - @objectstack/observability@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/lint@17.2.0 + - @objectstack/service-package@17.2.0 + - @objectstack/cloud-connection@17.2.0 + - @objectstack/plugin-reports@17.2.0 + - @objectstack/plugin-sharing@17.2.0 + - @objectstack/metadata@17.2.0 + - @objectstack/trigger-schedule@17.2.0 + - @objectstack/trigger-record-change@17.2.0 + - @objectstack/driver-sqlite-wasm@17.2.0 + - @objectstack/service-sms@17.2.0 + - @objectstack/service-settings@17.2.0 + - @objectstack/mcp@17.2.0 + - @objectstack/account@17.2.0 + - @objectstack/service-queue@17.2.0 + - @objectstack/service-realtime@17.2.0 + - @objectstack/verify@17.2.0 + - @objectstack/formula@17.2.0 + - @objectstack/trigger-api@17.2.0 + - @objectstack/types@17.2.0 + - @objectstack/plugin-pinyin-search@17.2.0 + - @objectstack/console@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 2292600547..8d0d0d2345 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.1.0", + "version": "17.2.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 d98a7feb70..e6bea12036 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,64 @@ # @objectstack/client-react +## 17.2.0 + +### Patch Changes + +- 368e7a0: Fix the `useQuery` and `usePagination` TSDoc `@example` blocks in + `packages/client-react/src/data-hooks.tsx`, which read `data?.value` — a key + `PaginatedResult` (declared at `packages/client/src/index.ts:310`) does not + have. `PaginatedResult` declares exactly `records`, `total`, `object`, and + `hasMore`, so `data?.value` was always `undefined`; once the query resolved, + `data` was a real object and `.map` on `undefined` threw, taking the copied + component down. Both examples now read `data?.records`, matching the + hand-written doc that covers the same hooks (`content/docs/api/client-sdk.mdx`). + + Swept all four `@example` blocks in the file: the `useMutation` and + `useInfiniteQuery` examples never referenced `.value` and needed no change. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/client@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index e3ebd79c1a..04fe0b30fa 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.1.0", + "version": "17.2.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 79f32dbbbf..4881095c8b 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,95 @@ # @objectstack/client +## 17.2.0 + +### Minor Changes + +- 67630c4: The `/meta` FSM state route is singular: `meta.getLegalNextStates` moves, the plural registration is retired (#10077) + + Step 2 of the #9180 ruling — the `/meta` type segment is always singular, no + exception and no tolerated plural alias. Maintainer re-weigh, 2026-08-17, + verbatim: 「② 照原样做;只需要修正 objectstack objectui cloud 中错误的写法。」 + + - `client.meta.getLegalNextStates(object, field, from?)` now requests + `GET /api/v1/meta/object/:name/state/:field`. Same method, same arguments, + same response body — only the path segment changes. + - `GET /api/v1/meta/objects/:name/state/:field` is **no longer registered**. + The singular twin has been mounted alongside it since #7526, so the + migration for a hand-rolled HTTP caller is to drop the `s`. A request to the + retired spelling now gets the transport 404, which is the loud answer; the + one shape that changes hands rather than 404ing is a field literally named + `published`, which the compound `/:type/:section/:name/published` route + picks up. + - The two route ledgers follow what is mounted and what the SDK calls: the + plural row is deleted from `rest-route-ledger.ts` and the dispatcher ledger's + mirror row is respelled. + + **What this does not change.** The boundary fold `META_URL_TO_SINGULAR` is + untouched, so no `/meta/:type/...` spelling that is accepted today becomes + refused: the retired route matched a **literal** path segment and never + consulted the fold. The 2026-08-17 re-weigh (item 3) defers that break with no + scheduled window. The legacy dispatcher branch in `runtime/src/domains/meta.ts` + also still matches both literals; narrowing it is not part of this step. + +### Patch Changes + +- 59eb04d: Stop documenting bare `POST /api/v1/ai/chat` as agent-resolved (#10510). Two + shipped docblocks described a resolution step the route does not perform: + `client.ai.agents` claimed `/ai/chat` "talks to the environment's default + agent", and `App.defaultAgent` claimed that endpoint auto-resolves the app's + agent from `context.appName`. The bare route loads no agent and never reads + `context.appName`; the default-agent chain (explicit > `defaultAgent` of the + named app > first active) is driven by the assistant chat endpoint, + `POST /api/v1/ai/assistant/chat`, and `client.ai.agents.chat()` is the only SDK + method that reaches an agent at all. + + Both sites read as a security-relevant scoping guarantee — an agent-resolved + endpoint would have its tool offer scoped by that agent's skills (ADR-0063 + §1/§5) — so a reader auditing "which endpoints are surface-scoped?" from these + declarations got the wrong answer at both. Documentation text only: no schema + key, no parse behaviour and no runtime path changes. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index d4cce14c5e..4c52955214 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.1.0", + "version": "17.2.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 ad0b32a148..858a2eae90 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,115 @@ # @objectstack/cloud-connection +## 17.2.0 + +### Minor Changes + +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [128684d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [d806081] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b39785] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [047ac86] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [13a6cb4] +- Updated dependencies [9f483d9] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/runtime@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index 488b7d943f..3307bee7ca 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.1.0", + "version": "17.2.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 92d4adb373..c268061dc9 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/connector-mcp +## 17.2.0 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 70213de48a..b9a5c77364 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.1.0", + "version": "17.2.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 7a3a089c92..77b83e8f42 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,69 @@ # @objectstack/connector-openapi +## 17.2.0 + +### Patch Changes + +- 6d5c4fa: Release these plugins' resources from `destroy()`, the teardown hook the kernel + actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and + `destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and + `LiteKernel.destroy()`, which walk the plugins in reverse calling + `plugin.destroy()`, walked straight past every plugin whose teardown was spelled + `stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still + armed, the REST/OpenAPI/Slack connectors still registered on the automation + engine, the approvals SLA escalation job still scheduled, and the knowledge + event-sync subscription still open. + + Each teardown body now lives in `destroy()`. `stop()` is retained as a + delegating alias with its parameter made optional, so an embedder that learned + to call it directly — precisely because the kernel never did — keeps working + unchanged. No export is removed and the `Plugin` interface is untouched. + + Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as + fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted + from the merge queue. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 45b2824f25..1878199797 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.1.0", + "version": "17.2.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 a577fed1a1..2e1ed04fa2 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,69 @@ # @objectstack/connector-rest +## 17.2.0 + +### Patch Changes + +- 6d5c4fa: Release these plugins' resources from `destroy()`, the teardown hook the kernel + actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and + `destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and + `LiteKernel.destroy()`, which walk the plugins in reverse calling + `plugin.destroy()`, walked straight past every plugin whose teardown was spelled + `stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still + armed, the REST/OpenAPI/Slack connectors still registered on the automation + engine, the approvals SLA escalation job still scheduled, and the knowledge + event-sync subscription still open. + + Each teardown body now lives in `destroy()`. `stop()` is retained as a + delegating alias with its parameter made optional, so an embedder that learned + to call it directly — precisely because the kernel never did — keeps working + unchanged. No export is removed and the `Plugin` interface is untouched. + + Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as + fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted + from the merge queue. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 94336a9923..a7980d95bd 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.1.0", + "version": "17.2.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 49735eee38..f01c8c486b 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,69 @@ # @objectstack/connector-slack +## 17.2.0 + +### Patch Changes + +- 6d5c4fa: Release these plugins' resources from `destroy()`, the teardown hook the kernel + actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and + `destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and + `LiteKernel.destroy()`, which walk the plugins in reverse calling + `plugin.destroy()`, walked straight past every plugin whose teardown was spelled + `stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still + armed, the REST/OpenAPI/Slack connectors still registered on the automation + engine, the approvals SLA escalation job still scheduled, and the knowledge + event-sync subscription still open. + + Each teardown body now lives in `destroy()`. `stop()` is retained as a + delegating alias with its parameter made optional, so an embedder that learned + to call it directly — precisely because the kernel never did — keeps working + unchanged. No export is removed and the `Plugin` interface is untouched. + + Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as + fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted + from the merge queue. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 30cc0b6be7..58209d8dac 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.1.0", + "version": "17.2.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 6fc263d1ca..991af1a200 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/console +## 17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 46b661cae6..7ed26afcf8 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.1.0", + "version": "17.2.0", "description": "Prebuilt Console SPA pinned to this @objectstack/framework release. 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 d5d0f67353..65e5569a2a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,188 @@ # @objectstack/core +## 17.2.0 + +### Patch Changes + +- 47cd3ec: The kernel's two `Promise.race` timeout guards — the startup guard around each + plugin's `init`/`start`, and the shutdown guard around `performShutdown()` — + now reclaim **both** halves of the guard when the race settles: the timer is + cleared *and* the losing promise is settled (#10604). + + Neither site settled its loser, so the timeout promise and the reaction + `Promise.race` held on it were retained for the life of the process — four + leaking promises per showcase test run under `vitest --detectAsyncLeaks`, now + zero. The two hand-rolled copies had also drifted into doing opposite halves of + the same cleanup: the startup site cleared its timer and never `unref`'d, the + shutdown site `unref`'d and never cleared. Both now go through one internal + `TimeoutGuard`, so they cannot drift apart again. No exported API changes. + + **Behaviour change, at the shutdown guard:** the shutdown timer is no longer + `unref()`d. Two consequences for an embedding host (CLI, auth-proxy, test + runner): + + - After a **successful** shutdown, no timer is left armed. Previously the guard + survived its own race and stayed scheduled to fire against a kernel already + `'stopped'`. That late rejection was *handled* — `Promise.race` had attached a + rejection handler to it — so this was never an unhandled-rejection risk; it + was retained work and a wakeup after teardown. + - When teardown **hangs**, the guard now actually fires. An unref'd timer does + not keep the event loop alive, so a process with nothing else to run could + exit silently — status 0, teardown incomplete — before `shutdownTimeout` + elapsed, leaving `Shutdown timed out — forcing exit` and its `exit(1)` + unreachable in exactly the case they exist for. Reclaiming on settle keeps the + guard ref'd exactly as long as the race is undecided, which is the guarantee + the startup guard already had (#4813). + + If your host relied on a hung `shutdown()` letting the process fall out of the + event loop on its own, it will now wait up to `shutdownTimeout` (default 60s) + and then hard-exit with status 1. Lower `shutdownTimeout` in the kernel config + to shorten that window. +- 9d7d2de: `resolveLocalizationContext` now memoizes a FAILED read's fallback per `(ql, tenantId, userId)` for 30s (#10221). + + On a fresh environment whose `sys_setting` table hasn't been created/migrated yet, every authenticated request re-ran the same `sys_setting` localization read, and every one of those reads failed the same way ("no such table"). The `#2409` batching had already collapsed the three per-key reads a single request used to issue into one query, but that one query still repeated on every subsequent request, and `driver-sql`'s `backendStatementFault` logs a `[sql-driver] DATABASE_ERROR` warning on every failed read — so the identical warning printed once per request and buried real errors in between. + + Only the case where the underlying read genuinely fails (a backend fault, e.g. the missing table) is cached; a successful read — including a legitimate "nothing configured yet" empty result — is never cached and always re-reads on the next call, so a settings write takes effect immediately. (An earlier version of this fix cached every outcome, mirroring `packages/plugins/plugin-audit/src/audit-writers.ts`'s existing TTL cache of this same read — safe there because audit-trail enrichment is best-effort, but not safe for `@objectstack/rest`'s use of this function: analytics date-bucketing reads the org timezone on every query and `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` — the #1982/#2018 golden regression — asserts the very next read reflects a just-written timezone.) The `UTC` / `en-US` fallback behavior itself is unchanged; this only stops the failing query — and its log line — from re-running every request. The cache is keyed on the `ql` engine instance first, so two environments/tenants sharing one process never share a cached outcome, and self-heals within one TTL window once `sys_setting` exists. +- 795ea05: A lapsed `sys_member` row now confers no org role either — one row, one answer (#10982) + + `resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it: + `accessible_org_ids` (the `group` posture's read reach, ADR-0105 D2) and the + org-administration role projection into `positions` (ADR-0095 D3). Only the + first applied the ADR-0091 validity window. A membership outside + `[valid_from, valid_until)` was therefore excluded from org access while still + projecting its better-auth role — two answers from one read, and with + `role: 'owner'` the role reaches the `organization_admin` capability that + `derivePosture` reads for `TENANT_ADMIN`. + + The role projection now drops out-of-window rows **before** the derivation, the + same shape `sys_user_permission_set` already had, so an expired membership can + no more yield `org_owner` than an expired `admin_full_access` can yield + `platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22 + live session (item 2): a lapsed membership is *no membership*, not merely *no + org access*. + + **Why `patch` and not a breaking bump, argued in the open.** This is a real + change of authorization semantics — a membership that used to confer a role + stops conferring it — so the direction is a tightening, and tightenings are the + kind of change that normally earns a major. It is nevertheless `patch` because + the population it can affect is provably empty: `sys_member` declares neither + `valid_from` nor `valid_until` (see `sys-member.object.ts`), and `isGrantActive` + reads an absent bound as unbounded, so **no row any deployment can currently + store is lapsed** and every existing membership resolves exactly as before. That + is asserted directly rather than reasoned about, in + `resolve-authz-context.test.ts` ("a membership with NO bounds is unbounded — + every shipped row is unaffected"), alongside the load-bearing leg that an + in-window membership still projects its role. Landing it now is the cheap + moment: once the columns exist, the same change becomes a migration carrying + live semantics. + + **Not in scope, and deliberately so.** This does not add the validity columns to + `sys_member`, and it does not reach into `sys_user_permission_set` rows that + plugin-security's `reconcileOrgAdminGrant` provisioned from a membership role. + Such a grant is standing authority in its own right with its own ADR-0091 + window; the role is only its provisioning source (ADR-0095 D3). The boundary is + pinned as a measured fact rather than left as an assumption. +- 504c8d5: Materialize the RBAC catalog **per organization**, so a walled deployment can + administer positions, permission sets and sharing rules again (#10103). + + On a walled deployment (`group` / `isolated`) every principal — an organization + owner and a platform admin alike — listed **zero** positions, permission sets + and sharing rules while the tables held rows. Nothing could be bound through + Setup, and a declared `hierarchy-security` could never be armed by an operator + however loudly an app declared it. + + Every row in those three tables was organization-less. plugin-security's Layer 0 + composes a strict `organization_id = :tenant` for a walled posture and the + middleware ANDs it into the read AST over the driver's + `(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the + two is the strict equality alone, so the driver's null arm was annihilated on + every authenticated read. + + **The wall is not changed, at either layer.** The rows get an owner instead: + + - `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`, + `bootstrapDeclaredPermissions` (plugin-security) and + `bootstrapDeclaredSharingRules` (plugin-sharing) upsert by + `(name, organization_id)` and run **one pass per organization** under a walled + posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`, + `guest`) included, matching `sys_user_position`, which is already + per-organization, and matching both objects' own `unique: 'organization'` name + index. + - Seeding also fires on **organization creation**, not only at `kernel:ready`, so + a tenant created after startup does not administer an empty catalog until the + next restart. + - `single` posture is **unchanged**: exactly one organization-less pass, which is + the correct shape there. + + An organization-less row is now invalid state under a walled posture. Nothing is + reaped — grants (`sys_user_position`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_record_share`) point at these rows by id, so + deleting them would revoke standing access with no signal at the moment of loss. + Instead a per-organization pass that meets pre-fix organization-less rows for + names it seeds **says so loudly**, naming the rows and the remedy, and still + creates that organization's own copies. The failure this closes is the silent + no-op: a tenant-threaded pass that sees the old row through the driver's + compatibility arm, reads the name as already represented, and creates nothing + while reporting success. + + Two enforcement-plane reads are scoped in the same change, because the exposure + they carry only exists once per-organization copies exist: + + - `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved + `sys_position` by name across **every** organization, so the junction read + behind it collected another organization's `everyone` binding — a cross-organization + grant bleed, and an O(organizations) read on the per-request path. It is now + threaded through the driver's tenant chokepoint, keeping per-request resolution + O(the caller's own organization's catalog). + - plugin-security's permission-set `dbLoader` resolved sets by name unscoped, + with a `limit` equal to the number of names — correct while one row existed per + name, a truncation the moment copies exist. It is now scoped to the caller's + organization and its bound widened. + + Boot reconciliation is O(changed declarations): each pass reads what its + organization already has and writes only where a declaration actually differs, so + the common boot performs no writes at all. Steady state rides the + organization-creation hook. + + Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization + sharing rules cheaper than the unscoped sweep they replace. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 7cab7e0463..d86d114cc7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.1.0", + "version": "17.2.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 f280be998e..0c9fa4e2c2 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,331 @@ # create-objectstack +## 17.2.0 + +### Minor Changes + +- 5a616d5: `create-objectstack` now closes with a "Created files" summary derived from a + walk of the finished project directory, so it names everything the run wrote — + including the files written after the template copy (#10323). + + The old summary was the template copy's own list, printed before + ` install` and before `npx skills add`. Measured against published + `create-objectstack@17.1.0` (`create-objectstack demo-app`, then a full walk of + the result): 12 entries printed, 18,045 paths on disk, **18,033 of them + unreachable from the summary** — `AGENTS.md`, `.github/copilot-instructions.md`, + `pnpm-lock.yaml`, `skills-lock.json`, `node_modules/`, and two ~968 KB trees of + agent instructions at `.agents/skills/` and `agent/skills/`. + + That mattered because the same run ends with the `skills` CLI printing *"Review + skills before use; they run with full agent permissions."* Advice to review + files the run never named, at paths it never showed, is advice a newcomer + cannot act on — the wrong failure direction for a security-flavoured warning. + + The list could not have been correct where it stood: two of the three write + phases belong to other processes, and the `skills` installer's destination set + moves with **its** releases, not ours. Reading the directory afterwards makes + the summary self-correcting instead. Large directories collapse to one line + carrying their path, entry count and size, so the bulk stays reviewable without + 18,000 lines of output, and the paths the skills installer created are marked + `⚠ skills` with the permissions warning tied to them. + + Same run, after the change: 20 entries printed, **0 written paths unreachable**. + +### Patch Changes + +- cec9d23: Fix `create-objectstack`'s startup banner hardcoding `◆ Create ObjectStack v6.x` + regardless of the package's real, released version — eleven majors stale, on + the first line of output a newcomer ever sees (#10325). The banner now calls + `readCliVersion()`, the same reader `.version()` already used, instead of a + literal string. + + Dropping the real version in without recomputing the box's padding would have + reintroduced the same defect one line later — the border is a fixed run of + `═` computed for the 4-character `v6.x`, and a longer real version (`v17.1.0` + is 7 characters) would push the right border out of alignment (the sibling + bug fixed in #10322, one function away in the same file). The box now derives + its width from the version string's plain length and widens the frame — never + truncates — for a version long enough to need more room; ordinary versions + still render at the historical box size. + + No behaviour change beyond the printed banner. +- 3a3f209: Tell a newcomer that the `blank` starter ships no app, so an empty Console + reads as the intended starting point rather than a broken install (#10317). + + Measured on a real scaffold-and-boot (`create-objectstack my-app -t blank`, + published 17.1.0 packages, `objectstack dev --ui`): `GET /api/v1/meta/app` + returns the two platform apps (Setup, Account) and nothing of the project's + own, while `GET /api/v1/data/my_app_note` serves the scaffolded object the + whole time. The template ships `src/objects/` only — deliberately, as every + scaffolder template in this repo does — but nothing the newcomer could reach + said so, and `pnpm dev` advertises the Console URL on every boot. + + Documentation only: a new "The Console" section in the generated `README.md` + naming the Console path, the consequence, and `src/apps/*.app.ts` as the + remedy. No change to what the scaffolder writes into `src/`. +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- 675ab57: **First-run polish:** a brand-new scaffold's very first `pnpm install` no longer reports two unmet peer dependencies (#10326). + + Reproduced on a clean scaffold from published `create-objectstack@17.1.0` — no lockfile, `node_modules` removed, nothing configured by the user — and again on the second scaffold path, `objectstack init`. Both printed the same two: + + ``` + ✕ unmet peer better-call + Installed: 1.4.0 + Wanted: + 1.3.7: + @better-auth/scim@1.7.0-rc.1 + + ✕ unmet peer better-sqlite3 + Installed: 13.0.3 + Wanted: + ^12.0.0: + better-auth@1.7.1 + ``` + + Nothing was broken — but it is the first screen a newcomer sees, and there is nothing they did to cause it or can do about it. + + **`better-sqlite3`: the pin is right and the upstream range is stale — so it is widened, not corrected.** better-auth 1.7.1 declares `better-sqlite3` as an **optional** peer at `^12.0.0`, and it governs exactly one configuration: a raw better-sqlite3 `Database` handed to better-auth's `database` option, which its Kysely dialect then drives. ObjectStack never takes that path — `AuthManager.createDatabaseConfig()` returns `createObjectQLAdapterFactory(dataEngine)`, and every `better-sqlite3` use under `plugin-auth` is knex's `client: 'better-sqlite3'` beneath ObjectQL. Measured anyway on the configuration the range *does* govern: better-auth 1.7.1 with `database: new Database(':memory:')`, running `getMigrations().runMigrations()`, `signUpEmail`, `signInEmail` and adapter `findOne`/`update`/`delete`, is green on **better-sqlite3 13.0.3** and byte-for-byte equivalent on **12.11.1**. The same probe with `Database.prototype.prepare` neutered fails, so that green is the driver's and not an unexercised path. Pinning our own `^13.0.3` declarations back to `^12` would downgrade a native module across the platform to satisfy a range measurement shows is simply behind. + + **`@better-auth/scim`: the rc pin stays, and one `better-call` copy is the correct tree.** `npm view @better-auth/scim dist-tags` reads `latest: '1.7.1'`, but stable 1.7.x ships the rc.2 whole-model rewrite, so adopting it is a separate migration rather than a version bump; the exact `1.7.0-rc.1` pin is deliberate. The rc peers an exact `better-call@1.3.7` while better-auth 1.7.1 depends on `1.4.0` — and a better-auth plugin has to share the **host's** better-call instance, so the single 1.4.0 copy every install already resolves is right, not a skew to repair. This declaration retires together with the rc pin. + + **What changed, and what deliberately did not.** Both remedies are pnpm `peerDependencyRules.allowedVersions` entries, scoped `>` so each widens exactly one declaration. They ship *inside* the scaffold — the bundled `pnpm-workspace.yaml` template and the one `objectstack init` renders — because a block in this repo's own workspace file does not travel with published packages. `allowedVersions` changes what pnpm **reports**, never what it resolves: measured on both scaffold paths, the lockfile is byte-identical with and without it (0 lines of diff), and no dependency version, range or resolution moved anywhere. This repo's own resolutions are untouched. +- e85182d: Converge the blank scaffold template's `README.md` docs links on the ruled + canonical origin, `https://objectstack.ai` (maintainer ruling, 2026-08-21: + 「这个仓的文档站规范 URL 是 https://objectstack.ai」; enforced by + `CANONICAL_DOCS_ORIGIN` in `scripts/check-published-readme-links.mjs`). The + template previously linked the accepted-but-unratified `docs.objectstack.ai` + alias in three places, which disagreed with the root `README.md`'s already- + canonical spelling — so a single `npm create objectstack@latest` run handed + the user two different hostnames for the same docs site. +- aea1e64: Fix the declared bin (`bin/create-objectstack.js`) being tracked non-executable + in git. It carries a `#!/usr/bin/env node` shebang and is pnpm's link target + for the `create-objectstack` command, but was committed `100644` instead of + `100755` — matching the sibling declared bin `packages/cli/bin/run.js`, which + was already tracked executable. + + Patch bump: this is a packaging-mode correction with no content, API or + behavior change (the blob hash is identical) — it only fixes how the file is + tracked in git and therefore how it is packed for npm. +- 818e027: Fix `objectstack init`'s closing "Created files" summary omitting `pnpm-lock.yaml` / `package-lock.json` and `node_modules/` (#10557). + + The summary used to be printed from a list accumulated while the template + files were written — before ` install` ran — so it could never name what + the package manager wrote. `init` now prints it after the install attempt + (succeeded or failed) from a walk of the finished project directory, reusing + `create-objectstack`'s `created-summary.ts` (now published as the + `create-objectstack/created-summary` subpath) instead of a second copy of the + same renderer. +- afe1c4e: fix(cli): declare the four `@better-auth/utils` peer skews a freshly scaffolded project reports (#10931) + + Both scaffold paths emit a `peerDependencyRules.allowedVersions` block whose + stated purpose is that a brand-new project's first `pnpm install` does not open + with a peer-skew report. It declared two skews and left four showing: + + ``` + ├─┬ @better-auth/core 1.7.1 + │ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 + ├─┬ @better-auth/scim 1.7.0-rc.1 + │ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 + ├─┬ @better-auth/oauth-provider 1.7.1 + │ └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 + └─┬ @better-auth/sso 1.7.1 + └── ✕ unmet peer @better-auth/utils@0.4.2: found 0.5.0 + ``` + + `@better-auth/core`, `/oauth-provider`, `/scim` and `/sso` each peer an **exact** + `@better-auth/utils@0.4.2`. The 0.5.0 they are handed comes from + `better-call@1.4.0` — better-auth's own HTTP layer — which *depends* on + `^0.5.0`; `@objectstack/plugin-auth` names the four as direct dependencies + without naming utils, so pnpm satisfies their peer from better-call's copy + instead of better-auth's own exact 0.4.2 dependency. + + **Measured compatible before widening, not assumed.** Those four import three + symbols in total: `base64`/`base64Url` (`@better-auth/utils/base64`), + `createHash` (`/hash`) and, in core only, `createRandomStringGenerator` + (`/random`). 0.5.0 declares all three with identical signatures; `/random` is + unchanged apart from formatting, `/base64` swaps `new Uint8Array(data)` for a + helper that *is* `new Uint8Array(data)` on non-strings, and `/hash` only widens + its input coercion for views not backed by a plain `ArrayBuffer`. Run against + the input shapes those call sites actually pass, the two versions agree on every + value; run end to end — better-auth with the `sso`, `oauth-provider` and `scim` + plugins — a tree where the four resolve 0.5.0 and one where they resolve 0.4.2 + produce the same transcript: sign-up, sign-in, session, both OAuth metadata + documents, the RFC 7636 PKCE challenge, and the SCIM and SSO endpoint outcomes. + + A resolution change was measured too, and rejected: pinning utils back to 0.4.2 + clears the four lines only by dragging `better-call@1.4.0` off its own declared + `^0.5.0` — manufacturing one real range violation to silence four benign ones. + + Four scoped entries, one per declaring package, matching the block's convention + that each rule widens exactly one declaration. `allowedVersions` suppresses the + report only: the lockfile a scaffold resolves is byte-identical with and without + the block. The version is spelled `0.5.0` exactly rather than `0.5`, so a future + `0.6.0` reports again instead of inheriting this finding. + + Both scaffold paths — `objectstack init` (rendered by the CLI) and + `npx create-objectstack` (a copied template file) — are changed together, and + `packages/cli/test/scaffold-workspace-consistency.test.ts` gains a limb that + compares the peer maps the two produce, so they cannot drift apart again. +- 568de19: Scaffolded projects declare an explicit empty `packages: []` in their + `pnpm-workspace.yaml` (#10933). Both scaffold paths render it — + `renderPnpmWorkspaceYaml` in `objectstack init`, and the bundled `blank` + template `npx create-objectstack` copies. + + The file was deliberately keyless so it would act purely as a settings file. + That intent is now written down rather than inferred from a missing key, and + writing it down is what fixes a first-command failure: pnpm 9.x and 10.0–10.4 + parse `pnpm-workspace.yaml` **before** they read `engines`, so they refused a + brand-new project outright with + + ``` + ERROR packages field missing or empty + ``` + + naming a file the user never wrote and giving no hint that the cause is their + pnpm version — and no `engines.pnpm` floor could reach them, because they never + got as far as the engines check. Measured, one clean install per pnpm version, + each with its own store: + + | pnpm | before | after | + |---|---|---| + | 9.15.9, 10.0.0, 10.4.0 | `ERROR packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming `>=10.15` | + | 10.5.0–10.14.0 | `ERR_PNPM_UNSUPPORTED_ENGINE` | unchanged | + | 10.15.0, 10.34.5, 11.22.0 | installs | installs, byte-identical `pnpm-lock.yaml` | + + So every unsupported pnpm now reports the same actionable cause, and supported + pnpm is unaffected: the empty key was measured equivalent to omission on + 10.15.0, 10.34.5 and 11.22.0 — identical lockfile bytes, identical + `node_modules/.modules.yaml` once the run-local `prunedAt`/`storeDir` fields are + dropped, identical `pnpm ls -r --depth -1`, and an identical second-install + "Already up to date". + + The declaration is an **empty** list on purpose. `packages: ['.']` satisfies the + same parsers but declares the project root a workspace *member* — a monorepo + root — which a single-package scaffold is not, and which reads to the next + author (human or AI) as an invitation to add member packages to an app. + + `engines.pnpm` is unchanged at `>=10.15`. +- 8d21f7a: Fix `create-objectstack`'s closing "Next steps" and install-failure remedy + hardcoding `npm` regardless of which package manager the run actually used + (#10322). `detectPackageManager()` already prefers `pnpm` and falls back to + `npm` only when `pnpm` is unreachable — confirmed still true at HEAD, and + confirmed empirically: a real run with `pnpm` on `PATH` installs with `pnpm` + (`pnpm-lock.yaml`, "Done in … using pnpm vX") and then told the newcomer to + run `npm run dev` / `npm run validate` next, a package manager the run never + touched. The detected package manager is now read once, up front, and reused + consistently for the install command, the install-failure remedy, and every + line of "Next steps" — so the printed guidance always names the tool the run + actually used, in both the `pnpm` and the `npm`-fallback case. + + Also names `validate` — the step the generated `AGENTS.md` calls + unskippable — in the "Getting started" section of the generated `blank` + template's README, not only in its later "Verify your changes" section, so a + newcomer reading top-to-bottom sees it at first touch. + + No install behaviour changes: the scaffolder still installs by default and + still supports `--skip-install`; this is a messaging-only fix. +- 9d101d2: Declare a pnpm floor (`engines.pnpm: ">=10.15"`) in the `package.json` both + scaffolders write, so an unsupported pnpm reports its own version instead of an + error about a file the user never wrote. + + Both scaffold paths emit a settings-only `pnpm-workspace.yaml` with no + `packages:` key. Early pnpm 10 refuses that file outright — `pnpm install` exits + 1 with `ERROR packages field missing or empty` before resolving a single + dependency, so a brand-new project could not be installed at all. Measured on + the rendered shape, one clean install per pnpm version, each with its own store: + + | pnpm | before | after | + | --- | --- | --- | + | 10.0.0 – 10.4.0 | `packages field missing or empty` | unchanged — see below | + | 10.5.0 – 10.14.0 | `packages field missing or empty` | `ERR_PNPM_UNSUPPORTED_ENGINE`, naming the expected range | + | >= 10.15.0 | installs | installs | + + The floor is a diagnosis, not a repair: pnpm 10.0.0–10.4.0 parse + `pnpm-workspace.yaml` *before* they read `engines`, so they still print the raw + workspace error. Closing that remaining sliver requires deciding what a + single-package scaffold should declare under `packages:`, which is tracked + separately and deliberately not decided here. + + `engines.pnpm` rather than a `packageManager` stamp: npm, yarn and bun ignore + `engines.pnpm` entirely, so the scaffold keeps working for all four package + managers `objectstack init` hands off to. A `packageManager: "pnpm@x.y.z"` stamp + would declare the project pnpm-only (corepack-driven yarn refuses to run in such + a project) and pin one exact version that goes stale on every pnpm release — and + it buys nothing on 10.0–10.4, which reach the workspace error before reading + that field either. + + No existing project is affected; this only changes what a newly scaffolded + `package.json` contains. +- 6d441e4: Correct the pnpm boundary the blank template states for `allowBuilds`, and gate + the two scaffold paths against each other (#10498, #10499). + + `packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml` is copied + verbatim into every scaffolded project, so its header comment is prose that + ships **inside the user's own repository**. It said `allowBuilds` needs + pnpm >= 10.31 and that `onlyBuiltDependencies` covers pnpm 10.0–10.30. Measured + on a probe depending on `esbuild@0.28.2`, with a workspace file carrying only + `allowBuilds`, one clean install per pnpm version and each with its own + `--store-dir` (isolation matters — pnpm's side-effects cache will otherwise hand + a later run a build an earlier run performed, and it reads as "the key worked"): + + | pnpm | `allowBuilds` alone | + |:--|:--| + | 10.15.0 – 10.25.0 | ignored — build not run | + | **10.26.0** | **honoured — build ran** | + | 10.28.0 – 10.33.0 | honoured — build ran | + + So the floor is 10.26.0 and the older-key band is 10.0–10.25. A user on pnpm + 10.28 was being told by the file in front of them that their pnpm cannot read + the key it is in fact reading. Both load-bearing claims in that comment were + correct and are unchanged: both keys are needed, and pnpm 11 reads only + `allowBuilds`. No setting, no assertion and no install behaviour changes — the + rendered `onlyBuiltDependencies` / `allowBuilds` values are byte-identical. + + The reason it was wrong for so long is the second half of this change. + `objectstack init` renders the same file from `renderPnpmWorkspaceYaml()` in + `packages/cli`, it was corrected to the measured numbers separately, and each + package's ratchets are package-local — so neither could ever fail for the other + file's regression, and the two scaffold paths shipped contradictory prose about + the same rule with every gate green. `packages/cli/test/scaffold-workspace-consistency.test.ts` + now compares the two **rendered outputs**: the packages each key actually grants + a build to, and the pnpm versions each file actually names for each key. It was + confirmed failing against the live divergence before this correction landed. + + Bumped `patch` rather than left out: the corrected text is user-visible — it is + delivered into every new project — while nothing executable moves. +- ecd06f6: Rewrite the scaffolded project's starter comments so a newcomer can actually + follow them (#10324). `objectstack.config.ts` and `src/objects/note.object.ts` + are the first two files opened after scaffolding, and between them they cited + four ADR identifiers, one bare issue number and the path of a release-time + script in this monorepo — none of which ship in, or are linked from, a + scaffolded project. `// per ADR-0097` read as a reference the reader was + failing to follow rather than as the context it was meant to be. + + The explanations are kept and made self-contained; only the dead ends are + gone. Each now states the fact the identifier stood for — the protocol range + is checked before anything loads and was stamped to match the installed + version rather than hand-tuned; `automation` must stay whenever `plugins:` + lists a connector or the executors have nowhere to register; a declarative + `mcp` stdio transport is denied by default; the org-wide default is required + so the baseline is an authored decision — and points at the public docs page + that covers it in full. The blank `Dockerfile` likewise stops pointing at a + file in this repo and points at the self-hosting guide it already links. + + A pin (`starter-comments-self-contained.test.ts`) keeps it that way from both + sides: no shipped template file may cite an ADR identifier, a bare issue + number or a repo script path, and the facts those references carried must + still be stated — so the comments cannot be "fixed" by deleting them. It also + resolves every canonical-origin docs URL in the shipped tree against + `content/docs`, because a link that 404s is the same defect one level out. + ## 17.1.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 9d62902c8f..db1f795189 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.1.0", + "version": "17.2.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 727fd2f092..815b75091b 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/driver-memory +## 17.2.0 + +### Patch Changes + +- 6936d07: `engine.aggregate` honours a per-aggregation `filter` (#10576, the contract + half of #10413). `AggregationNodeSchema.filter` — declared since #4286 but + marked experimental and enforced by nothing — is now live with SQL + `FILTER (WHERE …)` semantics: the predicate narrows the SOURCE rows that one + aggregation reads while sibling aggregations in the same call keep seeing + every row of the group, so a measure-scoped filter (`stage: 'closed_won'`) + can finally reach the engine instead of being silently dropped (the #10413 + wrong-numbers defect on the ObjectQL analytics path). The + `StrategyContext.executeAggregate` bridge (`@objectstack/spec/contracts`) + gains the same optional `filter` on its aggregation entries so analytics + strategies can lower measure filters into it (#10413 phase 2 consumes this + seam next). + + Execution is the correct-first two-tier shape date bucketing and HAVING use: + the engine lowers filtered aggregations in memory for every driver (unknown + operators refuse loudly with `INVALID_FILTER`/400 naming the aggregation + position; a group emptied by its filter answers the ruled empty-group values + — count/sum 0, avg/min/max null). No driver compiles conditional aggregation + natively today, so each native aggregate face (driver-sql — inherited by + driver-sqlite-wasm and Turso local —, the Turso remote transport, + driver-mongodb's pipeline builder, driver-memory's `performAggregation`) + refuses a directly-delivered per-aggregation filter with + `NOT_IMPLEMENTED`/501 instead of silently aggregating the unfiltered rows. + Aggregations without a `filter` are byte-identically unchanged, including + their native pushdown path. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 5ed92bf491..52a872a036 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.1.0", + "version": "17.2.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 ee4817e470..bf8dd819c1 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/driver-mongodb +## 17.2.0 + +### Patch Changes + +- 6936d07: `engine.aggregate` honours a per-aggregation `filter` (#10576, the contract + half of #10413). `AggregationNodeSchema.filter` — declared since #4286 but + marked experimental and enforced by nothing — is now live with SQL + `FILTER (WHERE …)` semantics: the predicate narrows the SOURCE rows that one + aggregation reads while sibling aggregations in the same call keep seeing + every row of the group, so a measure-scoped filter (`stage: 'closed_won'`) + can finally reach the engine instead of being silently dropped (the #10413 + wrong-numbers defect on the ObjectQL analytics path). The + `StrategyContext.executeAggregate` bridge (`@objectstack/spec/contracts`) + gains the same optional `filter` on its aggregation entries so analytics + strategies can lower measure filters into it (#10413 phase 2 consumes this + seam next). + + Execution is the correct-first two-tier shape date bucketing and HAVING use: + the engine lowers filtered aggregations in memory for every driver (unknown + operators refuse loudly with `INVALID_FILTER`/400 naming the aggregation + position; a group emptied by its filter answers the ruled empty-group values + — count/sum 0, avg/min/max null). No driver compiles conditional aggregation + natively today, so each native aggregate face (driver-sql — inherited by + driver-sqlite-wasm and Turso local —, the Turso remote transport, + driver-mongodb's pipeline builder, driver-memory's `performAggregation`) + refuses a directly-delivered per-aggregation filter with + `NOT_IMPLEMENTED`/501 instead of silently aggregating the unfiltered rows. + Aggregations without a `filter` are byte-identically unchanged, including + their native pushdown path. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index 01391b204a..92cc662e6d 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.1.0", + "version": "17.2.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 5141ecb25d..bb1e069e84 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,219 @@ # @objectstack/driver-sql +## 17.2.0 + +### Minor Changes + +- 95437e7: fix(driver-sql): `introspectSchema()` emits the spec introspection contract — `primaryKey`, `dialect`, `introspectedAt` (#10676, #10998) + + **BREAKING** change to the value `SqlDriver.introspectSchema()` returns, shipped + as `minor` under the repo's launch-window convention for breaking changes. + + `packages/spec/src/contracts/schema-diff-service.ts` declares one introspection + contract. The driver declared a second one beside it and, separately, so did + `packages/objectql/src/util.ts`. The three agreed on the idea and disagreed on + the vocabulary: the driver spelled a column's primary-key membership + `isPrimary`, the spec spells it `primaryKey`; the spec declares `dialect` and a + REQUIRED `introspectedAt` that the driver's schema type never mentioned and + `introspectSchema()` therefore never emitted. Nothing was type-unsound — each + side compiled against its own declaration and the value crossed between them + with no compiler in the middle. + + Measured on a live in-memory SQLite database before this change: the id column + of a `primary key (id)` table came back carrying `isPrimary: true` with no + `primaryKey` key at all, and `Object.keys()` of the schema was `["tables"]`. + Two consequences, both silent: + + - `ExternalDatasourceService.generateObjectDraft` reads `col.primaryKey`, so + every federated object drafted from a real remote table lost the remote + primary key — the addressing key for the federated table, dropped by the + codegen meant to produce it (#10676). + - type mapping ran with `dialect: undefined` across the whole federation path, + making every per-dialect alias in `suggestFieldTypeForSqlType` unreachable + there, and `refreshCatalog` persisted `dialect: undefined` into the + `external_catalog` record Studio's schema browser and the boot gate read + back (#10998). + + Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 = + 驱动侧对齐 spec 契约): `packages/spec` is the one contract and the driver + aligns to it. + + What the driver now returns: every column carries the boolean `primaryKey`, the + schema carries `dialect` and `introspectedAt`, and the retired `isPrimary` + member is gone rather than emitted alongside — one spelling, so no consumer can + key off the wrong one again. `dialect` is the driver's canonical dialect name + (`sqlite`, `postgres`, `mysql`, `unknown`), which is the vocabulary the only + in-tree consumer keys its alias tables on; `introspectedAt` is an ISO 8601 + instant stamped before the reads begin. + + `IntrospectedColumn`, `IntrospectedTable` and `IntrospectedSchema` in both + `@objectstack/driver-sql` and `@objectstack/objectql` are now derived from the + spec contract instead of re-declared, so a key added there fails their `tsc` + until the producer emits it. Two divergences are kept explicitly: `defaultValue` + stays `unknown` at the SQL layer because Knex reports `null`, and `indexes` is + omitted rather than emitted empty because this driver does not introspect + indexes and an empty array would tell a schema differ that a table has none. + + TypeScript consumers of the removed member are told by the compiler, precisely + and at every site: `Property 'isPrimary' does not exist on type + 'IntrospectedColumn'`. + + + +### Patch Changes + +- 6936d07: `engine.aggregate` honours a per-aggregation `filter` (#10576, the contract + half of #10413). `AggregationNodeSchema.filter` — declared since #4286 but + marked experimental and enforced by nothing — is now live with SQL + `FILTER (WHERE …)` semantics: the predicate narrows the SOURCE rows that one + aggregation reads while sibling aggregations in the same call keep seeing + every row of the group, so a measure-scoped filter (`stage: 'closed_won'`) + can finally reach the engine instead of being silently dropped (the #10413 + wrong-numbers defect on the ObjectQL analytics path). The + `StrategyContext.executeAggregate` bridge (`@objectstack/spec/contracts`) + gains the same optional `filter` on its aggregation entries so analytics + strategies can lower measure filters into it (#10413 phase 2 consumes this + seam next). + + Execution is the correct-first two-tier shape date bucketing and HAVING use: + the engine lowers filtered aggregations in memory for every driver (unknown + operators refuse loudly with `INVALID_FILTER`/400 naming the aggregation + position; a group emptied by its filter answers the ruled empty-group values + — count/sum 0, avg/min/max null). No driver compiles conditional aggregation + natively today, so each native aggregate face (driver-sql — inherited by + driver-sqlite-wasm and Turso local —, the Turso remote transport, + driver-mongodb's pipeline builder, driver-memory's `performAggregation`) + refuses a directly-delivered per-aggregation filter with + `NOT_IMPLEMENTED`/501 instead of silently aggregating the unfiltered rows. + Aggregations without a `filter` are byte-identically unchanged, including + their native pushdown path. +- 46cfa5b: **Bug fix:** on Postgres, index and schema introspection now resolve tables the way the session does, instead of assuming the `public` schema (#9350). + + `introspectIndexes` pinned `n.nspname = 'public'` and `introspectSchema` pinned `table_schema = 'public'`. For a driver whose connection carries a `searchPath` pointing anywhere else, both returned **empty** — not an error, an empty result. Measured on a live Postgres 16: for a table carrying a primary key *and* a declared unique index, `introspectIndexes` returned `[]` and `introspectSchema` listed no tables at all. + + Empty does not read as "I could not see" downstream; it reads as "there are no indexes". `assertConflictTargetHonoured` turns that into a refusal, so an `upsert` against a perfectly well-indexed table would be rejected with *no PRIMARY KEY or UNIQUE index backs them* — and index-drift detection would propose creating indexes that already exist. + + - `introspectIndexes` now resolves the table with `to_regclass(?)` and reads `pg_index` by OID. That is the same resolution every other statement in the session performs — first match along `search_path` — and it removes an ambiguity a schema list would introduce, since two schemas on the path can hold the same table name and only one of them is the one a query reaches. + - `introspectSchema` now lists `table_schema = ANY (current_schemas(false))`. + + **No change for a default deployment.** With the default `search_path`, `current_schemas(false)` is exactly `{public}` and `to_regclass` resolves into `public`, so both queries return what they returned before. The behaviour only differs where the old queries returned nothing. +- a037f7c: Fix JSON-field writes on Postgres deployments that manage DDL out-of-band + (`skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`): a non-empty array and a bare + string were rejected with a 500, and an empty array was **silently stored as an + empty object** (#10995). + + The SQL driver does `JSON.stringify` a JSON field's value on every non-SQLite + dialect — but only for fields listed in its per-object `jsonFields` registry, + and that registry (like the boolean / numeric / date / datetime / time / + auto_number registries and the tenant-isolation column) was filled **only** as + the first step of a DDL call. A deployment that skips boot schema sync therefore + served every write knowing nothing about its objects, and values reached + node-postgres to be encoded by its per-type defaults: + + - an **object** became JSON text — accidentally correct; + - an **array** became a Postgres ARRAY LITERAL (`{…}`) — `22P02 invalid input + syntax for type json`, a 500 on every write; + - **except `[]`**, whose array literal `{}` is valid JSON, so an empty array was + accepted and stored as an empty **object** — corruption, not an error; + - a **bare string** was passed raw (`x` is not JSON text, `"x"` is) — a 500, + while a number survived because `42` already is valid JSON. + + SQLite never showed any of it: `formatInput` ends with a bind-safety net gated + on that dialect, so the same empty registry is invisible there — which is why + tenant environments on Turso/SQLite and the suites that run on them were blind + to a defect live on every Postgres deployment. + + The registration is now separable from the DDL, on the ruling #7737/#10629 + already made for federated objects — that flag is about DDL, and a binding that + is DDL-free must not ride on it: + + - `SqlDriver.registerObjectMetadata(objects)` installs a managed object's + coercion metadata with no `CREATE TABLE`, no `ALTER TABLE`, no existence probe + and no round-trip — the managed sibling of `registerExternalObject`, declared + optional on `IDataDriver` so drivers that don't need it omit it; + - a `skipSchemaSync` boot (and metadata reload) now takes that route instead of + doing nothing, keeping the cold-start budget the flag exists to protect; + - `initObjects` registers before the ADR-0015 DDL gate refuses, so objects on a + datasource ObjectStack is only a guest in are encoded from their declared + field types too. The refusal itself is unchanged. +- f59035c: SQLite introspection now reports every member of a composite primary key, in + declared key order. `SqlDriver.introspectPrimaryKeys` filtered + `PRAGMA table_info` rows on `row.pk === 1`, but SQLite does not report `pk` as + a boolean — it is the column's **1-based position within the primary key** + (`0` = not part of the key, `1` = first key column, `2` = second, and so on). + The filter therefore kept only the first member of a composite key and silently + dropped the rest. + + Measured on in-memory SQLite, table declared `primary key (order_id, line_no)`: + + | signal | reported | reports instead | + | --- | --- | --- | + | `table.primaryKeys` | `['order_id']` | `['order_id', 'line_no']` | + | `column.isPrimary` for `line_no` | `false` | `true` | + + Both signals were wrong together and for the same reason: `introspectSchema` + derives `col.isPrimary` from `primaryKeys`, so a consumer could not recover the + dropped member by cross-checking the two. Fixing the list repairs the flag with + it. + + The rows are now also ordered by the `pk` ordinal rather than taken in + `table_info` row order (which is *column* order). The two differ whenever a key + is declared out of column sequence — a table with columns + `(carrier_code, shipment_id, leg_seq)` and `primary key (shipment_id, + carrier_code)` now reports `['shipment_id', 'carrier_code']` — and + `primaryKeys` is consumed as an addressing / upsert-conflict-target key, where + the order is load-bearing. + + Consumers affected: the federated-object codegen and the persisted + `external_catalog` (ADR-0015) recorded a partial addressing/upsert key, and + schema-drift comparison against a declared composite key read as drift on the + dropped member. `SqliteWasmDriver` and `TursoDriver` extend `SqlDriver` and + override neither method, so they inherit the repair. The Postgres and MySQL + arms did not have this defect and are unchanged. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/observability@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index ee6a6fa9b0..bcb6e0498b 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.1.0", + "version": "17.2.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 24621f7edf..96c9693eba 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/driver-sqlite-wasm +## 17.2.0 + +### Patch Changes + +- 6ceaa4b: docs: name packages that exist in seven published documents, and gate the class (#10893) + + A published README ships inside the npm tarball, so an install instruction in one + reaches every reader of the package. Nine `@objectstack/` names across seven + published documents named a package that is in **no directory of this repo**, and + five of those sat on `import` lines inside runnable fences. + + `check:published-readme-exports` could not see any of it, by construction. It + resolves a documented import against the package's built type surface through the + workspace member map, so a specifier that is not a member has no type entry to + compare against and the gate reads no further — strict about a member that exists, + silent about one that does not. The gate now makes the member-existence claim + first: an `@objectstack/`-scoped specifier that names no workspace member is a + finding, and the run header prints the scoped population as `N/N` so a recogniser + that stops matching shows up as a denominator that fell. + + What each dead claim now says, and why: + + - **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`** + each misnamed **themselves**. Both READMEs — including their `# ` titles and + every fenced import — said `@objectstack/plugin-trigger-…`, a name that has + never been published. The exported class names (`ScheduleTriggerPlugin`, + `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all + along; only the package name was wrong, so this is a rename pinned by each + package's own `name` field. + - **`@objectstack/plugin-security`** told readers to `install + @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No + such package exists. The organization wall ships as the enterprise + `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the + `org-scoping` service this plugin probes — the name `objectstack serve` and + `objectstack doctor` both print. Asking for the wall without it is a refusal to + boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The + tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the + cloud control-plane runtime from the separate `cloud` repository and not where + the wall comes from either. + - **`@objectstack/service-package`** described packages being "delivered to + runtime kernels that load them through `@objectstack/service-marketplace`". That + package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future + work. The loading half that does exist here is + `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`. + - **`@objectstack/embedder-openai`** had a fenced example importing + `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape, + because a reader pastes it. No knowledge adapter in this repository consumes an + `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder + option, and the adapters the contract is written for are not here. The example + is now the `embed()` surface that does exist, with the gap stated rather than + papered over with a substitute package name. + - **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against + `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has + ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite + through Knex, choosing the client from its optional peers. + - **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code + generators to write `import { User } from '@objectstack/protocol'`. The package + is `@objectstack/spec`, which the same sentence names as the path being + replaced. + + Four `@objectstack/` names that are **not** in this repo are deliberately left as + they are, because prose may name a package this repo does not build and a runnable + import may not: `@objectstack/security-enterprise` (the enterprise edition, whose + install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant` + (the cloud runtime), `@objectstack/framework` (the umbrella install name), and the + two names `service-datasource`'s README recalls as its own past. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [95437e7] +- Updated dependencies [46cfa5b] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [f59035c] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/driver-sql@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index 4211f447eb..594b5bcb40 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.1.0", + "version": "17.2.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 4234e8c805..62702ad9d2 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,80 @@ # @objectstack/driver-turso +## 17.2.0 + +### Patch Changes + +- 6936d07: `engine.aggregate` honours a per-aggregation `filter` (#10576, the contract + half of #10413). `AggregationNodeSchema.filter` — declared since #4286 but + marked experimental and enforced by nothing — is now live with SQL + `FILTER (WHERE …)` semantics: the predicate narrows the SOURCE rows that one + aggregation reads while sibling aggregations in the same call keep seeing + every row of the group, so a measure-scoped filter (`stage: 'closed_won'`) + can finally reach the engine instead of being silently dropped (the #10413 + wrong-numbers defect on the ObjectQL analytics path). The + `StrategyContext.executeAggregate` bridge (`@objectstack/spec/contracts`) + gains the same optional `filter` on its aggregation entries so analytics + strategies can lower measure filters into it (#10413 phase 2 consumes this + seam next). + + Execution is the correct-first two-tier shape date bucketing and HAVING use: + the engine lowers filtered aggregations in memory for every driver (unknown + operators refuse loudly with `INVALID_FILTER`/400 naming the aggregation + position; a group emptied by its filter answers the ruled empty-group values + — count/sum 0, avg/min/max null). No driver compiles conditional aggregation + natively today, so each native aggregate face (driver-sql — inherited by + driver-sqlite-wasm and Turso local —, the Turso remote transport, + driver-mongodb's pipeline builder, driver-memory's `performAggregation`) + refuses a directly-delivered per-aggregation filter with + `NOT_IMPLEMENTED`/501 instead of silently aggregating the unfiltered rows. + Aggregations without a `filter` are byte-identically unchanged, including + their native pushdown path. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [95437e7] +- Updated dependencies [46cfa5b] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [f59035c] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/driver-sql@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 121c153053..5321dbb087 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.1.0", + "version": "17.2.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 b7dd106749..d0099e4dd8 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,46 @@ # @objectstack/formula +## 17.2.0 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 9e46f6653f..76724b47a7 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.1.0", + "version": "17.2.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 5f9b0a9191..f4ec22fe5d 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,231 @@ # @objectstack/lint +## 17.2.0 + +### Minor Changes + +- 78818ec: Report an unparseable source instead of scoring it CLEAN (#10653). + + Four validators parsed authored source with `ts.createSourceFile` and never read + `parseDiagnostics`. That call **cannot throw**, so a source with syntax errors + came back as a tree built by error recovery, got walked like any other, and + produced no findings — a source the validator could not read, reported as a + source with nothing to report. Two of the sites carried a `try/catch` around the + parse that never once ran. + + Each now reports what it could not read, as a finding the author receives rather + than as an exit — a publish-time validator is handed metadata by someone else, + so ending the process on their input is not its call. Four new advisory + (`warning`) rule ids, all additive: every finding these rules produce today they + still produce, including from a partially recovered tree. + + - `react-page-source-unparseable` — `kind:'react'` page source + (`validateReactPageProps`) + - `startup-source-unparseable` — plugin source (`findStartupRegistryVerdicts`) + - `hook-body-source-unparseable` — L2 hook body (`validateHookBodyWrites`) + - `action-body-source-unparseable` — L2 action body (`validateActionBodyWrites`) + + New exports: the four rule-id constants, plus `describeParseFailure`, + `PARSE_FAILURE_HINT` and the `SourceParseFailure` / `CheckedParse` / + `CheckedParseOptions` types. `ExtractedHookBodyWriteSet` gains an optional + `parseFailure`, so a consumer of the extractor can tell "wrote nothing" from + "could not be read" — the distinction that was missing. + + Nothing is removed or renamed, and no source that parses gains a finding. A + stack whose authored sources all parse lints exactly as before; one carrying a + source with a syntax error gains a warning that names the file, line and column + instead of silently skipping the checks. +- def0d3e: Runtime publish-gate findings for collection-resident write types (`object` / + `permission` / `book`) now key the top-level collection entry in + `issues[].path` / `advisories[].path` by NAME — + `objects.acme_invoice.sharingModel` — instead of by the gate's private + per-write snapshot index (`objects[417].sharingModel`), which no caller could + resolve: that index numbered an in-memory array a Studio / MCP / REST receiver + has never seen. Single-member write types keep their trivially-stable + positional form (`flows[0].nodes[1]…`), and nested positions inside one named + item (`objects.acme_invoice.indexes[1]`) stay positional — they index the + author's own document. An entry with no splice-safe name falls back to the + positional spelling. The accepted metadata set is unchanged; only the spelling + of the emitted finding `path` changes, and `RuntimeAuthoringIssueSchema.path`'s + description now states the convention. CLI (`os validate` / `os lint`) output + is unchanged — there the index resolves against the author's own config file. +- e2bb237: The SORT axis now asks the #8116 provenance question about a name the blanket + `SYSTEM_FIELDS` union told it not to flag — new rule `sort-field-unprovisioned` + (#10474), the twin of `searchable-field-unprovisioned` on the identical index + (#8404). + + `validate-sortable-fields` consulted the union and stopped there, so a list view + ordering by a registry-injected anchor on an ADR-0015 `external` object was + skipped in silence. The #8999 consumer census recorded that gap with the reason + that such an object never reaches the union branch at all — skip (2) was believed + to catch it. **That reason was measured wrong.** `declaredFieldTarget` returns + `null` on exactly one condition (`fields` missing, unreadable, or naming + nothing) and nothing in it tests `external`, so the shipped shape — a federated + object that declares a mapped field map, as `examples/app-showcase`'s + `showcase_ext_customer` does — is indexed like any other object and lands + squarely in the skip. The census ledger entry now carries the correction rather + than the inherited reason. + + Why the authoring gate is the only door available for it: both runtime doors on + this axis judge `formula` alone (`UNMATERIALIZED_SORT_TYPES`) — the REST ingress + `assertSortFieldsExist` (#6994) and the engine's `assertOrderByIsMaterializable` + (#7095). An injected anchor is a `datetime` or `lookup`, it *is* in `gate.known` + because the registry injected it into the served schema, and it is undotted, so + it clears every verdict and reaches the driver. Measured with a real `SqlDriver` + over better-sqlite3, the object declared exactly as the showcase declares it, + against a remote `customers` table carrying `[id, name, email, region, + lifetime_value]` and none of the seven injected anchors: + + ``` + orderBy name asc -> [c1,c2,c3] desc -> [c3,c2,c1] (a real column: reverses) + orderBy created_at asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error + orderBy owner_id asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error + ``` + + `asc` and `desc` byte-identical while the baseline reverses is what makes it a + dropped sort rather than a coincidence — the same signature this rule already + records for `formula`, reached by a second route, except that a formula sort is + refused at both doors and this one is not. A list view ordered by an anchor with + no storage answers `200` with the rows in the driver's arbitrary order, on the + view's first fetch and every fetch after it, which `limit`/`offset` then slice + into an arbitrary page. + + `warning`, never `error` and never gating (#4330's cost asymmetry, the call every + sibling makes): the remote schema is invisible to this pass, so the remote table + may genuinely carry a `created_at` of its own. Declaring that column — the first + remedy the shared hint prescribes — silences the finding, because + `unprovisionedInjectedColumnsFor` excludes an author-declared column of the same + name (#7859's security direction). The runtime publish gate sorts on severity, so + this lands as an advisory and refuses no write. + + Two deliberate narrowings, both pinned: + + - **Undotted names only** — the one place this axis departs from the SEARCH twin. + `resolveSearchFields` matches by exact string and drops a dotted entry like a + typo, but a dotted SORT name is refused by the ingress gate as its own verdict + (`400 INVALID_SORT`, loudly, on every fetch), so the silent degradation this + finding reports cannot happen there. Answering would give the SORT axis its own + dotted verdict, which is exactly the posture the rule shares with the FILTER + and PROJECTION axes (#4256 / #7532 / #7589) and declines to break. + - **`checkSortDeclaration`'s new anchor-index parameter is optional**, with the + same meaning `checkSearchableFieldList`'s carries: an out-of-repo caller that + never built the index keeps its pre-#10474 answers. Every in-repo caller passes + it. + + Also re-ruled, with fresh eyes and on evidence rather than inheritance: + `validate-translation-references` still correctly asks nothing. It reads the + union at exactly one site (the `fields.` orphan test), and the key it + decides about is derived from the *registered* metadata, into which the registry + injects the anchor on a federated object just as on a local one — so the key + resolves and the label renders. Warning there would flag a translation that + works. The blank-column consequence belongs to the surface that renders the + anchor (`validate-page-field-bindings`, #8340), not to the bundle that names it. +- adbcbfd: feat(lint): the two list-view field rules reach a standalone list view at the runtime publish gate — `view` writes are now judged by `validateSearchableFields` and `validateSortableFields` (#9313) + + An `active`-state `view` save through `saveMetaItem` (Studio, REST `/meta` item + CRUD, an MCP/AI author) is now refused with the existing 422 `invalid_metadata` + envelope when its list view declares a `sort` or `searchableFields` entry the + bound object cannot honor — an unknown field name, a virtual (`formula`) sort + target with no stored column to ORDER BY, or a search narrowing the #4254 + ingress gate would refuse on every toolbar search. Both rules already gated + `os validate` / `os build` / `os lint`; the runtime door — the only door a + Studio tenant or an MCP/AI author has — ran neither, and an author writing the + exact declaration these rules exist to refuse got it accepted. + + Two halves, because either alone is a silent no-op: the reference-integrity + suite's registry entry gains `runtimeTypes: ['view']`, and both rules' metadata + walks gain the SELF rung — a `views[]` entry that IS a flattened standalone + list overlay (`ViewMetadataSchema`'s list-overlay member: `viewKind: 'list'`, + no nested `config`), the shape a standalone list view takes on the wire and the + shape the gate snapshots as `views: [item]`. + + The suite dispatches per member on this door: a `view` snapshot reaches exactly + the two list-view field rules (`ReferenceIntegrityRule.runtimeTypes`, default + `['flow']`), never the members whose resolution universe the per-write snapshot + does not carry — `validateActionNameRefs` resolving against `stack.actions` + would otherwise refuse legitimate view writes. CLI behaviour is unchanged (the + commands run the full suite as before); `flow` snapshots keep every member. + Measured before crossing: 0 refusals and 0 advisories over 50 shipped + view-door bodies (11 containers + 39 console-shaped personalization overlays, + `sort[].id` decorations included) across four authoring lineages — a lower + bound, as every authored corpus is. Draft saves are untouched (D1), stored rows + keep being served (ADR-0087 asymmetry), and + `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to a loud log. +- f1b5ad3: feat(lint): a standalone ViewItem record's nested `config.sort` / `config.searchableFields` reach the runtime publish gate (#10001) + + An `active`-state `view` save through `saveMetaItem` (Studio, REST `/meta` + item CRUD, an MCP/AI author) whose body is a standalone ViewItem RECORD — + `ViewMetadataSchema`'s member 1, `{ name, object, viewKind: 'list', config }`, + the shape a Studio-saved view takes and the shape objectui's `updateView` + round-trips on every pin/reorder toggle — is now refused with the existing + 422 `invalid_metadata` envelope when its `config.sort` / `config.searchableFields` + declares a field the bound object cannot honor: an unknown name, a virtual + (`formula`) sort target with no stored column to ORDER BY, or a search + narrowing the #4254 ingress gate would refuse on every toolbar search. #9313 + closed the same gap for the flattened list overlay, one union member over; + the record's declarations live one level down, inside `config`, and were + judged by neither list-view field rule — so a record write carrying + `config.sort: [{ field: '' }]` published in silence and answered + `400 INVALID_SORT` (#6994/#7095) on the view's first fetch, every load. + + Walk-only, by design: #9313 already widened the reference-integrity suite + entry and exactly these two members onto `view` writes, so this change adds + the RECORD rung to both twin walks — recognised by the wire union's own + member discrimination (`viewKind: 'list'` AND a record-shaped `config`; the + flattened-overlay rung keeps its `no nested config` guard, a strict container + carries neither key, and a `form` record has no list-field surface), judged + against `listViewObject(config) ?? record.object` at path + `views[i].config.sort[…]` / `views[i].config.searchableFields[…]`. The + per-member granularity split is unchanged: no further suite member crosses + onto `view`. Measured before shipping: 0 refusals and 0 advisories over 39 + record-shaped console round-trip bodies (one per shipped list surface, + `config.sort[].id` decorations and `isPinned`/`sortOrder` riding along, the + shape `saveMetaItem` really stores) across the four shipped stacks — a lower + bound, as every authored corpus is. Draft saves are untouched (D1), stored + rows keep being served (ADR-0087 asymmetry), and + `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to a loud log. + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/formula@17.2.0 + - @objectstack/sdui-parser@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index ac51515fd3..c8b9ce79b5 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.1.0", + "version": "17.2.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 882977f1e0..ef06c41d4d 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/plugin-mcp-server +## 17.2.0 + +### Minor Changes + +- 502dc6f: fix(mcp): the stdio MCP transport assembles its ExecutionContext with the shared assembler, and resolves localization (#7279) + + `resolveStdioExecutionContext` was the last hand-written `ExecutionContext` + assembly on the platform. #6216 converged the dispatcher, REST and share-link + sites onto `assembleExecutionContext`; this face was not in that card's + inventory, so it kept building the envelope field-by-field — and fell behind it + in two ways. + + | field | before | after | + |---|---|---| + | `tabPermissions` | dropped | **carried** | + | `timezone` / `locale` / `currency` | **resolved not at all** | **carried** (workspace values) | + | `accessToken` | absent by omission | **withheld by decision, on the record** | + | `positions` / `permissions` / `systemPermissions` / `userId` / `tenantId` / `email` / `posture` / `org_user_ids` / `accessible_org_ids` | carried | carried, unchanged | + + ## ⚠️ This changes output on the stdio surface — it is NOT a no-op + + **Formula fields evaluated during a stdio call move from `UTC` to the + workspace timezone.** The read path threads `ExecutionContext.timezone` into + `ExpressionEngine.evaluate`, which defaults to `UTC` when the context carries + none (`cel-engine.ts`: `ctx.timezone ?? 'UTC'`). Every stdio call previously + carried none. **A date-bucketing formula can therefore return a different + calendar day than it did before this change** — for a workspace whose timezone + is not UTC, that is the point: the same record read over REST and over stdio + now agree, where before they could disagree by a day. + + Two smaller shifts ride along: + + - **Denial messages localize.** A read refused by CRUD/FLS or RLS renders in the + workspace language (`userFacingDenialMessage`, `opCtx.context?.locale`) instead + of English. + - **Date-dependent driver generation on the write doors** (autonumber + `{YYYYMMDD}` tokens) resolves its calendar day from the workspace timezone. + `buildDriverOptions`' `hasTz` gate (`execCtx?.timezone !== undefined`) is one + of the few places where a field's ABSENCE is a meaningful state, and a stdio + call crosses it for the first time. Pinned in both directions by + `packages/objectql/src/engine-timezone-presence-gate.test.ts`. + + If a deployment's workspace timezone is unset, `resolveLocalizationContext` + falls back to `UTC` / `en-US` — the values this face effectively used before — + and nothing changes for it. + + ## `accessToken` is withheld, deliberately, and now says so + + The stdio face's credential is a **long-lived `osk_` API key** read from + `OS_MCP_STDIO_API_KEY`, not a session bearer. `ExecutionContext.accessToken` is + a **published hook surface** (`session.accessToken`, `spec/data/hook.zod.ts`), + so handing every `beforeFind`/`afterFind` a credential with far longer life than + the session token that surface was designed around is a product decision nobody + has made. This face passes `accessToken: undefined` with the reason written + down, matching the REST precedent. (It is also unreachable here: the value is + assigned only inside `resolve-authz-context.ts`'s + `if (!userId && typeof input.getSession === 'function')` branch, and this call + passes no `getSession`. The test injects a sentinel token at the seam anyway, so + the *decision* is pinned rather than the accident.) + + ## Cost, and where it is paid + + `resolveStdioExecutionContext` still re-resolves the **identity** on every call, + deliberately — ADR-0101 D1, so a revoked key stops working on the next one. + Localization is resolved **once, in `start()`**, and reused: the key's tenant + cannot change mid-session, and up to three settings reads per MCP call on a + long-lived process is not acceptable steady state. + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/formula@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index ba8086ece2..e6f39d825e 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.1.0", + "version": "17.2.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 78c5ea0d8e..e4f61d1bd7 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/metadata-core +## 17.2.0 + +### Minor Changes + +- 05bc692: `runRepositoryContractTests` gains two narrow options so the shared invariant + table can be applied to `SysMetadataRepository` — the implementation that backs + every production metadata write, and the one that had never been handed to the + suite (#10420). Both are additive and optional; every existing call site is + unchanged. + + - **`primaryType` / `secondaryType`** move the suite's two *fixture* metadata + types (previously hard-coded `'view'` and `'object'`), defaulting to exactly + those. This is a fixture knob, not an invariant knob: no clause is added, + removed or weakened by moving it. It exists because an implementation may sit + behind a write-authorization door keyed on the type — + `SysMetadataRepository.assertAllowed()` refuses any type whose registry entry + lacks `allowOrgOverride`, `'object'` included — so a hard-coded fixture type + silently decided which implementations could be held to the table at all. + - **`declaredDivergences`** records an issue-tracked exception to the table. + It does **not** skip the clause it names — a skipped clause is + indistinguishable from coverage in a green run, which is the one failure a + shared contract suite must not have. It swaps in a clause that *pins the + divergent behaviour*, so the suite reds the day the implementation starts + conforming and whoever fixes it is told to delete the declaration in the same + PR. Shrink-only, audited in the fixing direction, like the repo's other + ledgers. The only member today is `resumableWatch` (contract invariant 6), and + the only declaration is `SysMetadataRepository` — see #10842. + + Publishable behaviour is otherwise untouched: `packages/metadata-protocol` gains + a test file only, and 32 of the suite's 34 clauses were already satisfied by + `SysMetadataRepository` on the first run. +- f334d66: `MetadataRepository.watch()` — a numeric `since` now replays from the durable + log, and what a bare `watch(filter)` owes is written into the contract. + + **`SysMetadataRepository.watch(filter, since)`** read `since` only as a drop + filter on live events, so an event that had already committed was unreachable + through `watch()` however low `since` was set — even though the repository holds + a durable per-org `event_seq` log in `sys_metadata_history` and already reads it + org-wide in `nextEventSeq()`. Invariant 6 of the repository contract + ("`watch(_, since)` MUST replay all events with `seq > since` before delivering + live events") was therefore unimplemented in the repository backing every + production metadata write. It now replays through that same query, using the + row-to-event mapping extracted out of `history()`. The live listener is + registered before the durable read is issued and a set of delivered `seq` + numbers closes the replay-to-live seam, so an event committing mid-read arrives + exactly once; a failed durable read is raised to the consumer rather than + degraded into a silent live-only tail. + + **No behaviour change for a `watch()` with no `since`** — deliberately. Both + in-repo production subscribers (`MetadataManager.startRepositoryWatch()` and + `MetadataCache.start()`) attach that way, and replaying for them would push an + org's entire history through cache invalidation and HMR as "this just changed" + at every attach. + + **Contract text (`@objectstack/metadata-core`, `repository.ts`).** Invariant 6 + now states its own boundary: a `watch()` with no `since` is owed **live events + only**; an implementation MAY additionally deliver events that had already + committed, but a caller MUST NOT rely on it, and a caller that needs the + already-committed prefix passes a numeric `since` or reads `history()`. That + half was previously unwritten and load-bearing — "no `since` replays + everything" existed only as `InMemoryRepository`'s implementation, and the + shared contract suite silently depended on it. + + **If you run `runRepositoryContractTests` from + `@objectstack/metadata-core/testing` against your own implementation**, one + clause changed shape. `watch filters by type and name` (which wrote twice, then + opened a watch and expected the match back) is replaced by `watch filters by + type and name — over the live stream`, which opens the subscription first and + writes after. FROM: an implementation passed by replaying its whole matching log + on a bare `watch(filter)`. TO: it passes by delivering, and filtering, the + events that commit after the subscription is established. An implementation that + replays as well still passes — the new clause asserts the floor, not the + maximum. If yours only replayed and never delivered live events, it was relying + on unspecified behaviour and now needs a live path. + +### Patch Changes + +- 26f3588: **Fix:** the REST `/meta` doors now decide **organization scope on the folded type**, never on the raw URL spelling (#10340). + + Storage folds `/meta/:type` through `META_URL_TO_SINGULAR` — the complete spelling map — while the doors' scope predicate (`declaresOrgOverride`) tolerates only the manifest-collection spellings. For the two registry-derived spellings, `translations` and `email_templates`, the doors therefore read and wrote **env-wide** where the singular twin was org-scoped: an org-active author's `PUT /meta/translations/:name` landed an env-wide row their own org-scoped read then shadowed (persisted, receipted as live, served by nothing), and `GET` under one spelling answered a different partition than the other — one item, two namespaces, addressed by spelling (#4432 / #7894's defect one layer down). + + - All nine `/meta` org-scope call sites (list, single read, layers view, compound read, save, compound save, delete, publish, rollback) fold the segment through `canonicalMetaUrlType` **before** calling `organizationIdForMetaRead` / `organizationIdForMetaWrite`, exactly as `metadata-url-spelling.ts` mandates: folding happens at the boundary and only there. + - The `GET /meta/:type/:name/published` code-store fallback folds too — the smaller second site of the same class: it reads a registry keyed by canonical types, so a recognised plural of a code-published item answered 404 while the singular answered 200. + - **Deliberately unchanged:** `GET /meta/_drafts` still applies no fold (it filters by the draft row's *stored* type, which is canonical because the protocol folds on save), the request `type` handed to the protocol stays the raw segment (the protocol owns its own fold), and `declaresOrgOverride` does **not** absorb the URL map — a predicate below the boundary consuming the URL spelling contract is the repair #7894 forbids. `@objectstack/metadata-core` changes are documentation and pins only: the predicate's header no longer claims parity with the protocol's normalization (measured false), and new tests pin both the composed fold→predicate contract and the predicate's deliberate limit. + + No stored rows move: rows previously minted env-wide through a plural spelling stay env-wide and keep serving org-less callers (and org-active callers until an org overlay exists), which is the same layering the singular spelling always had. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index 8ed015443a..ebd28eaffc 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.1.0", + "version": "17.2.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 7bed9aae23..b64ec141b6 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,14 @@ # @objectstack/metadata-fs +## 17.2.0 + +### Patch Changes + +- Updated dependencies [26f3588] +- Updated dependencies [05bc692] +- Updated dependencies [f334d66] + - @objectstack/metadata-core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 70c50c2b51..388443c44d 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.1.0", + "version": "17.2.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 15da6b77fe..4ff7c0729a 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,627 @@ # @objectstack/metadata-protocol +## 17.2.0 + +### Minor Changes + +- 5886ee6: Stop issuing two DB queries for questions already answered earlier in the same + request (#10757). One authenticated `GET /data/:object?$top=1` measured **24 DB + queries before, 23 after** — **22** when the caller opts out of the count. + Measured with `X-OS-Debug-Timing: json` on `pnpm dev:crm`, whose `Server-Timing` + carries `db;dur=…;desc="N queries"`. + + **`$count=false` now skips the COUNT query** (`@objectstack/metadata-protocol`). + The parameter has been declared (`ODataQuerySchema.$count`), aliased on the wire + (`$count` → `count`), reserved out of the implicit-field-filter bucket, + arity-checked and boolean-coerced for a long time — and then deleted unread, so + every paginated list ran `engine.count()` whether or not the caller wanted a + total. It is honoured now: + + ``` + GET /data/task?$top=25 → { records, total, hasMore } (unchanged) + GET /data/task?$top=25&$count=true → { records, total, hasMore } (unchanged) + GET /data/task?$top=25&$count=false → { records, hasMore } (no COUNT query) + ``` + + Read the shape of that carefully before adopting it: + + - **Only an explicit `false` opts out.** An ABSENT `$count` still counts and + still reports `total`. OData reads absent as "omit the count", and taking that + reading here would silently strip `total` from every existing caller — none of + them send the parameter, all of them read the number. The asymmetry is + deliberate and pinned by tests. + - **`total` is OMITTED, never estimated.** `FindDataResponse.total` is declared + optional ("if requested"), so absent is the declared shape for "not + requested". A caller that opted out and then reads `total` gets `undefined`, + not a plausible-looking guess — guard the read (`total ?? undefined`) or do + not send `$count=false`. + - **`hasMore` is still answered**, from the page alone: a full page means there + may be more. Same page-local rule the `$search` path already uses. + + **A find and its COUNT resolve permission sets once, not twice** + (`@objectstack/plugin-security`). `findData` answers a paginated list with two + engine operations, and the security middleware runs on both; each pass re-read + `sys_permission_set` for the same context with identical bindings. The + resolution is now memoized per execution context — a `WeakMap` keyed on the + context object, which is built once per request and collected with it, so + nothing outlives the caller it was resolved for — and **retired by any write**: + a process-wide epoch is bumped on every `insert`/`update`/`delete` the engine + middleware sees, ahead of the `isSystem` bypass so a seeder, a package publish + or an auto-org-admin grant invalidates too. A context whose grants are rewritten + in place re-resolves as well (the memo key covers `positions`, `permissions`, + `principalKind` and the presence of `userId`). No authorization answer is reused + across a write, across a context, or across a request. + + Not a fix for the whole cost: the remaining ~22 queries per authenticated + request are session resolution, grant resolution, localization and metadata + reads that repeat on every request. Removing those needs cross-request caching + with an invalidation design, which is deliberately not in this change. +- 2866d5f: `os migrate duplicates` now reports the rows blocking the three `kernel:ready` + NULL-safe index tightenings, and the three migrations' conflict messages point + there instead of at `os migrate plan` (#8725). + + **The gap.** Three migrations replace a declared UNIQUE index with the NULL-safe + — and sometimes active-rows-only — form it was always meant to have, at + `kernel:ready` on a serving boot: + + | table | index(es) | migration | + | --- | --- | --- | + | `sys_metadata` | overlay `active` + `draft` | `ensureMetadataOverlayIndexes` | + | `sys_view_definition` | `idx_sys_view_def_active` | `ensureViewDefinitionActiveIndex` | + | `sys_setting` | the declared row identity | `ensureSysSettingIdentityIndex` | + + Each is a tightening, so rows an installation already holds can block it. The + migration then refuses — previous index kept, no row touched, boot continues — + and reports at `error` on the boot channel. That channel was the only one: + these indexes are invisible to `os migrate plan` **by construction**, twice + over. After the tightening, `isRuntimeManagedIndex` excludes the index (without + that exclusion a boot would propose rebuilding away the guarantee it had just + created); before it, each migration deliberately reuses the *declared* index's + name, so the reconciler's name-matched slot reads as filled whichever physical + form is really there. Measured with a matched control — one database carrying + the same duplicate damage under a declared index and under + `sys_view_definition`'s runtime one — `plan` named the declared one in full and + said nothing whatsoever about the runtime one. + + **What is new.** The report gains a `runtimeIndexPreflight` section, one entry + per index, each `blocked` (with every colliding key group and its row count), + `clear`, `table-absent` (`sys_setting` arrives with the optional settings + service) or `unreadable` (with the driver's own message), plus + `summary.runtimeIndexesBlocked` and `summary.runtimeIndexBlockingRows`. + `reportVersion` moves `1` → `2`. Every `1` field keeps its name, shape and + meaning; the bump says there is more in the document, for consumers that + validate it strictly. + + The probes are the migrations' own duplicate-listing statements — + `@objectstack/metadata-protocol` exports `collectRuntimeIndexPreflight` and + `runtimeIndexProbes`, which read those builders rather than restating the keys, + so the pre-flight and the boot report cannot describe different duplicates. On + MySQL the `sys_setting` probe uses the migration's MySQL spelling, where the + bare form is `ERROR 1064` on the reserved word `key`. + + **The referral, repointed rather than deleted** (maintainer ruling, 2026-08-22). + All three conflict messages told the operator to "run `os migrate plan`" as an + alternative way to list the blocking rows, and that instruction was false: they + now name `os migrate duplicates`, which answers it. The six doc comments that + state the same referral as part of the ADR-0120 D4 disposition are updated with + them. + + **Nothing about a migration's behaviour changes.** No tightening is armed, + deferred or altered, and `os migrate plan`'s drift contract is untouched. The + pre-flight only makes the refusal's evidence readable one command before the + restart — from a command that boots read-only and writes nothing, which is + pinned logically (schema plus every row, ordered) rather than by a file hash: a + raw hash over a SQLite file moves on any read-write open and would accuse this + command of mutating the install it exists to describe. +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + +- 16cef97: Declare `outcome: 'published' | 'refused' | 'nothing_to_publish'` as a required + key on the `publishPackageDrafts` response (#10462) — the first-class + discriminant for WHICH exit answered, the fact `success` compresses into one + boolean. Before this field, a publish with nothing to promote and a genuine + refusal (pre-flight violation or ADR-0067 D2 rollback) were indistinguishable: + both answer `success: false` with `publishedCount: 0` on a 200, and the no-op + left no trace at all — an AI consumer graded the no-op as "refused and rolled + back" and burned two repair rounds on artifacts that were already correct + (cloud#1488; cloud#1492's patch discriminates on `failed.length > 0`, an + invariant the producer never stated). + + The producer invariants, now stated and pinned in the conformance suites, both + directions of each: `outcome === 'refused'` ⟺ `failed.length > 0`; + `outcome === 'nothing_to_publish'` ⟺ + `published.length === 0 && failed.length === 0`; + `success === (outcome === 'published')`. `success` keeps its exact pre-#10462 + value on every exit — a no-op still answers `success: false` — so consumers + reading only `success` see no change, and cloud#1492's `failed.length` + discrimination stays valid during its convergence onto `outcome`. The no-op + exit additionally logs one `info` line naming the package and both facts + (nothing pending, nothing refused), so that exit is no longer traceless. + + Additive for response consumers. A custom protocol implementation that serves + `publishPackageDrafts` must now emit `outcome` on every return — + `PublishPackageDraftsResponseSchema` declares it required, and the conformance + suites treat a producer return without it as a drifted seam. +- f334d66: `MetadataRepository.watch()` — a numeric `since` now replays from the durable + log, and what a bare `watch(filter)` owes is written into the contract. + + **`SysMetadataRepository.watch(filter, since)`** read `since` only as a drop + filter on live events, so an event that had already committed was unreachable + through `watch()` however low `since` was set — even though the repository holds + a durable per-org `event_seq` log in `sys_metadata_history` and already reads it + org-wide in `nextEventSeq()`. Invariant 6 of the repository contract + ("`watch(_, since)` MUST replay all events with `seq > since` before delivering + live events") was therefore unimplemented in the repository backing every + production metadata write. It now replays through that same query, using the + row-to-event mapping extracted out of `history()`. The live listener is + registered before the durable read is issued and a set of delivered `seq` + numbers closes the replay-to-live seam, so an event committing mid-read arrives + exactly once; a failed durable read is raised to the consumer rather than + degraded into a silent live-only tail. + + **No behaviour change for a `watch()` with no `since`** — deliberately. Both + in-repo production subscribers (`MetadataManager.startRepositoryWatch()` and + `MetadataCache.start()`) attach that way, and replaying for them would push an + org's entire history through cache invalidation and HMR as "this just changed" + at every attach. + + **Contract text (`@objectstack/metadata-core`, `repository.ts`).** Invariant 6 + now states its own boundary: a `watch()` with no `since` is owed **live events + only**; an implementation MAY additionally deliver events that had already + committed, but a caller MUST NOT rely on it, and a caller that needs the + already-committed prefix passes a numeric `since` or reads `history()`. That + half was previously unwritten and load-bearing — "no `since` replays + everything" existed only as `InMemoryRepository`'s implementation, and the + shared contract suite silently depended on it. + + **If you run `runRepositoryContractTests` from + `@objectstack/metadata-core/testing` against your own implementation**, one + clause changed shape. `watch filters by type and name` (which wrote twice, then + opened a watch and expected the match back) is replaced by `watch filters by + type and name — over the live stream`, which opens the subscription first and + writes after. FROM: an implementation passed by replaying its whole matching log + on a bare `watch(filter)`. TO: it passes by delivering, and filtering, the + events that commit after the subscription is established. An implementation that + replays as well still passes — the new clause asserts the floor, not the + maximum. If yours only replayed and never delivered live events, it was relying + on unspecified behaviour and now needs a live path. + +### Patch Changes + +- 7d81c88: `SysMetadataRepository.close()` now terminates every live `watch()` iterator + instead of broadcasting a synthetic drain event (#11021). A consumer holding a + `for await` over `watch()` at shutdown could hang forever, and the hang was + worst for the subscription shapes most likely to be in use. + + Shutdown was modelled as a metadata event — `{ seq: -1, ref: { org: '', type: + 'view', name: '_close' } }` — pushed through the same dispatch closure real + events pass, followed by clearing the watcher registry. Both of that closure's + guards reject it: + + - `matchesFilter` drops it for any subscription naming an `org` (the synthetic + ref's org is the empty string), a `type` other than `view`, or a `name` — + `MetadataCache.start()` with any non-empty `watchFilter` is exactly that + shape; + - the `since` drop-filter drops it for every numeric-`since` subscription, + since `-1 <= since` holds against every real seq. + + Dropped and then unsubscribed, nothing could settle the parked promise. Measured + before the fix: `watch({org:'system'}, seq)` and `watch({org:'system'})` were + both still unsettled 500ms after `close()`. The empty-filter case looked drained + and was not — it received the synthetic event as a *real* one (a `view` named + `_close`, deleted, at seq -1, which `MetadataManager` turns into a cache + invalidation and re-emits to Studio's HMR stream) and then hung on the next pull + anyway, because delivering an event does not end an iterator. + + `close()` now runs each subscription's terminator — the same routine the + consumer's own `iterator.return()` runs — so a parked `next()` settles with + `{ done: true }` and no value, and so does every later one. Consumers no longer + need to recognise a shutdown event, because there is no longer one to recognise; + nothing in the repo ever named the `_close` sentinel. + + The contract this repairs was unstated, which is why the two defensible repair + shapes were both arguable. It is stated now: invariant 8 in + `packages/metadata-core/src/repository.ts` ("shutdown terminates; it does not + emit") says what a repository-level `close()` owes a pending iterator, and + records the one measured non-conformance among today's implementations + (`FileSystemRepository`, filed as #11127). +- 02d56b4: fix(metadata-protocol): `getMetaDiagnostics` refuses an unrecognised `type` spelling with the producer's 400 instead of answering "scanned 1 type, 0 problems" (#8924) + + + + This is a **narrowing that makes an already-classified 400 reach the caller**. + `GET /api/v1/meta/diagnostics?type=` + (and the SDK method `client.meta.getDiagnostics({ type })`) used to answer + `200 {"entries":[],"total":0,"scannedTypes":1,"scannedItems":0,"stats":{}}` — + "scanned 1 type, no issues" — for a spelling every sibling `/meta` door + refuses with a 400 that names both accepted spellings. The producer had + already classified the mistake (`status: 400`, `code: 'INVALID_REQUEST'`, + raised by `canonicalizeMetaRequestType` inside `getMetaItems`); the + diagnostics sweep's per-type `catch` swallowed the verdict into a benign + skip, and `scannedTypes: 1` then published a sweep that scanned nothing as + coverage. Maintainer ruling 2026-08-20: rethrow the 400 the same way #8855's + fix rethrows the 503. + + **Measured on a booted kernel (real HTTP), before → after:** + + ``` + GET /api/v1/meta/diagnostics?type=fieldes 200 {"scannedTypes":1,"stats":{}} → 400 [invalid_request] "… Address it as 'field' or 'fields'." + GET /api/v1/meta/diagnostics?type=fields 200 (recognised plural) → 200 unchanged + GET /api/v1/meta/fieldes 400 → 400 unchanged + ``` + + What is unchanged: recognised plurals (`fields`, `views`, …) still fold and + answer; a name that is a plural of nothing (`fieldz`) still answers an honest + `count: 0` entry; a genuine, unclassified listing failure still skips that + one type instead of failing the sweep; the whole-corpus sweep (no `?type=`) + cannot produce the refusal at all — its target set comes canonical out of the + registry. A caller that treated the old `200`-with-empty-stats answer as + "clean" now hears the refusal that names the accepted spellings. +- 82cb6e8: fix(metadata-protocol): stop prescribing `?force=true` on the duplicate door, which accepts no `force` (#11015) + + `saveMetaItem`'s Phase 3a-destructive refusal ended every message with + `— re-submit with ?force=true to proceed.` The refusal is raised in one place + and quoted onto whatever response the caller's catch builds, so that one + sentence went out on every face that reaches the gate — including + `POST /packages/:id/duplicate`, which has no `force` to set. + + Measured: the duplicate route accepts `targetPackageId`, `targetName`, + `targetNamespace`, `organizationId` and `actor` — no `force` in the query + string or the body — and `duplicatePackage`'s own request type has no `force` + field either, so its internal `saveMetaItem` call cannot carry one. The gate is + reached on the ordinary duplicate-**again** workflow, where the target + namespace already holds the renamed object from an earlier duplicate; the copy + is refused and the refusal is reported as data on a `200`: + + ``` + "error": "[destructive_change] object/crm2_task would drop or transform existing + data: Field 'b' removed — … — re-submit with ?force=true to proceed." + ``` + + A caller who does what that sentence says gets the identical refusal back. The + remedies that do exist on that face — duplicate into a target namespace that is + free, or reconcile the colliding object first — were never stated. + + The clause is now rendered per face. The duplicate door says: + + ``` + … — this copy cannot be forced: the duplicate door accepts no `force`. + Duplicate into a target namespace that does not already hold 'crm2_task', or + reconcile that item with the source first. + ``` + + Three narrowings, each pinned: + + - **The clause is repaired, not the door.** No `force` parameter is added to + `POST /packages/:id/duplicate`; that would widen a public surface and is a + contract decision, not a message fix. Which face is being served is stated by + the server on the internal call, exactly as `source` already is — a caller + cannot smuggle one in. + - **Nothing else in the message moved.** #10886 measured that + `duplicatePackage`'s `failed[].error` is the sole carrier of the per-field + destructive findings, so the findings prose stays verbatim. Only the trailing + remedy sentence is face-dependent. + - **No accept/reject behaviour changed.** The copy is still refused, still + reported as `failed[]` data on the `200`, still counted. Faces that state no + door — the single-segment REST `PUT /api/v1/meta/:type/:name`, where + `?force=true` is a real query parameter the route threads — keep the previous + wording byte for byte. +- d806081: Render `saveMetaItem`'s `422 INVALID_METADATA` findings clause per write face + + The spec-validation refusal restated its own findings in the message + (`: ` for the first three, plus a `(+N more)` tail) while + attaching the same array as `issues`. On the HTTP 422 both channels ride one + response, so every console rendering both showed each finding twice. + + The clause is now rendered per face. The `/meta` HTTP write doors — REST's + `PUT /meta/:type/:name` and `PUT /meta/:type/:a/:b`, and the runtime + dispatcher's `PUT /meta` — declare that they carry the findings structurally + and get a one-sentence headline instead: the issue count plus up to three + `path [zod code]` locators, the same grammar the seed refusal and the + author-time gate already compose. `issues[]` is attached unchanged on every + face, so nothing is withheld from anyone. + + Faces that carry no structured channel keep the full prose, byte for byte — + `duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s + `rows[].reason`, and the two out-of-package log faces, where this sentence is + the sole carrier of the author's prescription. Silence means "keep the prose": + a write door only ever drops the restatement by declaring itself, never by + omission. +- d728325: Per-item publish (`POST /api/v1/meta/:type/:name/publish`) now re-binds runtime consumers + and finds drafts authored env-wide — the two things the package-scoped publish door + already did. + + **A metadata publish now announces `metadata:reloaded` on BOTH doors.** The event that + tells boot-cached consumers to re-read had two announcers: the dev-artifact watcher and + the runtime dispatcher after `POST /packages/:id/publish-drafts`. Publishing item by item + — what AI authoring and the item-level Studio doors do — announced nothing, so a flow + published while the server ran stayed `state='active'` and completely inert (no trigger + bound, no execution) until the kernel was rebuilt. `publishMetaItem` now notifies its host + through a new `onMetaItemPublished` seam and `ObjectQLPlugin` turns that into the kernel + announce, so `service-automation`'s flow re-bind, the authored hook/action re-sync, + declarative connectors and authored translations all catch up without a restart. The + announce is awaited, so the publish's own 2xx means the re-bind was attempted; a + subscriber failure is logged and never fails the publish. The batch door is unchanged — + it keeps its single per-publish announce rather than gaining one per promoted draft. + + **A per-item publish now resolves the draft's own org scope.** For the types the registry + declares `allowOrgOverride: true` (`view`, `dashboard`, `report`, `translation`, + `email_template`) the REST seam threads the session's active organization into the + publish, while package/AI authoring writes the draft env-wide — so the strict org lookup + matched nothing and answered `404 [no_draft] … nothing to publish` over a draft the + console's pending-changes banner was listing and the batch button published fine. The + per-item door now discovers the draft's scope the way `publishPackageDrafts` has since + #3115, with the ADR-0005 precedence (an org holding its own draft publishes that one) and + the same `NO_DRAFT` refusal when no scope holds a draft. +- 0c24898: fix: a package publishes as a self-consistent unit — `publishPackageDrafts` judges each draft against the batch's own pending declarations + + The batch publish door built the author-time validation context from + `engine.registry` alone, i.e. the ALREADY-LIVE universe. A draft is not in that + registry, and the batch's own promotions do not put it there either: the + registry write-through runs in Phase 2, after the Phase-1 transaction that gates + and promotes every draft. So while a batch was being judged, no member of it was + visible to any other member — in any order. + + Measured consequence: a package shipping `dataset/x` together with a `dashboard` + whose widget binds `x` could NEVER publish. `validateWidgetBindings` raises + `widget-dataset-unknown` at `severity: 'error'`, which refuses the promotion, + and the batch being all-or-nothing rolls the whole package back. Renaming the + dataset could not help, and neither could re-ordering the items. + + `publishPackageDrafts` now reads its own pending drafts once, before any + promotion, and folds them into all four context collections the closure carries + (`objects`, `permissions`, `books`, `datasets`) — pending declarations replace a + live one of the same name, never sit beside it. A binding that resolves to + neither the batch nor the live universe is still refused exactly as before. +- a79bd35: Publish refusals no longer render each validation finding twice (#10524) — declare-then-trim. + + **Declared (spec, additive):** `PublishPackageDraftsResponseSchema.failed[]` elements now + declare `issues[]` (the `RuntimeAuthoringIssueSchema` findings the producer has emitted + since #8333 but no declared parse could carry), and `seedApplied` declares `issues[]` + (`{ path, message, code? }`, the seed-body schema refusal's findings). Typed consumers — + the SDK's `PublishPackageDraftsResponse`, any `parse` through the schema — can now read + the structured findings back instead of having them silently stripped. + + **Trimmed (producers):** the #4463 author-time gate's 422 message and + `seedRequestValidationError`'s message are one-sentence headlines — total count plus up to + three `path [rule]` / `path [zod-code]` locators — instead of restating the issue prose + that `issues[]` carries on the same response. Consumers that render only `error` (CLI, + logs) keep what failed, where, under which rule, and how many; consumers that render both + channels stop repeating themselves. The old `(+N more)` tail is subsumed by the leading + count. Both catches that surface the seed refusal onto `seedApplied` now thread the + structured findings beside the headline. + + Error `code`/`status` vocabularies, `advisories`, the DESTRUCTIVE_CHANGE (409) message, + and `saveMetaItem`'s spec-validation 422 message are unchanged. Messages are not contract + (the machine-readable channels are `code` and `issues[]`), so this is not a breaking + change and registers no migration. +- 490879a: Declare `packageId` on `publishMetaItem`'s request type, and correct three + in-tree comments that claimed the per-item publish door "names no package" + (#10350). + + `publishMetaItem` declared `type / name / organizationId / actor / message / + _skipSeedApply` and no `packageId`, while since #10063 `POST + /meta/:type/:name/publish?package=PKG_ID` states one on every HTTP-driven + promotion that names a package — which is Studio's designer save-then-publish + loop. + + **No runtime behaviour changes, and nothing was broken at runtime.** The value + already flowed end to end: `publishMetaItem` forwards its whole request object, + the one transform in between (`canonicalizeMetaRequestType`) is a spread that + drops no key, and `promoteDraftForPublish` already declared + `packageId?: string | null` and threaded it into both the #9612 gate closure and + `repo.promoteDraft`. What was wrong was the *declared* contract: the binding was + invisible to every typed caller, and the only caller that states one reaches the + method through a cast, so it was enforced by nothing — one destructuring + refactor away from being dropped in silence. + + `packageId` is `string | null | undefined`, and the three states are distinct: + an **absent** key keeps the historical "match any package" resolution, `null` + pins the lookup to the unbound row, and a present-and-`undefined` key coerces to + `null` downstream and makes a package-bound draft unfindable. Spread it in + conditionally; never write `packageId: maybeUndefined`. + + `environmentId` is deliberately **not** added, though it sits in the same + cast-hidden position on the REST call site. It is the multi-kernel routing key + and is out of the protocol request shape by the maintainer ruling recorded + 2026-08-18 on #9741 — `resolveProtocol(environmentId)` selects the kernel before + the call, and `request.environmentId` is read nowhere in + `@objectstack/metadata-protocol`. `packages/rest` types that one transport-level + member on top of the declared shape (`TransportScopedMetaRequest`) instead. + + Three pins land in `protocol-publish-drafts-package-scope.test.ts`, on the same + two-colliding-drafts fixture the #8907 batch-door cases use, so a promote that + loses the package dimension resolves the *wrong* row rather than merely + succeeding. +- 38bc74e: `backfillSeedTenancy` no longer reports `no-split` over a driver it never queried + (#10789). The boot-time seed/API tenancy repair answered `status: 'no-split'` — + *"I looked, there is no split"* — on the memory driver, having looked at nothing, + and its own `absent` branch was unreachable there despite the branch's comment + saying *"Absent on a memory engine"*. + + `InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` + and returns `null`. It neither throws nor is absent, so `resolveSeedTenancySeam`'s + shape test (`typeof d.execute === 'function'`) was satisfied and the `no-driver` + guard never fired; `normalizeRows(null)` is `[]`, which is also what a real driver + returns for a SELECT that matched nothing. Every branch of this migration reads + "no rows" as "healthy install, nothing to do", so the two collapsed into one + answer. + + The migration now separates the cases the guard used to conflate: **a seam that + cannot answer is absent, not empty.** Its READ probes are held to the standard + that actually distinguishes them — a driver that answers returns a RESULT SET — + so a probe that hands back no result set reports `absent` (with a `detail` naming + the reason) instead of being read as zero rows. Nothing names a driver: any host + with the same no-op shape is covered without an allowlist to maintain. This is the + consumer-side shape #10677 / PR #10788 landed for `os migrate duplicates`, applied + to this module's own probes. No driver package was modified. + + Three behaviours are deliberately unchanged: + + - **A real SQL install does not move.** An empty result set is an ANSWER in every + dialect spelling — a bare `[]`, `{ rows: [] }`, and the `[rows, fields]` tuple — + so a healthy install still reports `no-split`. The counter-table presence probe + is a `WHERE 1 = 0` SELECT that matches nothing by construction and runs on every + boot, which is exactly why "no rows" must stay distinct from "no answer". + - **Write statements are not held to "must answer".** An UPDATE or DELETE does not + return a result set on every dialect, so the repair's stamp and counter-merge + statements stay on the bare seam. + - **A seam that THROWS keeps its behaviour.** Throwing is a driver present and + refusing loudly, and step 1's `catch` already reported it as `absent`; only a + seam that RETURNS a non-answer was invisible. + + Boot-time behaviour is otherwise untouched: neither status logs anything, and + neither writes a ledger receipt, so a memory-driver boot logs exactly what it + logged before. What changes is the reported `status`, which is the value a caller + uses to tell "nothing to repair" from "could not look". +- 0ab81d1: fix(metadata-protocol): the seed/API tenancy repair now records each applied run in `sys_migration`, so "was my data rewritten, and when" survives the container being replaced (#9451) + + `backfillSeedTenancy` (#8686) is the platform's only row-rewriting repair that + runs unattended: it stamps `organization_id` onto business rows, merges one + autonumber counter and deletes another. It persisted nothing about having done + so. The only evidence was one `logger.info` line, and the healthy path is silent + by design — so once that line had scrolled, a silent boot and a boot that + rewrote data were indistinguishable. The operator most likely to need the record + (a fresh install, repaired during the first admin sign-up, where nobody is + reading server stdout) was the one least likely to have captured it. + + An `applied` run now writes one row into the **existing** `sys_migration` + deployment ledger — the face that already answers "has this deployment run this + data migration", and is already written at boot by the ADR-0104 attestation + path: + + ```sql + SELECT last_run_at, advisory, details FROM sys_migration + WHERE id = 'seed-tenancy-backfill'; + ``` + + `details` carries the run's status, the objects stamped, the organization + adopted and the identifiers that could not be adopted because they were already + minted on both sides of the split. + + Deliberately narrow: + + - **`applied` only.** `no-split` stays silent — a row per healthy boot would be + a ledger of non-events. + - **`verified_at: null`, `blocking: 0`, always.** This repair runs no self-check + and gates no consumer, so it claims no certificate; the collision count goes + to `advisory`, which never gates. Every reader of this ledger looks a row up + by `id`, so the new id cannot reach another migration's gate. + - **Best-effort, and loud when it fails.** A boot is never failed by + bookkeeping (2026-08-15 ruling), so a failed receipt write is reported at + `error` — naming that the rows *were* rewritten, that the repair is not + retried, and what to do — rather than rethrown. + - **No new schema, no new authoring surface, no new dependency.** The row is + written against the `@objectstack/spec/system` contract that + `metadata-protocol` already depends on. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [78818ec] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [047ac86] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [e2bb237] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [adbcbfd] +- Updated dependencies [f1b5ad3] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/lint@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/metadata@17.2.0 + - @objectstack/formula@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 1e2f723860..8aa515de3e 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.1.0", + "version": "17.2.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 2a6f27a542..af75fc193c 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,102 @@ # @objectstack/metadata +## 17.2.0 + +### Patch Changes + +- 047ac86: Five `Plugin` implementations now release their resources from `destroy()`, the + only teardown hook the kernel calls (#10772). + + `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and + `destroy?()`. `ObjectKernel.performShutdown()` and `LiteKernel.destroy()` walk + the plugins in reverse calling `plugin.destroy()` — and nothing anywhere calls + `stop()`, `dispose()`, `close()` or `shutdown()` on a plugin. Each of these five + spelled its teardown with one of those names instead, so what it released was + still held after `await kernel.shutdown()` had **resolved**: + + | package | class | was spelled | what outlived shutdown | + |:--|:--|:--|:--| + | `@objectstack/metadata` | `MetadataPlugin` | `stop` (arrow property) | artifact watcher, `manager.dispose()`, repository handle | + | `@objectstack/runtime` | `AppPlugin` | `stop` (arrow property) | the `app:unregistered` catalog event, never emitted | + | `@objectstack/runtime` | `ExternalValidationPlugin` | `stop` (arrow property) | every armed drift-check `setInterval` | + | `@objectstack/plugin-email` | `EmailServicePlugin` | `dispose` | two metadata subscriptions, the SMTP transport, an engine binding | + | `@objectstack/plugin-webhooks` | `WebhookOutboxPlugin` | `dispose` | the auto-enqueuer (2 realtime subscriptions + a refresh interval) and two engine hooks | + + `ExternalValidationPlugin` is the one with teeth: it is one of only two `Plugin` + implementations in the tree that own `setInterval` directly, it is mounted on + the real `os serve` path, and its `stop()`'s only caller anywhere was the class + itself re-arming. Measured against a real kernel, its drift checker performed + five further reads in the five intervals after a resolved shutdown — the #9371 + mechanism verbatim. `WebhookOutboxPlugin.dispose()` had **zero** callers in the + entire repo, so its teardown had never run in any process at all. + + **Nothing is removed and no signature narrows.** Each old name is retained as a + delegating alias, because it is public API of an exported class and an embedder + may have learned to call it directly precisely BECAUSE the kernel never did. + `stop` stays an arrow property where it was one (so a detached + `const { stop } = plugin` keeps working) and stays synchronous on + `ExternalValidationPlugin` (so a non-awaiting call site is unaffected). The two + `stop(ctx)` aliases widen their parameter to optional. + + One behavioural note for direct callers, since `destroy()` takes no context: + `MetadataPlugin.stop(ctx)` and `AppPlugin.stop(ctx)` now use the context + captured in `init()` and ignore the argument. In a real composition these are + the same object. The visible difference is confined to a plugin whose `init()` + never ran — for `MetadataPlugin` a dropped `warn` line, for `AppPlugin` a + catalog event that is no longer emitted for an app that was never registered. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/types@17.2.0 + - @objectstack/metadata-fs@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 92f53f7c11..683c11f467 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.1.0", + "version": "17.2.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 a4935c0231..b568e1327e 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,419 @@ # @objectstack/objectql +## 17.2.0 + +### Minor Changes + +- 6936d07: `engine.aggregate` honours a per-aggregation `filter` (#10576, the contract + half of #10413). `AggregationNodeSchema.filter` — declared since #4286 but + marked experimental and enforced by nothing — is now live with SQL + `FILTER (WHERE …)` semantics: the predicate narrows the SOURCE rows that one + aggregation reads while sibling aggregations in the same call keep seeing + every row of the group, so a measure-scoped filter (`stage: 'closed_won'`) + can finally reach the engine instead of being silently dropped (the #10413 + wrong-numbers defect on the ObjectQL analytics path). The + `StrategyContext.executeAggregate` bridge (`@objectstack/spec/contracts`) + gains the same optional `filter` on its aggregation entries so analytics + strategies can lower measure filters into it (#10413 phase 2 consumes this + seam next). + + Execution is the correct-first two-tier shape date bucketing and HAVING use: + the engine lowers filtered aggregations in memory for every driver (unknown + operators refuse loudly with `INVALID_FILTER`/400 naming the aggregation + position; a group emptied by its filter answers the ruled empty-group values + — count/sum 0, avg/min/max null). No driver compiles conditional aggregation + natively today, so each native aggregate face (driver-sql — inherited by + driver-sqlite-wasm and Turso local —, the Turso remote transport, + driver-mongodb's pipeline builder, driver-memory's `performAggregation`) + refuses a directly-delivered per-aggregation filter with + `NOT_IMPLEMENTED`/501 instead of silently aggregating the unfiltered rows. + Aggregations without a `filter` are byte-identically unchanged, including + their native pushdown path. +- 2570ab0: The `__search` companion is no longer provisioned or backfilled on objects whose only companion source is the primary key (#10290) + + `resolveSearchCompanionSources` resolves the companion's source through + ADR-0079's `resolveDisplayField`. That derivation ends at "first title-eligible + field by declaration order", and on a table whose only text column IS its + primary key — system tables, junction tables, append-only logs — it lands on + `id`. `id` is `type: 'text'`, not hidden and carries no `requiredPermissions`, + so it passed the eligibility gate: `provisionSearchCompanion` declared a + `__search` column on those objects and `plugin-pinyin-search`'s backfill walked + them at every boot. + + That work is doomed by construction rather than merely unlikely. Both writers — + the `beforeInsert`/`beforeUpdate` stamp and the boot backfill — gate on + `containsCJK(row[source])`, and a platform-generated primary key is ASCII by + construction, so the predicate can never be true. Measured on a real + `bootStack` of `examples/app-showcase`: **20 of the 66 objects** the backfill + enumerated were in this state, walking whole platform tables to compute nothing + — `sys_secret`, `sys_oauth_access_token` and `sys_jwks` among them. + + `resolveSearchCompanionSources` now returns `[]` when the resolved display + field is the record's primary key, and `isPrimaryKeyField` is exported as the + named judgement behind it. + + **Keyed on the field's ROLE, not on "resolved by fallback".** The registry's + materialization seam runs `provisionPrimary(schema, { synthesize: false })` + before this module — a contractual order — and that pass writes `nameField: + 'id'` onto the document, so by the time provisioning asks, a derived fallback + and an author's explicit pointer are byte-identical. The role is readable from + the name because that is where the platform keeps it: the driver provisions + `id` on every physical table unconditionally and there is no per-field + `primaryKey` marker in the spec, which is why `isPreservableUnderAudit` already + keys on `SystemFieldName.ID` for the same reason. `_id` is refused as the + alternate spelling of the same address. + + **This interprets ADR-0079, it does not amend it.** The title contract is + untouched: `resolveDisplayField` still resolves `id`, `provisionPrimary` still + designates it, and `resolveRecordDisplayName` still renders the `Record #` + floor. Only the search normalizer declines to take its input from there — the + same distinction #4483 drew one seam over on the READ path, where the display + field's job in the `$search` auto-default is to ORDER the set and never to + ADMIT a field the exclusions already rejected (`SEARCH_AUTO_EXCLUDED_FIELDS` + names `id` and `_id`). + + **What does not change.** Existing permanently-NULL `__search` columns on + already-migrated tables stay: ADR-0045 migrations are additive and dropping a + physical column is a separate decision. Those deployments still stop walking — + the backfill skips an object whose sources resolve empty even when its schema + still declares the column. Objects with a real name/title field are unaffected: + provisioning, write-time stamping and the query-time `$or` clause all behave + exactly as before, including when the object also declares an `id` field and + when its display field is a plain text column that is not named `name`/`title`. +- 95437e7: fix(driver-sql): `introspectSchema()` emits the spec introspection contract — `primaryKey`, `dialect`, `introspectedAt` (#10676, #10998) + + **BREAKING** change to the value `SqlDriver.introspectSchema()` returns, shipped + as `minor` under the repo's launch-window convention for breaking changes. + + `packages/spec/src/contracts/schema-diff-service.ts` declares one introspection + contract. The driver declared a second one beside it and, separately, so did + `packages/objectql/src/util.ts`. The three agreed on the idea and disagreed on + the vocabulary: the driver spelled a column's primary-key membership + `isPrimary`, the spec spells it `primaryKey`; the spec declares `dialect` and a + REQUIRED `introspectedAt` that the driver's schema type never mentioned and + `introspectSchema()` therefore never emitted. Nothing was type-unsound — each + side compiled against its own declaration and the value crossed between them + with no compiler in the middle. + + Measured on a live in-memory SQLite database before this change: the id column + of a `primary key (id)` table came back carrying `isPrimary: true` with no + `primaryKey` key at all, and `Object.keys()` of the schema was `["tables"]`. + Two consequences, both silent: + + - `ExternalDatasourceService.generateObjectDraft` reads `col.primaryKey`, so + every federated object drafted from a real remote table lost the remote + primary key — the addressing key for the federated table, dropped by the + codegen meant to produce it (#10676). + - type mapping ran with `dialect: undefined` across the whole federation path, + making every per-dialect alias in `suggestFieldTypeForSqlType` unreachable + there, and `refreshCatalog` persisted `dialect: undefined` into the + `external_catalog` record Studio's schema browser and the boot gate read + back (#10998). + + Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 = + 驱动侧对齐 spec 契约): `packages/spec` is the one contract and the driver + aligns to it. + + What the driver now returns: every column carries the boolean `primaryKey`, the + schema carries `dialect` and `introspectedAt`, and the retired `isPrimary` + member is gone rather than emitted alongside — one spelling, so no consumer can + key off the wrong one again. `dialect` is the driver's canonical dialect name + (`sqlite`, `postgres`, `mysql`, `unknown`), which is the vocabulary the only + in-tree consumer keys its alias tables on; `introspectedAt` is an ISO 8601 + instant stamped before the reads begin. + + `IntrospectedColumn`, `IntrospectedTable` and `IntrospectedSchema` in both + `@objectstack/driver-sql` and `@objectstack/objectql` are now derived from the + spec contract instead of re-declared, so a key added there fails their `tsc` + until the producer emits it. Two divergences are kept explicitly: `defaultValue` + stays `unknown` at the SQL layer because Knex reports `null`, and `indexes` is + omitted rather than emitted empty because this driver does not introspect + indexes and an empty array would tell a schema differ that a table has none. + + TypeScript consumers of the removed member are told by the compiler, precisely + and at every site: `Property 'isPrimary' does not exist on type + 'IntrospectedColumn'`. + + +- 8012960: `lifecycle.ttl` now accepts an `onlyWhen` row filter mirroring `retention.onlyWhen`, and the shared `onlyWhen` value union gains the platform's canonical null predicate `{$null: boolean}` (on both blocks). A `transient` object that interleaves live rows with terminal audit tombstones can now spare rows defined by a value's absence — e.g. `ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }` — instead of the TTL reaping backdated tombstones first. The LifecycleService Reaper passes `ttl.onlyWhen` into the same reap scope `retention.onlyWhen` already rides; declaring `ttl.onlyWhen` together with rotation storage or archive is refused at parse time, mirroring retention's guards. + +### Patch Changes + +- 7d483e1: The Archiver resolves its window through ADR-0057 P4 governance (#10528). + `LifecycleService.archiveObject` read `archive.after` — and, since #10347, + `ttl.expireAfter` — straight off the declaration, so for any object declaring + `lifecycle.archive` an operator's settings override was silently ignored, a + registered `LifecycleRetentionFloor` was never evaluated, and per-tenant windows + did not apply. + + This was not a forgotten call. `reapObject` **returns** into `archiveObject` for + any object declaring `archive`, so the three `effectiveWindowMs` resolutions on + the reap path sat on a branch archive-declaring objects skip entirely — which is + why the divergence was total rather than partial, and why threading an override + into the cutoff alone would still have left floors and tenant windows unreached. + + All three legs now run, through the same resolver the Reaper uses: + + - a per-object `retention_overrides` entry beats the declaration, on the key that + matches which window the selection picked — `expireAfter` for a ttl-selected + archive, `maxAge` for an age-selected one; + - an override below a registered floor is rejected (the declared window stands), + logged at `error` naming the registrar, consequence and fix, and recorded in + `report.floorViolations` — the leg whose absence was *silent*, since an empty + `floorViolations` is indistinguishable from a healthy sweep. A *declared* + archive window below a floor is reported the same way and still enforced; + - tenant-scoped windows issue one candidate read per overriding tenant, then one + global pass covering everyone else including NULL-org rows — the shape `reap()` + already used, with tenant overrides going through the same floor. + + Unchanged on purpose: #10347's cutoff **selection** (a declared `ttl` still + decides which rows move, on `ttl.field`); the retain-first posture (no archive + datasource ⇒ `archive-pending`, hot-delete only what the cold store took); the + per-batch abort checks, now the first act of every pass; and the cold-side + `archive.keep` prune, which bounds the archive rather than the hot store and has + no settings key. An object with no override and no floor sweeps exactly as + before, as one pass over exactly the predicate it ran before. +- 530c1df: **Behaviour change:** a `lifecycle` that declares both `ttl` and `archive` now + has its **`ttl` enforced** — the Archiver selects the rows it moves by the + declared TTL cutoff (`ttl.field` past `ttl.expireAfter`) instead of by + `created_at` age (#10347). + + That pair has always parsed — ADR-0057 §3.5 is satisfied because `ttl` is a + bounding policy, and the `archive.after === retention.maxAge` refine only fires + when `retention` is present — but it did nothing: `LifecycleService.reapObject` + returns into `archiveObject` before its `ttl` branch is reachable, so no reap on + `ttl.field` ever ran and the Archiver copied and hot-deleted by `created_at` age + alone. Declared, not enforced. What the author wrote is now what executes; they + no longer have to discover that the two keys cannot usefully be written + together. + + **Lifecycles that declare `archive` without `ttl` are unaffected** — they keep + selecting rows by `created_at` past `archive.after`, unchanged. Every + archive-declaring object shipped with the platform (`sys_audit_log`, + `sys_metadata_audit`) is that shape, so no bundled object changes behaviour. + + Two details of the new selection, both deliberate: + + - A row whose `ttl.field` is **null or absent is retained, not archived**. `$lt` + is a positive comparison and a value that is not there satisfies none of them + (the platform-wide null answer settled in #5298/#5299), which is also the + right reading: a row with no expiry stamp has not been given one, and treating + "absent" as "expired at the epoch" would archive exactly the rows whose expiry + the author has not yet decided. + - The cold-side `archive.keep` prune is unchanged. It bounds how long **archived** + rows survive in cold storage, not which hot rows are due, and it still measures + from `created_at` under either policy. + + If you declare `retention` beside `ttl` and `archive`, the TTL cutoff is what + selects: the age window no longer separately bounds the hot store for that + triple. Whether the Archiver should honour both windows is a separate open + question, filed as #10527 rather than decided here. +- d23e3a0: **Waste removed:** the lifecycle dangling-reference audit no longer asks a federated (ADR-0015 `external`) remote for platform anchor columns that were never provisioned on it (#8414). + + `applySystemFields` injects `organization_id`, `owner_id`, `owning_business_unit_id` and the audit `*_by` lookups into every registered object, federated ones included — that is deliberate (#7865, direction B). `Engine.syncObjectSchema` then issues no DDL for a federated object, because the remote database owns its schema. So those five reference columns existed in the registered schema and nowhere else, and `auditDanglingReferences` — which enumerated reference fields off `fields` alone — projected all of them onto the remote table. Measured on a real boot of `examples/app-showcase`, against a `customers` table whose real columns are `id, name, email, region, lifetime_value`: + + ``` + select `id`, `organization_id`, `created_by`, `updated_by`, `owner_id`, `owning_business_unit_id` from `customers` limit ? + select * from `customers` limit ? + ``` + + The first statement cannot compile (`no such column` — a backtick-quoted identifier does not take SQLite's double-quote literal fallback, and Postgres/MySQL raise their own error); `SqlDriver.find`'s unknown-column recovery caught it and retried `select *`, fetching up to 500 whole rows to audit columns that cannot exist — once per federated object, every lifecycle sweep interval, each pass also emitting a #4363 non-deterministic-paging warning. **No answer was ever wrong**; the pass was pure waste, and it was being absorbed by a safety net rather than by a design. + + The enumerator now consults `unprovisionedInjectedColumns` (`@objectstack/spec/data`, the #7865 provenance derivation) and skips columns that are the platform's own injected anchor on an object the platform provisions no storage for. + + **This reads provenance, not `external != null`.** A federated object that declares a real remote `organization_id` — or any other anchor name — keeps its audit on that column: the author's definition is not byte-identical to the shipped one, so provenance answers `'author'` and nothing is withheld. Objects the platform provisions storage for are untouched: the derivation returns an empty set for them, so an ordinary object is still swept with its full column set. + + Two consequences worth knowing: + + - A federated object left with **no real reference column** is no longer read at all, and is deliberately not filed in `unscannedObjects` — a column that was never provisioned stores no reference, so its absence from `dangling` is proven, not assumed. A federated object that declares a real reference column is still opened and audited on it. + - `AuditableObject` now carries an index signature. The port was already being handed the whole registered document (the engine passes `SchemaRegistry.getAllObjects()` straight through); the type now says so, because the provenance derivation reads the injection plan's inputs off it. Hand-written doubles carrying only `name`/`fields` still satisfy the type and behave exactly as before. + + The card also named `backfillSearchCompanion` (`@objectstack/plugin-pinyin-search`) for `select `id`, `name`, `__search` from `customers``. **That statement is already gone and this release changes no code for it:** #9469 stopped `provisionSearchCompanion` from declaring `__search` on a federated object, so the backfill's existing `if (!schema.fields[SEARCH_COMPANION_FIELD]) continue` early-out drops those objects before enumerating anything. A second federation-aware guard inside the backfill would have been redundant, and — spelled as "skip external objects" — would have wrongly withheld the companion from a federated object whose author declares a real remote `__search`. The precondition is now pinned on a real boot instead. +- f3a8134: Apply a `formula` field's declared `scale` when the formula is evaluated + (#10280). `Field.formula({ scale: 2 })` was accepted and then ignored: a + percentage formula such as `(record.num_responses * 100.0) / record.num_sent` + **returned** `41.666666666666664`, so the API response — and the record page + rendered from it — carried all fifteen digits despite the declaration. + + The value is now rounded where it is produced, in the engine's formula + evaluation, so all three surfaces that materialize a formula inherit it: list + reads, single-record reads, and the record a write responds with. + + - **Rounding is `Number(v.toFixed(scale))`** — round-half-away-from-zero, the + same arithmetic the console's client-side computed columns use. Negatives + round away from zero: `-1.5` at `scale: 0` is `-2`, not `-1`. + - **A formula declaring no `scale` is unchanged** and keeps full precision. + - **Non-numeric results are untouched** — a formula returning a string, + boolean or `null` is returned as-is. + - A formula value is **returned, never stored** — it is virtual and has no + column. Rounding it at the producer is what makes an app's own copy of that + result writable into a stored `DECIMAL(10, 2)`-style field, which previously + failed that field's decimal validation. + + Unchanged: `scale` on a **caller-supplied** number (`Field.number`, + `Field.currency`, …) is still enforced by **rejection** (`max_scale`), never by + rounding. A value someone sent has an author to refuse; a platform-computed + formula result does not. +- d728325: Per-item publish (`POST /api/v1/meta/:type/:name/publish`) now re-binds runtime consumers + and finds drafts authored env-wide — the two things the package-scoped publish door + already did. + + **A metadata publish now announces `metadata:reloaded` on BOTH doors.** The event that + tells boot-cached consumers to re-read had two announcers: the dev-artifact watcher and + the runtime dispatcher after `POST /packages/:id/publish-drafts`. Publishing item by item + — what AI authoring and the item-level Studio doors do — announced nothing, so a flow + published while the server ran stayed `state='active'` and completely inert (no trigger + bound, no execution) until the kernel was rebuilt. `publishMetaItem` now notifies its host + through a new `onMetaItemPublished` seam and `ObjectQLPlugin` turns that into the kernel + announce, so `service-automation`'s flow re-bind, the authored hook/action re-sync, + declarative connectors and authored translations all catch up without a restart. The + announce is awaited, so the publish's own 2xx means the re-bind was attempted; a + subscriber failure is logged and never fails the publish. The batch door is unchanged — + it keeps its single per-publish announce rather than gaining one per promoted draft. + + **A per-item publish now resolves the draft's own org scope.** For the types the registry + declares `allowOrgOverride: true` (`view`, `dashboard`, `report`, `translation`, + `email_template`) the REST seam threads the session's active organization into the + publish, while package/AI authoring writes the draft env-wide — so the strict org lookup + matched nothing and answered `404 [no_draft] … nothing to publish` over a draft the + console's pending-changes banner was listing and the batch button published fine. The + per-item door now discovers the draft's scope the way `publishPackageDrafts` has since + #3115, with the ADR-0005 precedence (an org holding its own draft publishes that one) and + the same `NO_DRAFT` refusal when no scope holds a draft. +- a037f7c: Fix JSON-field writes on Postgres deployments that manage DDL out-of-band + (`skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`): a non-empty array and a bare + string were rejected with a 500, and an empty array was **silently stored as an + empty object** (#10995). + + The SQL driver does `JSON.stringify` a JSON field's value on every non-SQLite + dialect — but only for fields listed in its per-object `jsonFields` registry, + and that registry (like the boolean / numeric / date / datetime / time / + auto_number registries and the tenant-isolation column) was filled **only** as + the first step of a DDL call. A deployment that skips boot schema sync therefore + served every write knowing nothing about its objects, and values reached + node-postgres to be encoded by its per-type defaults: + + - an **object** became JSON text — accidentally correct; + - an **array** became a Postgres ARRAY LITERAL (`{…}`) — `22P02 invalid input + syntax for type json`, a 500 on every write; + - **except `[]`**, whose array literal `{}` is valid JSON, so an empty array was + accepted and stored as an empty **object** — corruption, not an error; + - a **bare string** was passed raw (`x` is not JSON text, `"x"` is) — a 500, + while a number survived because `42` already is valid JSON. + + SQLite never showed any of it: `formatInput` ends with a bind-safety net gated + on that dialect, so the same empty registry is invisible there — which is why + tenant environments on Turso/SQLite and the suites that run on them were blind + to a defect live on every Postgres deployment. + + The registration is now separable from the DDL, on the ruling #7737/#10629 + already made for federated objects — that flag is about DDL, and a binding that + is DDL-free must not ride on it: + + - `SqlDriver.registerObjectMetadata(objects)` installs a managed object's + coercion metadata with no `CREATE TABLE`, no `ALTER TABLE`, no existence probe + and no round-trip — the managed sibling of `registerExternalObject`, declared + optional on `IDataDriver` so drivers that don't need it omit it; + - a `skipSchemaSync` boot (and metadata reload) now takes that route instead of + doing nothing, keeping the cold-start budget the flag exists to protect; + - `initObjects` registers before the ADR-0015 DDL gate refuses, so objects on a + datasource ObjectStack is only a guest in are encoded from their declared + field types too. The refusal itself is unchanged. +- d29e271: Index short name → FQN in `SchemaRegistry` so name lookups stop scanning the + whole registry (#10945). + + `SchemaRegistry.resolveObjectKey` answered the short-name direction by walking + **every** key of `objectContributors` and calling `parseFQN` on each. It is + reached from seven call sites — `getObject` among them — so a kernel boot that + registers N objects and resolves O(N) names did O(N²) string work, with + `parseFQN` the largest non-database entry in the CPU profile. + + The consequence was a silence rather than a failure: boot got slower purely by + an environment accumulating metadata, and once bootstrap outgrew the request + waiter every request answered `kernel_warming` and the environment could never + be opened — no error anywhere. + + `resolveObjectKey` now reads a short-name → FQN index `Map` maintained beside + `objectContributors`. Both containers are mutated only through two private + choke points, so they cannot drift apart: a caller cannot add a contributor + list and forget the index half. + + Resolution is deliberately unchanged. The index array holds the same members in + the same order as the list the scan built, so an ambiguous short name still + resolves to the **first** key registered under it, the ambiguity warning still + names every match, and the legacy `__` fallback still works. That + equivalence is pinned against the old loop itself, over every registration + order, rather than against a hand-written expectation. + + Measured on the same container, resolving one name per registered object: + + | registry | 32,000 lookups over 4,000 objects | scaling ratio at 8× input | + |---|---|---| + | full-registry scan | 4,888 ms | 62.5× (quadratic) | + | short-name index | 2.3 ms | 5.7× | +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [7d81c88] +- Updated dependencies [5886ee6] +- Updated dependencies [02d56b4] +- Updated dependencies [82cb6e8] +- Updated dependencies [d806081] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [2866d5f] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [e222a53] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [047ac86] +- Updated dependencies [3ee8ddf] +- Updated dependencies [0c24898] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [490879a] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [38bc74e] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/spec@17.2.0 + - @objectstack/metadata-protocol@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/metadata@17.2.0 + - @objectstack/formula@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 7d407d8307..ea79b11be1 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.1.0", + "version": "17.2.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 c6bb4fa4b3..bf08663e04 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,103 @@ # @objectstack/observability +## 17.2.0 + +### Minor Changes + +- 914c413: fix(observability): **BREAKING** — `http_request_errors_total` is retired (ADR-0049 enforce-or-remove, #9834) + + **⛔ If you have a Grafana panel, an alert rule or a recording rule keyed on + `http_request_errors_total`, it will read a FLAT ZERO after this upgrade.** That + zero is the removal, not a healthy server, and it is the one way this change can + hurt you — nothing throws, nothing warns, the series simply stops receiving + samples. Rewrite the query before you deploy. + + Maintainer ruling 2026-08-20: **RETIRE**. The name was declared in `SEMCONV` as + part of a stable namespace *"so hosts can wire alerts/dashboards against it"*, + but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, + applied only by the dispatcher's own route Proxy — so the series never saw + auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other + inbound surface. Its two siblings in the same HTTP family moved to the + `IHttpServer.afterResponse` transport seam (`http_requests_total`, #9650/#9835; + `http_request_duration_ms`, #9834/#10004) and this one could not follow: + `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` + and **no throw signal of any kind**, so every transport-side shape would have + counted a *different* population rather than the same one more widely. + + Migration (FROM → TO): + + | Wrote | Write instead | + |---|---| + | `rate(http_request_errors_total[5m])` in a panel or alert | `rate(http_requests_total{status=~"5.."}[5m])` — emitted by the transport, so it covers every inbound surface instead of the dispatcher's routes only | + | `sum by (route) (http_request_errors_total)` | `sum by (route) (http_requests_total{status=~"5.."})` | + | `SEMCONV.httpRequestErrorsTotal` / `RUNTIME_METRICS.httpRequestErrorsTotal` in host code | Delete the read. Both members are gone; `tsc` reports the missing property at the read site. | + + One-line fix: replace the metric name with `http_requests_total{status=~"5.."}`. + + + + **The replacement is wider, not merely different.** The retired counter was + divergent from a 5xx rate in *both* directions, measured: the dispatcher answers + its own errors through `errorResponseBase`, which sets a status and does **not** + re-throw — so the counter **missed** those — while its `catch` incremented + unconditionally, so a **thrown 4xx WAS counted** as an error. And + `http_requests_total` already carries a `status` label, so a status-class error + counter was fully derivable from data the transport already publishes. Prove the + new query wider rather than merely non-empty: make an auth route or a REST + data-API route answer 5xx and confirm it moves, where the retired counter would + not have moved at all. + + **If what you were actually alerting on was "a handler threw rather than + returning an error envelope"** — the one signal this counter uniquely carried — + that is the `errorReporter`, not a metric. Wire an `ErrorReporter` adapter + (Sentry / Datadog / your own); it still fires on every 5xx throw and is + untouched by this change. + + What is NOT removed: `http_requests_total`, `http_request_duration_ms`, + request-id propagation, the 5xx error reporter, and the + `res.__obsRecordedError` side channel that carries a swallowed error to it. The + dispatcher still instruments every route it mounts; it just no longer publishes + a fourth series whose name promised more coverage than it had. + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 319a791d9b..3e24b0901e 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.1.0", + "version": "17.2.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 8731f4ae7a..92f0d8ba69 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,215 @@ # @objectstack/platform-objects +## 17.2.0 + +### Minor Changes + +- dccbcec: Declare an ADR-0057 lifecycle policy on `sys_session` (#7826): the object is + now `class: 'transient'` with + `ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }`. + + **Ordinary expired sessions are now reaped** by the LifecycleService Reaper one + day after `expires_at` passes — the same window `sys_device_code` uses. Until + now nothing swept this table: better-auth's only expiry-driven collector fires + inside `GET /get-session`, so it can never reach a row whose cookie is never + presented again, and an abandoned session was effectively immortal. + + **Revoked tombstones are deliberately spared.** The `onlyWhen` filter (#10165) + is load-bearing, not defensive: the #7732 revocation write backdates + `expires_at` to `now - 1000` and clears nothing, so an ADR-0069 D4 audit + tombstone looks *maximally* expired — a TTL on `expires_at` without the filter + would reap the audit trail first and hardest. + + Deliberate, known consequence: because tombstones are spared entirely, + `sys_session` still grows without bound on the revoked arm. How long a + revoked-session tombstone should be retained is compliance / audit-trail + policy and is not settled here. + +### Patch Changes + +- 8f04d9a: Correct a false vendor claim in the `organization/add-member` source comments: + `teamId` has **no** active-team fallback (#10532). Two comments — the + `sys_member` `add_member` action metadata (the origin) and the + `organization-add-member.ts` module header that cited it as authority — stated + that "organizationId/teamId default to the caller's active org/team when + omitted". Measured on the installed better-auth 1.7.1 + (`dist/plugins/organization/routes/crud-members.mjs`, inside `addMember`), only + the organization half is true: + + ```js + const orgId = ctx.body.organizationId || session?.session.activeOrganizationId; + const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0; + ``` + + `activeOrganizationId` is read 8 times in that module; `activeTeamId`, never. An + omitted `teamId` therefore stays `undefined` and the member joins no team — every + `if (teamId)` branch (team lookup, `TEAM_NOT_FOUND`, per-team limit) is skipped. + + No runtime behaviour changes, and no deployment was ever misled: the `add_member` + action's `params` list carries no `teamId`, so the toolbar never sent one and the + claim was never exercised. What the comment did mislead was the next reader of + the mount, which cited it as the justification for forwarding request headers — + forwarding buys the organization default only. Forwarding `teamId` itself remains + correct: pass it and it works. + + The asymmetry the docs now publish is held by a new pin, + `organization-add-member-team-fallback.test.ts`, which reads the fact out of the + installed vendor artifact (not out of our own comments) so that a future + better-auth bump *adding* an active-team fallback reddens instead of silently + putting the docs out of date. +- 02b3b07: Point every runtime-emitted documentation URL at the canonical host, and retarget the + metadata-protection `docsUrl` at a page that actually exists. + + Two defects, one string. The host half: `docs.objectstack.ai` is an alias that redirects + to `https://objectstack.ai` path-preservingly, so nothing here was a broken link — it was + the unratified spelling sitting in the places a user copies from. The CLI's spec-version + advisory, the Setup and Studio in-app overview pages (English and Chinese alike), and a + showcase demo action now all name the canonical host. + + The path half is the real fix. All 29 `protection.docsUrl` values on the platform's + system objects and apps pointed at `/adr/0010-metadata-protection`, and `/adr/...` is not + a route on any host: the docs site mounts `content/docs` under `/docs`, `docs/adr/` is + not published, and no redirect source lives outside the `/docs` space. The slug was wrong + too — the record is `0010-metadata-protection-model.md`. Studio renders this URL as a + link in the lock banner, so an operator asking why an item is locked was being sent + nowhere. They now point at `https://objectstack.ai/docs/references/shared/protection`, + the published reference for the very schema that carries the field. +- 0ab81d1: fix(metadata-protocol): the seed/API tenancy repair now records each applied run in `sys_migration`, so "was my data rewritten, and when" survives the container being replaced (#9451) + + `backfillSeedTenancy` (#8686) is the platform's only row-rewriting repair that + runs unattended: it stamps `organization_id` onto business rows, merges one + autonumber counter and deletes another. It persisted nothing about having done + so. The only evidence was one `logger.info` line, and the healthy path is silent + by design — so once that line had scrolled, a silent boot and a boot that + rewrote data were indistinguishable. The operator most likely to need the record + (a fresh install, repaired during the first admin sign-up, where nobody is + reading server stdout) was the one least likely to have captured it. + + An `applied` run now writes one row into the **existing** `sys_migration` + deployment ledger — the face that already answers "has this deployment run this + data migration", and is already written at boot by the ADR-0104 attestation + path: + + ```sql + SELECT last_run_at, advisory, details FROM sys_migration + WHERE id = 'seed-tenancy-backfill'; + ``` + + `details` carries the run's status, the objects stamped, the organization + adopted and the identifiers that could not be adopted because they were already + minted on both sides of the split. + + Deliberately narrow: + + - **`applied` only.** `no-split` stays silent — a row per healthy boot would be + a ledger of non-events. + - **`verified_at: null`, `blocking: 0`, always.** This repair runs no self-check + and gates no consumer, so it claims no certificate; the collision count goes + to `advisory`, which never gates. Every reader of this ledger looks a row up + by `id`, so the new id cannot reach another migration's gate. + - **Best-effort, and loud when it fails.** A boot is never failed by + bookkeeping (2026-08-15 ruling), so a failed receipt write is reported at + `error` — naming that the rows *were* rewritten, that the repair is not + retried, and what to do — rather than rethrown. + - **No new schema, no new authoring surface, no new dependency.** The row is + written against the `@objectstack/spec/system` contract that + `metadata-protocol` already depends on. +- 266654d: Show 2FA backup codes on the surface a user can actually reach — the reachable + regeneration path was a lockout (#10681). + + `sys_user.generate_backup_codes` is mounted at Setup → People & Organization → + Users (Security tab, via `record:quick_actions { location: 'record_section' }` + in `pages/sys-user.page.ts`). It declared no `resultDialog`: it toasted "New + backup codes generated — save them somewhere safe", issued the request, and + dropped the response. The previous code set is invalidated wholesale the moment + that request succeeds, so the reachable path was *old codes destroyed, new codes + discarded* — with no way to get them back: + + - better-auth's `twoFactor()` defaults to `storeBackupCodes: 'encrypted'` and + `auth-manager.ts` passes no `backupCodeOptions`, so `sys_two_factor.backup_codes` + holds `symmetricEncrypt(JSON.stringify(codes))` — one opaque ciphertext. + - `auth-route-ledger.ts` publishes `generate-backup-codes` and **no** route that + reads codes back. There is no re-reveal endpoint, by design. + + So the API response is the user's one and only sight of those codes. + `generate_backup_codes` now declares the one-shot reveal + (`{ path: 'backupCodes', format: 'code-list' }`) and `enable_two_factor` the QR + equivalent (`totpURI` as `qrcode` + `backupCodes`), which suppresses the toast + and opens an acknowledge-only dialog instead. Both copy the shapes + `sys_two_factor.enable_two_factor` / `regenerate_backup_codes` already carried — + deliberately not a third and fourth spelling of the same declaration. + + **Why the correct declarations existed and still did not help.** `sys_two_factor` + carries them and is mounted in **no** app — it appears in no navigation + contribution — so the only 2FA surface a user can reach was the one missing them. + That is why the new pin + (`packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts`) + walks the Setup-navigation → page → quick-actions → action chain rather than + asserting a key is present, and holds coverage over a **derived** set: every + identity action targeting a route known to return an unrecoverable secret must + reveal it. A fifth 2FA surface added later is held to the same rule with no edit + to the test. It also fails a `successMessage` declared alongside a + `resultDialog` — the toast is suppressed, so such a message is dead text, and in + this case it was the very string that made the defect look handled. + + The declaration-to-response join is measured over a booted stack in + `packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts`: the + declared paths are resolved against the live route's real response, because a + path that stops matching better-auth's response shape opens an **empty** dialog + and loses the codes just as thoroughly, while every declaration-shape assertion + stays green. + + **Also corrected, same area:** `sys_two_factor.backup_codes` was described as + "JSON-serialized backup recovery codes". It is JSON *before* encryption; what the + column stores is the ciphertext above. The description now says so, since the + whole reason the reveal must happen at generation time is that this column + cannot be read back. + + **Not addressed here:** mounting `sys_two_factor` into navigation is a larger + product-surface decision and is only raised, not taken; `#10700` (re-enrolment + rotating the TOTP secret while keeping `verified=1`) is a separate defect and + remains open. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/spec@17.2.0 + - @objectstack/metadata-core@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index a7ac27bd0d..c908e1a2b0 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.1.0", + "version": "17.2.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 e32be05440..cd06164897 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,108 @@ # @objectstack/embedder-openai +## 17.2.0 + +### Patch Changes + +- 6ceaa4b: docs: name packages that exist in seven published documents, and gate the class (#10893) + + A published README ships inside the npm tarball, so an install instruction in one + reaches every reader of the package. Nine `@objectstack/` names across seven + published documents named a package that is in **no directory of this repo**, and + five of those sat on `import` lines inside runnable fences. + + `check:published-readme-exports` could not see any of it, by construction. It + resolves a documented import against the package's built type surface through the + workspace member map, so a specifier that is not a member has no type entry to + compare against and the gate reads no further — strict about a member that exists, + silent about one that does not. The gate now makes the member-existence claim + first: an `@objectstack/`-scoped specifier that names no workspace member is a + finding, and the run header prints the scoped population as `N/N` so a recogniser + that stops matching shows up as a denominator that fell. + + What each dead claim now says, and why: + + - **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`** + each misnamed **themselves**. Both READMEs — including their `# ` titles and + every fenced import — said `@objectstack/plugin-trigger-…`, a name that has + never been published. The exported class names (`ScheduleTriggerPlugin`, + `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all + along; only the package name was wrong, so this is a rename pinned by each + package's own `name` field. + - **`@objectstack/plugin-security`** told readers to `install + @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No + such package exists. The organization wall ships as the enterprise + `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the + `org-scoping` service this plugin probes — the name `objectstack serve` and + `objectstack doctor` both print. Asking for the wall without it is a refusal to + boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The + tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the + cloud control-plane runtime from the separate `cloud` repository and not where + the wall comes from either. + - **`@objectstack/service-package`** described packages being "delivered to + runtime kernels that load them through `@objectstack/service-marketplace`". That + package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future + work. The loading half that does exist here is + `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`. + - **`@objectstack/embedder-openai`** had a fenced example importing + `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape, + because a reader pastes it. No knowledge adapter in this repository consumes an + `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder + option, and the adapters the contract is written for are not here. The example + is now the `embed()` surface that does exist, with the gap stated rather than + papered over with a substitute package name. + - **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against + `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has + ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite + through Knex, choosing the client from its optional peers. + - **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code + generators to write `import { User } from '@objectstack/protocol'`. The package + is `@objectstack/spec`, which the same sentence names as the path being + replaced. + + Four `@objectstack/` names that are **not** in this repo are deliberately left as + they are, because prose may name a package this repo does not build and a runnable + import may not: `@objectstack/security-enterprise` (the enterprise edition, whose + install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant` + (the cloud runtime), `@objectstack/framework` (the umbrella install name), and the + two names `service-datasource`'s README recalls as its own past. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 81171f638d..83aef704fd 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.1.0", + "version": "17.2.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 67f54ed7db..b86603d72a 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/knowledge-memory +## 17.2.0 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [7bf3fb7] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [e222a53] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [6d5c4fa] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/service-knowledge@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index f26f4add1f..00c6e9cdae 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.1.0", + "version": "17.2.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 8475d249e0..0a25f2517a 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,64 @@ # @objectstack/knowledge-ragflow +## 17.2.0 + +### Patch Changes + +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [7bf3fb7] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [e222a53] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [6d5c4fa] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/service-knowledge@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 06f24194e2..134b2cb649 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.1.0", + "version": "17.2.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 573f81b4d2..576a312291 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,394 @@ # @objectstack/plugin-approvals +## 17.2.0 + +### Minor Changes + +- b47ba2c: **BREAKING** (compile-time only): `ApprovalServiceOptions['logger']` now + declares a **non-optional** `warn`, so a durability report always has + somewhere to land (#9754, #10556). This is the thirteenth of the thirteen + mechanical repairs the card names — held out of #10691 to serialize against + PR #10547, which owned `approval-service.ts` while it was open; that fence + has since cleared. + + `minor`, not `major`: during the launch window this stack ships breaking + changes as `minor` — every publishable package versions in lockstep, so a + `major` would promote the whole release. `patch` would be wrong in the other + direction, because this *can* break a consumer's build. This is the same + reasoning #10691 used for the twelve sibling repairs; no exemption for a + types-only break was found there either, and none applies here. + + `error` stays optional — hosts legitimately inject reduced sinks, and + requiring `error` was measured and rejected as #9754 option C. What changes + is that its *absence* now has a declared, guaranteed destination. Call sites + keep the `logger?.warn?.(…)` spelling as the backstop for hosts the type + cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that constructs `ApprovalService` (or an `ApprovalServiceOptions` + value) with a `logger` object that has **no `warn` method** — for example + `{ error }` alone. Add a `warn` member; there is no rename, no removal, and no + stored value or metadata key to rewrite. The only non-test construction site + in this repo (`ApprovalsServicePlugin.start`, in this same package) passes the + kernel `ctx.logger`, whose `warn` is already required, so the in-repo cost is + zero. + + +- 13f533a: fix(approvals): screen the `manager` approver to the request's organization (#10153) + + `expandApprovers` hands the directory organization to every graph-shaped + approver expansion — `department`, `position`, `org_membership_level`. The + `manager` branch did not: `lookupManager` read `sys_user.manager_id` under a + system context and took no organization argument at all. `sys_user` is a global + identity table with no `organization_id`, so nothing else on that path supplied + the tenancy fact either. A `manager_id` crossing an organization boundary + therefore routed the submission to an approver **in another organization** — an + out-of-tenant person granted approval authority over the record. + + The same column has been screened on the hierarchy side since cloud#1195. This + brings the approvals consumer into line for the `manager` branch. + + ## What the screen is + + `lookupManager(userId, organizationId)` now resolves the manager and then asks + whether he is **provably outside** the request's organization: + + | membership rows for the manager | result | + |---|---| + | some exist, none in the request's org | **screened out** — the slot falls through to the `manager:` literal | + | one is in the request's org | resolves, unchanged | + | none exist at all | resolves, unchanged — the tenancy fact is absent, not negative | + | the `sys_member` read failed | resolves, unchanged | + | the request carries no organization | resolves, unchanged — and no read is performed | + + The fail-open half is this file's ruled posture on addressing paths, stated + twice already: `filterApproversWhoCanRead` refuses to empty a live slate on an + infrastructure hiccup, and `expandPositionUsers` carries "a step routing to + nobody is worse than one routing to a lapsed holder". A drop is logged with the + manager's id, his organizations and the request's, so the fix ("repair the link" + / "grant the membership" / "retarget the step") is legible without a debugger. + + ## ⚠️ This moves one input from accepted to refused + + A node whose **sole** approver is a cross-org `manager` and which is authored + with the **non-default** `onEmptyApprovers: 'fail'` used to open successfully; + it now throws `NO_APPROVERS`. Nothing new is thrown — a screened-out manager + leaves only a `type:value` literal, which the pre-existing empty-slate test + already classifies as empty, and `'fail'` already throws on empty. Every + screened sibling has reached that same bucket since it was written. + + **The default policy is unaffected**: `admin_rescue` still opens the request + (decidable by a privileged admin) and warns, and `auto_approve` still + auto-approves. Both directions and both policies are pinned in + `manager-approver-org-screen.test.ts`. + + ## What this does NOT decide + + - **#7497** (does approver routing imply record read visibility?) stays open. + The screen reads `sys_member`, which looks like the D2 read filter beside it, + and the code says at length why it is the *sibling* treatment instead: two of + the three org-scoped expansions already screen on `sys_member.organization_id`, + and `sys_user` offers no other tenancy fact. No reads are granted and no read + screen is applied to any type that lacked one. + - **`team`** is still unscreened — it is a sibling graph expansion that is not + org-scoped either, tracked as #10230, and it touches this same file. + - `APPROVER_ORG_SCOPED` is untouched. It answers ADR-0105 D9 *retargetability* + (may an author write `organization:` on this type?), not screening, and + `manager: false` remains correct. +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + + +### Patch Changes + +- 07bd1ca: Apply the subject object's field-level read controls to the approval payload + snapshot at serve time (#10749), so an approver no longer receives fields the + app author declared they may not read. + + `sys_approval_request.payload_json` stores the submitted record's raw row, + captured from the flow's `$record` variable — which the automation layer hands + over with the record's own FLS never applying. The column is a `textarea` on + `sys_approval_request`, so to every read door it is an opaque string: the + field-visibility machinery governs *columns of objects* and cannot see inside a + JSON column. Every field-level read control declared on the SUBJECT object — + `requiredPermissions` (ADR-0066 D3), a permission set marking a field + non-readable, a `maskingRule` — was therefore unenforceable on the approval + path, for every app. + + Per the maintainer's ruling (2026-08-22, Option B) the full snapshot **stays at + rest**: the approval record remains audit evidence of what was actually + submitted, which write-time trimming would have given away. Redaction happens at + **serve** time, keyed on the reading caller, so the same row answers an admin + with the whole snapshot and a restricted approver with only the fields they may + read. The readable set is not recomputed — it comes from the security service's + `getReadableFields`, documented as the same field mask the read middleware + applies, so this seam cannot drift from data-plane FLS. + + Two doors are covered, because `payload_json` has two independent readers and a + seam covering one manufactures the belief that the path is masked: + + - the **service door** (`getRequest` / `listRequests`, behind + `GET /api/v1/approvals/requests[/:id]`), which serves the parsed `payload`. + Redaction runs BEFORE display enrichment, so `payload_display` and + `payload_labels` — both built by walking the snapshot's own keys — cannot ship + a restricted field's name, its authored label, or the title of the record it + points at; + - the **generic data door**: the object declares + `enable.apiMethods: ['get','list']`, so a plain `find`/`findOne` returns the + raw string without the service ever running. Covered by object-scoped engine + middleware, which reaches the whole family sharing that producer (REST data + routes, ObjectQL, CSV/XLSX export, MCP). Middleware rather than an `afterFind` + hook on purpose: a hook receives `buildSession`'s output, which carries no + `onBehalfOf`, so a hook-based seam would drop the ADR-0090 D10 delegator + intersection and answer a delegated read more permissively than the service. + + **Behaviour change, argued rather than assumed.** A non-admin caller that was + reading restricted keys out of the snapshot now receives fewer keys, and that is + a real change for such a consumer. It is shipped as a fix rather than a breaking + change because those fields were never that caller's to read: the approval path + was a bypass of a declaration the platform enforces everywhere else, and the + served type is `payload?: unknown` — never a promised field set. This follows the + `__search` companion strip (#7642), which shipped the same way on the same + reasoning. Object-level access is deliberately untouched: an approver commonly + holds no read grant on the object under approval at all, and + `getReadableFields` answers a caller with no field-permission entries with the + full set, so every approval drawer shipping today keeps rendering. + + `hidden: true` is deliberately NOT acted on. It is a UI contract ("Hidden from + default UI") which, in the spec's own words, "has never governed serialization" + — measurably: no read path in the repo strips a value on it. Enforcing it here + alone would make the approval path stricter than a direct read of the same row + (closing no leak, since the approver can simply read the record) while breaking + drawers that render a `hidden` business column. That is a `packages/spec` + semantics question and is left open. +- f0d7647: **Deliberate search-semantics change.** `ApprovalService.listRequests` / + `countRequests` no longer push a free-text predicate onto the payload snapshot + column for a caller whose view of that snapshot is masked (#11040). + + Since #10749 the approval snapshot (`sys_approval_request.payload_json`, the + submitted record's row) is redacted **at serve time, per reader**: each row is + cut down to the fields that caller may read on that row's subject object. The + full row deliberately stays at rest, so the approval record remains audit + evidence of what was actually submitted. + + The free-text filter, however, is evaluated by the driver against the stored + column — before anything is served, and therefore against the unmasked bytes. A + predicate over that column is a question about its contents whose answer is row + membership, so the field-level read controls #10749 enforces on the way out did + not hold on the way in. That is `declared ≠ enforced`, and the platform has + already settled the governing principle for it: under `maskingRule` (#8993) a + field a caller sees masked is non-filterable, refused loudly, because otherwise + equality probes reconstruct the hidden span. This extends that settled posture + to the snapshot column, which is reached through a different door. + + **What changes.** For a caller whose view of the snapshot is masked, free-text + search matches on `process_name`, `object_name`, `record_id` and `submitter_id` + — the columns of `sys_approval_request` itself, which anyone who can see the row + reads whole — and no longer on snapshot contents. Such a caller can still find a + request by process, object, record id or submitter; they can no longer find one + by a value they may not read. Rows returned, their order and pagination are + otherwise untouched, and no query is refused: the change only ever removes one + disjunct, never denies. + + **What does not change.** A caller the serve path hands the whole snapshot to + keeps today's behaviour exactly — same rows, same order. That includes every + deployment that has not wired a field-visibility authority (the seam is + late-bound, and absent it snapshots are served unredacted), and the case where a + wired authority declines to narrow. Consistency with the serve path is the rule + here rather than blanket fail-closed: where serve hands over the whole snapshot, + keeping the predicate discloses nothing serve does not already disclose. + + The masked/unmasked verdict is read from **the same authority and the same + per-caller call the serve path uses**, asked as the caller. It is deliberately + not a second, independently derived notion of "redacted" — two derivations drift, + and the drift between a serve rule and a filter rule is exactly what this fixes. + + Because redaction is decided per row while a filter is built before any row + exists, the predicate-time scope matters: with an `object` filter the subject + object is known and the seam is asked about it directly; without one the query + spans every object, nothing sound can be asked, and the disjunct is dropped. + + Held by `approval-free-text-scope.test.ts`. +- 6d5c4fa: Release these plugins' resources from `destroy()`, the teardown hook the kernel + actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and + `destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and + `LiteKernel.destroy()`, which walk the plugins in reverse calling + `plugin.destroy()`, walked straight past every plugin whose teardown was spelled + `stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still + armed, the REST/OpenAPI/Slack connectors still registered on the automation + engine, the approvals SLA escalation job still scheduled, and the knowledge + event-sync subscription still open. + + Each teardown body now lives in `destroy()`. `stop()` is retained as a + delegating alias with its parameter made optional, so an embedder that learned + to call it directly — precisely because the kernel never did — keeps working + unchanged. No export is removed and the `Plugin` interface is untouched. + + Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as + fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted + from the merge queue. +- aa765b9: **Who loses access:** members of a team belonging to a *different* organization + than the record being approved. Concretely — a request raised in `org_a` routed + to a `team` approver whose `sys_team.organization_id` is `org_b` used to place + every `sys_team_member` of that team into `pending_approvers`, giving them the + approve/reject buttons on a record they are not a tenant of. They no longer + enter the slate, and the step falls back to the dead `team:` literal with + the existing `#3807` "expanded to nobody" warning — the same shape a cross-org + `position` approver has always produced (#10230). + + `team` was the last approver expansion that resolved people without asking + which organization was asking; `department`, `position`, `org_membership_level` + and (since #10153) `manager` all do. The screen reads the team's own + `organization_id`, so it costs one row and a team that fails it never fans out. + + **Who does not lose access**, deliberately: a team stamped with the request's + own organization; a team stamped with **no** organization (`organization_id: + null` on a platform object means "owned by no organization" — what a seed + writes, since a seed cannot know the id the runtime mints at boot); a team id + with no `sys_team` row at all; and any request that carries no organization — + all four leave routing exactly as it was, because the tenancy fact is absent + rather than negative. + + ⚠️ One externally observable accept→reject change beyond the routing itself: + under the non-default `onEmptyApprovers: 'fail'` policy, a node whose *sole* + approver was a cross-org team used to open a request and now throws + `NO_APPROVERS`. Under the default (`admin_rescue`) the node still opens. +- c5d0c2f: Screen expanded `team` approver members to the request's organization (#10547). + + #10230 made a `team` approver prove the TEAM's tenancy, and deferred the + members on purpose. `sys_team_member` carries `team_id` and `user_id` and no + organization column, so a team that passed that screen still routed every user + id it listed — including a user whose only `sys_member` row is in another + organization. Measured on a fixture, not read off the schema: an `org_a` + request against an `org_a` team returned `["u_outsider","u_insider"]` with zero + `sys_member` reads. + + The expansion now screens the members with the same provably-outside posture + the neighbouring screens pin, in ONE `$in` read for the whole slate: + + - membership rows exist for the user and none is the request's organization + (present and NEGATIVE) — dropped, with a warning naming the users, the team + and both organizations; + - no membership rows, an unreadable `sys_member`, a possibly-truncated read, or + a request carrying no organization (ABSENT) — routing is left exactly as it + was, and the no-organization case performs no read at all. + + Holding membership elsewhere is not disqualifying; holding none here is. + + ⚠️ Behaviour change, confined to one non-default policy: a node whose only + approver is a team staffed entirely by users provably outside the organization + now resolves to no one. Under the default `onEmptyApprovers: 'admin_rescue'` it + still opens, routed to the dead `team:` literal as any unresolved slate is; + under `onEmptyApprovers: 'fail'` it now throws `NO_APPROVERS` where it + previously opened. + + Residual condition on the security value: the screen can only act on tenancy + facts that exist. A deployment that stamps an organization on its approval + requests but does not materialize `sys_member` rows sees no change — by design, + since #3807 recorded what treating an absent fact as a negative one costs. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/formula@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index 084108f735..a25c814622 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.1.0", + "version": "17.2.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 89b7559e14..6829c21140 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/plugin-audit +## 17.2.0 + +### Minor Changes + +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + + +### Patch Changes + +- 76deca2: **Docs (published README) + ruling:** record-view auditing now documents how to turn it on under `objectstack serve`, and the answer to "should `os serve` grow an `appAuditPluginOptions(config)` helper?" is **no** (#9863). + + The README and `content/docs/permissions/record-view-auditing.mdx` both said the audited set is configured "where you compose the kernel", and the docs page went further: *"The CLI's `os serve` registers `AuditPlugin` with no options, so a stack served that way has record-view auditing off and no knob to turn it on."* That last clause stopped being true when #9864 declared and pinned the duplicate-registration contract. The knob is the stack's `plugins` array — a configured `new AuditPlugin({ readAudit: { objects: [...] } })` there supersedes the CLI's option-less instance by name, last-one-wins, on both kernels, with the displaced instance never reaching `init()`. Both pages now spell that path, and name the `Plugin superseded: 'com.objectstack.audit'` boot line as the opt-in working rather than a misconfiguration. + + **No new configuration surface was added, deliberately.** A `config.audit` key read by an `appAuditPluginOptions(config)` helper would reproduce, in `objectstack.config.ts`, exactly the failure #8992's ruling refused for the object-metadata spelling: a declaration that survives in a deployment which never installs this package, reading as coverage while recording nothing. It would also be a *second* configuration surface that silently loses to the first, since an app's own `plugins` entry supersedes whatever the CLI constructed. The `#7001` symmetry argument does not carry it either — `@objectstack/verify`'s `bootStack` constructs no `AuditPlugin` and does not depend on this package, so there is no second boot path to disagree with. + + No runtime behaviour changed. `packages/cli` gains only the reasoning at its registration site and `serve-audit-registration.contract.test.ts`, which pins the three facts the ruling rests on — including the load-bearing ordering (`AuditPlugin` registered above the stack `plugins` loop) that until now was asserted by a comment and nothing else. +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- dd41df3: **Behaviour change (tightening):** `enable.files` / `enable.feeds` are now enforced on the **update** verb, not only on insert (#10170). + + Both capability gates in `audit-writers.ts` registered on `beforeInsert` only. `enable.files` says whether `sys_attachment` rows may **target** an object and `enable.feeds` whether `sys_comment` rows may target it — properties of the target object, not of the verb that got a row there — so a re-point via update landed rows the declaration refuses: a caller who could not *create* an attachment on an object without `enable.files: true` could *move* an existing one onto it, and a comment could be re-threaded into a `feeds: false` object's thread. The access kits authorize the re-point (`comment-access-hooks.ts` since #4630, `attachment-access-hooks.ts` since #10091), but those are **access** checks — the capability half was never asked on update. + + What an operator will now observe: + + - An update of `sys_attachment` whose payload sets `parent_object` to an object that does not declare `enable: { files: true }` is refused with **403 `FILES_DISABLED`** — the same envelope the insert path has emitted since #2727 (ADR-0112: `code` + `status`). Fail-closed as on insert: an absent `enable` block, an absent flag, and an unknown parent object all reject. + - An update of `sys_comment` whose payload sets `thread_id` to a thread on an object declaring `enable: { feeds: false }` is refused with **403 `FEEDS_DISABLED`**. Opt-out semantics as on insert: only an explicit `false` rejects, and a missing or free-form `thread_id` is still allowed through — this is capability gating, not access control. + - Both apply on **both dispatch shapes**: a by-id update (`dispatch.mode` `record`) and a predicate `multi: true` update, which is evaluated per matched row (#5574 / ADR-0058 Addendum II). An unscoped predicate update is refused on its first matched row. + + **No existing row is newly refused, and no update that is not a re-point changes.** The gates read the payload: an update that never names `parent_object` / `thread_id` returns on the gate's first line, so renames, body edits, reaction writes and other column updates on a row whose parent object has since had the capability flipped off keep working exactly as before. Only a write that makes a row *newly target* a walled object is refused. + + **Blast radius.** A structural sweep of the 4 660 in-tree source files found **no** caller — none in `packages/` source, `examples/`, or the dogfood apps — that issues an update whose payload names `parent_object`, and none that re-points `thread_id`; in the console the only `sys_attachment` write is a create, and the only `sys_comment` update writes `reactions`. If you have your own "move this attachment" or "move this comment" flow, point it at a target object that declares the capability, or declare it on the target. + + No new error code: both codes are existing standard-catalog members already registered in `packages/spec/src/api/error-code-ledger.zod.ts` and already mapped to 403 by `packages/rest/src/error-response.ts`. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [2570ab0] +- Updated dependencies [95437e7] +- Updated dependencies [d23e3a0] +- Updated dependencies [f3a8134] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 7b5ad1bbba..74dad716ea 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.1.0", + "version": "17.2.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 5a3455114e..7badcb8e3c 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,512 @@ # Changelog +## 17.2.0 + +### Minor Changes + +- 4d7c564: fix(plugin-auth): the better-auth-native `/admin/` routes refuse an anonymous caller with the ADR-0112 envelope (#10349) + + **BREAKING** response-shape change on the `/api/v1/auth/admin/` namespace, + shipped as `minor` under the repo's launch-window convention for breaking + changes. + + `/api/v1/auth/admin/` is served by two implementations and answered the same + question in two shapes. ObjectStack's raw mounts (`create-user`, + `set-user-password`, `unlock-user`, `import-users`, `ban-user`, `unban-user`, + `oauth2/toggle-disabled`, `sso/*`) refuse an anonymous caller through + `judgePlatformAdmin` with the declared envelope and `code: 'UNAUTHENTICATED'`. + The routes better-auth serves itself refuse through the vendor's + `adminMiddleware` — `getAuthoritativeSessionFromCtx(ctx)` then + `APIError.fromStatus('UNAUTHORIZED')`, with no body argument at all. + + Measured on the installed better-auth 1.7.1, anonymous, through + `AuthManager.handleRequest`: ten vendor-lane routes (`impersonate-user`, + `set-role`, `revoke-user-sessions`, `revoke-user-session`, + `list-user-sessions`, `update-user`, `list-users`, `get-user`, + `has-permission`, `stop-impersonating`) answered `401` with a + `content-type: application/json` header and the **empty string** as the body. + A client that believes that header and parses the body throws on the refusal + instead of branching on it, and a client that wants to branch has to know, per + route, which of the two implementations happens to serve it — an + implementation detail, not a contract. + + `AuthManager.handleRequest` now gives those refusals the declared envelope at + the one seam every vendor route passes through. **Statuses are unchanged and + admission is unchanged**: nothing that was refused is now admitted, nothing + that was admitted is now refused, and no status moved. What is added is the + machine-readable `code`, derived from the status by ADR-0112's own + `standardErrorCodeForHttpStatus` map rather than spelled out again — so no new + error code is registered and the vendor lane's anonymous refusal is now + byte-identical to the ObjectStack lane's. + + Scope is the `/admin/` namespace only. Three narrowings hold the rest of the + surface still, and each is pinned: + + - **A refusal that already carried a body keeps it, byte for byte.** The + signed-in non-admin's `403` with the vendor's own + `YOU_ARE_NOT_ALLOWED_TO_*` vocabulary is untouched; this change fills in an + empty body and never rewrites a spoken one. + - **Only the two refusal statuses are named** (`401`, `403`). A bodyless `404` + such as `/admin/oauth2/*` with the `oidcProvider` plugin off, and any + semantic `4xx` the vendor owns, are left exactly as they are. + - **Nothing outside `/admin/` is touched.** `POST /sign-in/email` still answers + `401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}`, + measured identical on both sides of the change. + + Consumers that branch on the HTTP status are unaffected. Consumers that already + parse the ObjectStack `/admin/*` envelope now get the same shape everywhere in + the namespace, with no per-route knowledge required. + + +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + +- af1636c: The two SSO domain-verification admin routes now answer a registered ADR-0112 + error code. `POST /admin/sso/request-domain-verification` and + `POST /admin/sso/verify-domain` shape their failure as + `code: parsed?.code || `, and the default half — the code + ObjectStack itself authors when @better-auth/sso returns none — was lowercase + (#10716, found by #10658): + + | route | wrote | writes instead | + | --- | --- | --- | + | `POST /admin/sso/request-domain-verification` | `request_domain_verification_failed` | `DOMAIN_VERIFICATION_FAILED` | + | `POST /admin/sso/verify-domain` | `verify_domain_failed` | `DOMAIN_VERIFICATION_FAILED` | + + If you match on either lowercase spelling, match on `DOMAIN_VERIFICATION_FAILED` + instead — the two routes are distinguished by their path, as they already were + for every other failure they can answer. + + `DOMAIN_VERIFICATION_FAILED` is reused, not invented: it is already registered + for `@objectstack/plugin-auth` in the error-code ledger, so this PR adds nothing + to `packages/spec` and the emitted vocabulary gets no new member. A new spelling + (`VERIFY_DOMAIN_FAILED`) would have needed a ledger registration to be a legal + `error.code` at all, and — measured while fixing this — an unregistered code in + an `||` fallback slot is currently invisible to BOTH error-code gates, so it + would have shipped as exactly the silent fourth state ADR-0112 D3 exists to + prevent. + + **The vendor pass-through arm is unchanged.** `parsed?.code` still reaches the + caller verbatim, so @better-auth/sso's own diagnosis (`NO_PENDING_VERIFICATION`, + `DOMAIN_VERIFICATION_FAILED`) is never overwritten by ours — the half that would + be silently lost by a handler that stamped our code unconditionally, and it is + pinned in both directions by + `packages/plugins/plugin-auth/src/sso-domain-verification-error-codes.test.ts`. + Statuses and messages are untouched on every path. + + ADR-0087 disposition, in prose because the marker vocabulary has no slot for + this shape: nothing is registered and nothing needs to be. The declared wire + contract is `error.code ∈ StandardErrorCode ∪ ERROR_CODE_LEDGER`, and neither + lowercase spelling was ever a member of it — they were undeclared values a + blind gate let through, so this brings the implementation onto the published + contract rather than changing that contract. There is no metadata surface for + `objectstack migrate meta` to rewrite: error codes live in responses, not in + stored metadata. The table above is here for anyone who matched the undeclared + spelling anyway, which is why this ships as `minor` rather than `patch`. +- d9353b9: `POST /admin/sso/verify-domain` now answers the DISABLED condition the way its + sibling always has. When SSO domain verification is off for an environment, + `@better-auth/sso` never mounts the inner endpoint and answers `404` with no + code. Both bridge routes recognise that shape, and they used to answer it + differently (#10859): + + | route | answered | answers instead | + | --- | --- | --- | + | `POST /admin/sso/request-domain-verification` | `400` `DOMAIN_VERIFICATION_DISABLED` | unchanged | + | `POST /admin/sso/verify-domain` | `404` `DOMAIN_VERIFICATION_FAILED` | `400` `DOMAIN_VERIFICATION_DISABLED` | + + `verify-domain` rewrote only the `message` for that branch and let the code fall + through to its generic failure default, so the response carried "the feature is + off" copy under a code that means "verification failed". A caller can only act + on the machine-readable half, and the two halves disagreed. The status moves + with the code: the inner `404` describes the INNER endpoint, which is unmounted, + whereas this bridge route is mounted unconditionally — passing that status + through said "no such endpoint" about a resource that exists. + + If you match on `DOMAIN_VERIFICATION_FAILED` (or on `404`) to detect the + disabled case on `verify-domain`, match on `DOMAIN_VERIFICATION_DISABLED` (or on + `400`) instead — the same pair `request-domain-verification` has always + answered. The distinction is worth having: `DISABLED` means "turn on + `OS_SSO_DOMAIN_VERIFICATION`", `FAILED` means "the DNS TXT record is not visible + yet, retry". + + **No `packages/spec` change, and the emitted vocabulary gains no member.** Both + codes are already registered for `@objectstack/plugin-auth` in the error-code + ledger, with exactly these meanings (`DOMAIN_VERIFICATION_DISABLED` — "domain + verification is off on this deployment"). This route was emitting a *declared* + code whose registered meaning is a different condition, so this is + declared-vs-enforced restoration rather than a new contract decision. + + **A genuine verification failure still answers the failure code, and the vendor + pass-through arm is untouched on both routes.** The rewrite is keyed to the + disabled shape specifically — `404` *without* a code. A `404` that carries + `@better-auth/sso`'s own code is the vendor's diagnosis and reaches the caller + verbatim, status included, as does every non-404 failure. That direction is the + load-bearing one — an implementation keyed to "any 404", or to `!resp.ok`, would + satisfy the disabled case while destroying the diagnosis a caller acts on — and + it is pinned in both directions in + `packages/plugins/plugin-auth/src/sso-domain-verification-error-codes.test.ts`. + + Shipped as `minor`, following the same call the casing rename on these two + routes made (#10716). The argument for it: the vocabulary is unchanged, and the + old pairing was self-contradictory rather than a contract anyone could have + relied on deliberately. The argument against it, stated here rather than + settled: unlike that rename — whose old spellings were undeclared values no + schema admitted — `DOMAIN_VERIFICATION_FAILED` *is* a declared, registered code, + so a client keyed to it for this case was keyed to something the published + contract admitted, and both halves of the answer change. A reviewer who reads + that as `major` is not reading it wrong; this PR does not decide it silently. + +### Patch Changes + +- 8f04d9a: Correct a false vendor claim in the `organization/add-member` source comments: + `teamId` has **no** active-team fallback (#10532). Two comments — the + `sys_member` `add_member` action metadata (the origin) and the + `organization-add-member.ts` module header that cited it as authority — stated + that "organizationId/teamId default to the caller's active org/team when + omitted". Measured on the installed better-auth 1.7.1 + (`dist/plugins/organization/routes/crud-members.mjs`, inside `addMember`), only + the organization half is true: + + ```js + const orgId = ctx.body.organizationId || session?.session.activeOrganizationId; + const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0; + ``` + + `activeOrganizationId` is read 8 times in that module; `activeTeamId`, never. An + omitted `teamId` therefore stays `undefined` and the member joins no team — every + `if (teamId)` branch (team lookup, `TEAM_NOT_FOUND`, per-team limit) is skipped. + + No runtime behaviour changes, and no deployment was ever misled: the `add_member` + action's `params` list carries no `teamId`, so the toolbar never sent one and the + claim was never exercised. What the comment did mislead was the next reader of + the mount, which cited it as the justification for forwarding request headers — + forwarding buys the organization default only. Forwarding `teamId` itself remains + correct: pass it and it works. + + The asymmetry the docs now publish is held by a new pin, + `organization-add-member-team-fallback.test.ts`, which reads the fact out of the + installed vendor artifact (not out of our own comments) so that a future + better-auth bump *adding* an active-team fallback reddens instead of silently + putting the docs out of date. +- 163a162: Ledger and document the ObjectStack-owned auth mounts that were in neither the route ledger nor the docs (#10534). + + `auth-plugin.ts` mounts 17 routes directly on the raw Hono app ahead of the better-auth catch-all. A census found **nine** of them in neither half of `auth-route-ledger.ts`, and **six** with no literal wire path anywhere in the hand-written docs — the state that let a mount and its documentation gap ship separately with nothing objecting. + + **Ledger:** eight mounts gain reviewed `source: 'objectstack'` rows — `/admin/import-users`, `/admin/oauth2/toggle-disabled`, `/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification`, `/admin/sso/verify-domain`, `/admin/unlock-user`, `/sys-oauth-application/register`. All are `disposition: 'server-only'`: each was measured to have zero `ObjectStackClient` callers and exactly one real caller that is a declarative metadata action target or a Console wizard. `POST /api/v1/auth/set-initial-password` is deliberately left unledgered and escalated rather than given a guessed disposition. + + **Docs:** `GET /api/v1/auth/bootstrap-status`, `POST /api/v1/auth/set-initial-password`, `POST /api/v1/auth/admin/unban-user`, `POST /api/v1/auth/admin/sso/register`, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` are now documented with their literal wire paths, including the opt-in `OS_SSO_DOMAIN_VERIFICATION` domain-verification flow and the asymmetric way its two halves report the switch being off. + + No route's mounting, behaviour or accept/reject set changes. +- 03bdd14: Run the break-glass last-local-credential guard after authentication + + The guard that refuses removal of the last local-password login was registered + as a better-auth `before` hook, which runs ahead of the endpoint middleware that + establishes identity. It therefore decided — and answered — a question about a + named user for a caller who had not been authenticated, while every neighbouring + route on the same lane answers with the ordinary "please log in" refusal. + + The guard now runs only once the acting user is resolved. An unauthenticated + caller falls through to the ordinary refusal and learns nothing about the named + user. For an authenticated caller nothing changes: the same lookup runs and the + same `LAST_LOCAL_CREDENTIAL` conflict is returned, so the lockout protection is + unaffected. +- bbe643c: Gate the localhost trusted-origin substitution to non-production (#10366). + + `AuthManager`'s `trustedOrigins` block substituted a localhost wildcard trio + (`http://localhost:*`, `http://*.localhost:*`, `https://*.localhost:*`) whenever + the resolved trusted-origin list came out empty and `OS_CORS_ORIGIN` was unset + or `*`. Its own comment described this as a development convenience, but the + condition tested only emptiness — it carried no `NODE_ENV` term, no dev-mode + term, nothing. A production deployment that reached it with an empty list + CSRF-trusted every `localhost` and `*.localhost` origin. The declared boundary + and the enforced boundary disagreed, and only the declared one was visible in + the file. + + The substitution is now gated on `NODE_ENV !== 'production'`, the same dev + signal already used by the fallback auth secret and by the dev `Origin` + synthesis in the same file. The property enforced: **a development convenience + exists only outside production.** + + **What production receives instead.** With the trio gated off and the list + empty, the block's tail omits `trustedOrigins` from the better-auth config + entirely. That is not an absent policy. Measured against the installed + better-auth 1.7.1: `getTrustedOrigins` + (`dist/context/helpers.mjs`) unconditionally seeds the trusted set from the + resolved `baseURL` origin and treats `options.trustedOrigins` as purely + **additive**, so an omitted key and an empty array are equivalent — both leave + exactly the deployment's own origin trusted, and `validateOrigin` + (`dist/api/middlewares/origin-check.mjs`) refuses everything else with + `403 INVALID_ORIGIN`. + + **Who is affected.** Deployments with an explicitly configured `trustedOrigins`, + or one derived from `OS_CORS_ORIGIN`, are unchanged in production — the + substitution never fired for them. Non-production behaviour is unchanged, + including under `NODE_ENV=test` and when `NODE_ENV` is unset. A production + deployment that was relying on the substitution to reach its own login page + now receives a loud `403` rather than silent over-trust; the remedy is to set + `OS_TRUSTED_ORIGINS`, or to fix the base URL that resolved unusable (PR #10369's + boot diagnostic already names that condition at startup). + + Both existing pins keep their dev-only assertions verbatim; new pins cover the + production omission, the non-production legs, the SSO per-request-function + shape, and — load-bearing — that explicitly configured and `OS_CORS_ORIGIN`-derived + trust survives in production. +- 5b0af2b: **Fix:** `POST /api/v1/auth/admin/impersonate-user` now admits ObjectStack **platform admins**. It previously refused every one of them with `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS` — byte-identical to the refusal a plain member received — so the `sys_user` "Impersonate User" button was dead on every deployment (#9968). + + better-auth's `admin` plugin authorizes on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing. ObjectStack's platform admin is a `sys_user_permission_set` row pointing at `admin_full_access` with `organization_id = null`, which the vendor cannot be pointed at, and re-synthesizing the scalar is permanently vetoed. + + **What an operator will now observe.** A platform admin who could not impersonate anyone can now impersonate a non-admin user, and the impersonation takes effect for cookie and bearer clients alike. Refusals are unchanged for everyone else: a signed-in non-platform-admin (including an organization owner or admin, who is **not** a platform admin under ADR-0068) still gets `403 YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS`, and an anonymous caller still gets `401` from better-auth's own `adminMiddleware`. + + **One refusal is newly reachable.** The vendor refuses to impersonate an admin-grade *target* by reading that same `role` scalar against `adminRoles: ['admin']` — a column nothing writes after ADR-0068 D2, so the guard was inert. It is now asked through the ADR-0068 predicate, so impersonating a **platform-admin target** is refused with `403 YOU_CANNOT_IMPERSONATE_ADMINS` where it previously succeeded. + + Implemented as a better-auth **plugin endpoint**, replacing the vendor endpoint in place on the `admin` plugin's own `endpoints` record — not a raw Hono mount. That keeps the signed-cookie contract with `/admin/stop-impersonating` and keeps the `/admin/impersonate-user` path-keyed rotation hook attached, so bearer-client impersonation does not regress to a silent 200 no-op. + + Every other better-auth-native `/admin/*` route still gates on the legacy scalar and still refuses platform admins — unchanged here. +- c49007a: Declare `@objectstack/plugin-hono-server` and put the published auth example in a + tsc program (#10869). + + `packages/plugins/plugin-auth/examples/basic-usage.ts` — the file + `content/docs/permissions/authentication.mdx` publishes as "Basic Auth Example" — + imports `HonoServerPlugin` from `@objectstack/plugin-hono-server` on line 12, and + this package declared that dependency in **none** of `dependencies`, + `devDependencies` or `peerDependencies`. (It declares `hono`, which is a different + package.) So the example could not resolve, compile or run for anyone who copied + it out of the docs: + + ``` + examples/basic-usage.ts(12,34): error TS2307: Cannot find module + '@objectstack/plugin-hono-server' or its corresponding type declarations. + ``` + + The declaration is now there (`devDependencies`, `workspace:*` — the example is + development material, and `files` ships only `dist`, so nothing new reaches a + published tarball). + + **The dependency alone would have been unverifiable, which is the other half of + this change.** `tsconfig.json` selects `include: ["src/**/*"]`, so `examples/` sat + in no tsc program at all — the type-check-coverage census's only instance of that + — and a manifest edit does not change an `include`. The fix would have had no + compile behind it and the defect could return unseen. So the directory now has a + program: `packages/plugins/plugin-auth/tsconfig.examples.json`, a non-emitting + sibling named in the package's `typecheck` script, following the precedent + `packages/spec/tsconfig.scripts.json` and `packages/objectql/tsconfig.scripts.json` + set. Strictness is inherited, not relaxed, and the directory enters with zero + recorded debt — the example type-checks clean under `strict`, which also measures + that every API it demonstrates (`ObjectKernel.use`/`bootstrap`/`getService`, + `HonoServerPlugin({ port })`, and every `AuthPluginOptions` key it passes) still + exists as written, so it is a working reference rather than a stale one. + + Because the directory is now read, `packages/plugins/plugin-auth/examples` leaves + `UNCHECKED_SOURCE_DEBT` in `scripts/check-type-check-coverage.mjs` — the ratchet + shrinks because the thing was repaired, and `RECONCILED` required the deletion in + the same change. +- 86a8ec9: **Behaviour change (tightening):** registering an SSO identity provider through the direct `POST /api/v1/auth/sso/register` endpoint now requires a **platform admin**. An organization **owner or admin** who is not a platform admin can no longer register an identity provider on any surface (#10009). + + Who loses access: an org owner/admin (a `sys_member` row graded owner/admin) with no org-less `admin_full_access` grant. They previously passed the ADR-0024 before-hook on the direct endpoint and now receive `403 SSO_REGISTER_FORBIDDEN`. Platform admins — an org-less `sys_user_permission_set` link to `admin_full_access`, per ADR-0068 D2 — are unaffected, as are anonymous callers, who still fall through to better-auth's `sessionMiddleware` (`401`). + + This closes a posture divergence: the four `/admin/sso/*` bridges the `sys_sso_provider` metadata actions call have gated on the platform-admin judge since #9653, while better-auth's own endpoint kept the wider ADR-0024 admit set — so the same principal was refused at one door and admitted at the other for the same underlying registration, leaving the bridge tightening as labelling rather than a boundary. Per the 2026-08-20 maintainer ruling, ADR-0068 D4 governs: registering an identity provider is a platform-operator action. If org-scoped IdP self-serve is ever wanted, it is a deliberate future decision rather than a vendor default inherited by omission. + + The direct endpoint also gains its first test pins; the now-callerless `isOrgOrPlatformAdmin` predicate was removed rather than left dead. +- 45204a5: `POST /api/v1/auth/two-factor/enable` no longer leaves `sys_two_factor.verified` + describing the enrollment *before* the secret it stores. + + better-auth's enable handler computes the row it writes as + `verified: existingTwoFactor != null && existingTwoFactor.verified === true` + (measured on the installed 1.7.1, `dist/plugins/two-factor/index.mjs`), and + `sys_two_factor` declares `user_id` unique — so a second `enable` on an account + that already has a confirmed factor rewrites that one row with a brand-new + secret while inheriting the old enrollment's flag. The flag then said + "user-confirmed" about a secret nobody had ever confirmed, and the sign-in + challenge honoured it. + + The vendor already gates the challenge on that flag, in both places it matters: + `totp/index.mjs` refuses an unconfirmed factor with `TOTP_NOT_ENABLED` before + any lockout bookkeeping, and the post-sign-in hook offers `totp` among + `twoFactorMethods` only when the flag is not `false`. That gate is exactly what + a *first* enrollment relies on. Re-enrollment was the one path that slipped past + it — not because the gate was missing, but because the value handed to it was + inherited. So the fix restores the flag rather than adding a second gate: + after a successful `method: 'totp'` enable, `verified` is set to `false`, and + the freshly issued secret becomes live only once the caller proves possession of + it through `/two-factor/verify-totp`. + + This is a tightening. The request body, the response shape and the status are + unchanged, a first-time enrollment is unaffected (better-auth already wrote + `false` there), and a rotation is still reachable and still completes — it now + takes the same confirmation step a first enrollment takes. What changes is that + a secret the endpoint hands out is no longer accepted at the next sign-in until + it has been confirmed. Clients that re-enroll and then rely on the new + authenticator working immediately at sign-in must call `/two-factor/verify-totp` + with the live session first, which is the flow first-time enrollment already + uses. +- 9b0172d: fix(plugin-auth): a 2FA verification echoes the session it INSTALLED, not the one it deleted (#10701) + + `POST /api/v1/auth/two-factor/verify-totp` answered `200` with two credentials + that disagreed. The `Set-Cookie` named the caller's new session; the JSON + `token` named a session row the same request had just deleted. + + The cause is upstream and mechanical. better-auth's `verifyTwoFactor` helper + resolves the caller's session once, at entry, and closes over it: + + ```js + valid: async (ctx) => ctx.json({ token: session.session.token, ... }) + ``` + + On the enrolment lane — a signed-in user confirming a new TOTP factor — the + route rotates that session before it answers: it mints a new session, installs + it with `setSessionCookie`, and deletes the caller's original session row. Only + then does it call `valid(ctx)`, which still holds the pre-rotation session and + echoes the token of the row that no longer exists. (Measured on the installed + better-auth 1.7.1: `dist/plugins/two-factor/verify-two-factor.mjs` and + `dist/plugins/two-factor/totp/index.mjs`.) + + Every other auth response in this repo echoes `token` as the unsigned token of + a live session, and `bearer()` accepts exactly that — presented without a + signature it signs the value itself before verifying. Measured on + `/sign-up/email`, the body's `token` resolves to the user as a bearer. So a + client following that contract after enrolling in 2FA stored a revoked token. + + That did not merely fail to authenticate. `bearer()`'s before-hook OVERWRITES + the request's session cookie with whatever the `Authorization` header carries, + so a request presenting the still-valid rotated cookie *and* the dead token + resolved to nobody. Measured before the fix, on one enrolment: the cookie alone + resolved to the user (`get-totp-uri` `200`); the echoed token alone resolved to + nobody (`get-session` `200` and empty, `get-totp-uri` `401`); and the two + together also resolved to nobody (`401`). Fail-closed — no privilege was + available to gain — but a legitimate user was locked out of a session they + still held, which is the point of the report. + + The echoed value is now read back out of the response's own session cookie, so + the `token` names the session the response actually installed. This restores + the contract rather than changing it: the field keeps its shape (the unsigned + session token) and its meaning ("the session you now hold"), and only the value + moves, from a deleted row to the live one. Shipped as `patch` for that reason — + no consumer expression has to be rewritten, and the previous value was not a + usable credential for anything, so nothing could have depended on it. + + The repair is keyed on the mechanism, not on the enrolment branch: it applies + only when the response staged a session cookie whose token differs from the one + being echoed. On the sign-in-challenge lane, where the route mints the session + it echoes, the two agree and this is a no-op — pinned, along with the cookie + lane, so that fixing the broken lane could not quietly rewrite the others. + `/two-factor/verify-otp` carries the byte-identical rotate-then-answer block and + is covered by the same guard; `/two-factor/verify-backup-code` does not rotate + and is unaffected. + + Resolver precedence is deliberately untouched. Having the resolver fall back to + the cookie when a bearer is unusable was the other repair direction named in the + report, and it was ruled out of scope: it would stop an invalid credential from + failing loud. Two pins hold that line — anonymous is still refused, and a bogus + bearer still overrides a valid cookie and still fails closed — so an attempt to + loosen the resolver later reddens this suite instead of passing it. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [e634ecf] +- Updated dependencies [6ce58a7] +- Updated dependencies [d806081] +- Updated dependencies [9a1ed7a] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b39785] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [9e04c3e] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [acb4dbc] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [490879a] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [4389fe9] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/rest@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index c2f5f3bdd7..7c006001e6 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.1.0", + "version": "17.2.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 cad28356b6..2d2858cd73 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,114 @@ # @objectstack/plugin-dev +## 17.2.0 + +### Patch Changes + +- Updated dependencies [8f04d9a] +- Updated dependencies [4d7c564] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [da891e0] +- Updated dependencies [a38c3ff] +- Updated dependencies [163a162] +- Updated dependencies [128684d] +- Updated dependencies [5337ef1] +- Updated dependencies [7d2d112] +- Updated dependencies [03bdd14] +- Updated dependencies [5fa0d72] +- Updated dependencies [7bf3fb7] +- Updated dependencies [02b3b07] +- Updated dependencies [2570ab0] +- Updated dependencies [5886ee6] +- Updated dependencies [bbe643c] +- Updated dependencies [e634ecf] +- Updated dependencies [95437e7] +- Updated dependencies [b20c8d2] +- Updated dependencies [6ce58a7] +- Updated dependencies [d806081] +- Updated dependencies [d23e3a0] +- Updated dependencies [9a1ed7a] +- Updated dependencies [f3a8134] +- Updated dependencies [b03a880] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b0af2b] +- Updated dependencies [5b39785] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [9e04c3e] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [a16ff50] +- Updated dependencies [e222a53] +- Updated dependencies [acb4dbc] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [c49007a] +- Updated dependencies [047ac86] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [490879a] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [4389fe9] +- Updated dependencies [13a6cb4] +- Updated dependencies [9f483d9] +- Updated dependencies [923c424] +- Updated dependencies [b419135] +- Updated dependencies [88e32a8] +- Updated dependencies [a24b7fa] +- Updated dependencies [1ec36b7] +- Updated dependencies [9e93fc6] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [af1636c] +- Updated dependencies [86a8ec9] +- Updated dependencies [d9353b9] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [45204a5] +- Updated dependencies [9b0172d] +- Updated dependencies [24ba050] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/plugin-auth@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/driver-memory@17.2.0 + - @objectstack/service-storage@17.2.0 + - @objectstack/runtime@17.2.0 + - @objectstack/plugin-security@17.2.0 + - @objectstack/service-i18n@17.2.0 + - @objectstack/setup@17.2.0 + - @objectstack/rest@17.2.0 + - @objectstack/plugin-hono-server@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/account@17.2.0 + - @objectstack/service-realtime@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 8d1071ca70..687b64a50d 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.1.0", + "version": "17.2.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 284a5f97c6..c6508933b3 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,218 @@ # @objectstack/plugin-email +## 17.2.0 + +### Minor Changes + +- a16ff50: `SweepLogger` and `ProjectionLogger` now declare `warn` as a REQUIRED channel, so a sink handed to the boot outbox sweep or to permission-set reconciliation can no longer be one that prints nothing (#9754) + + Both interfaces declared every member optional — `info?`, `warn?`, `error?` — which made `{ info }` a legal sink. Against such a sink both durability reports evaporated: each reaches for `error`, finds none, falls back to `warn`, and finds none of that either. For the sweep that is mail the platform accepted and never delivered, summarised to nobody; for reconciliation it is a permission set that will not survive a re-provision, with the `info` "reconciled" line skipped as well, so the sink heard neither the failure nor the reassurance. + + #9657 and #9748 repaired the call-site spellings. This is the other half, and the half that cannot regress: an optional `error` with no guaranteed alternative is a contract that permits silence, so an author reading the interface can write a report that never prints and be right about the type. Requiring `warn` makes that unrepresentable at the point of authoring rather than catchable one gate-run later. + + `error` deliberately stays optional on both types — hosts do inject reduced sinks, and requiring `error` would foreclose the `{ warn }`-only host the drivers were written for. + + If you pass a logger of your own and it declares no `warn`, add one; the kernel `Logger`, `ctx.logger` and `console` all satisfy the tightened shape unchanged. Consumers reach these types through `@objectstack/plugin-security`'s exported `ProjectionDeps`; `SweepLogger` is internal to `@objectstack/plugin-email`. + + The rule now has a checker of its own: `pnpm check:optional-error-sink` scans every sink type in `packages/**`, reports the population as a census on every run, and carries a shrink-only ledger of the 15 sinks that still permit silence. +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + + +### Patch Changes + +- b20c8d2: **Durability fix:** the two boot-time **summary** reports now reach a logger sink that has no `error` method, instead of printing nothing at all (#9748). + + `SweepLogger.error` and `ProjectionLogger.error` are both declared **optional**, and both summaries were spelled `logger?.error?.(…)` — an optional call that emits **nothing** when the method is absent. #9657 repaired the six per-row reports of this shape; it could not see these two, because `check:durability-log-level` only judges a call inside a `catch`, and a summary sits after the loop. Against a `{ info, warn }` sink the result was that the repair made the split **worse**: the per-row detail arrived at `warn` while the count of failures vanished, so the detail and the total reported through different channels. + + - `sweepStrandedOutbox()` — *"N stranded `sys_email` row(s) could NOT be delivered"*. Mail the platform **accepted** and never delivered, previously summarised to nobody. + - `reconcilePermissionSetProjection()` — *"N FAILED backfill(s)"*. Worse than a plain omission here: the `else` branch carrying the `info` "reconciled" line is skipped too, so such a sink heard **neither** — the reassuring half-truth this rule exists to remove, arrived at from the other side. + + Both now reach for `error` and fall back to `warn`, never to silence — the same repair shape #9657 applied to the per-row lines. A sink that **does** have `error` is unaffected and still gets the summary at `error`; a downgraded level is a degradation of the channel, never of the message, so the consequence and the fix survive the fallback intact. + + Also enforced from now on: `check:durability-log-level` grew a **summary limb** that judges a report keyed on the counter a durability-critical `catch` accumulated into, so this class cannot regress silently. The limb never second-guesses a chosen log **level** — it only checks that a call that reaches for `error` can actually print. +- 047ac86: Five `Plugin` implementations now release their resources from `destroy()`, the + only teardown hook the kernel calls (#10772). + + `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and + `destroy?()`. `ObjectKernel.performShutdown()` and `LiteKernel.destroy()` walk + the plugins in reverse calling `plugin.destroy()` — and nothing anywhere calls + `stop()`, `dispose()`, `close()` or `shutdown()` on a plugin. Each of these five + spelled its teardown with one of those names instead, so what it released was + still held after `await kernel.shutdown()` had **resolved**: + + | package | class | was spelled | what outlived shutdown | + |:--|:--|:--|:--| + | `@objectstack/metadata` | `MetadataPlugin` | `stop` (arrow property) | artifact watcher, `manager.dispose()`, repository handle | + | `@objectstack/runtime` | `AppPlugin` | `stop` (arrow property) | the `app:unregistered` catalog event, never emitted | + | `@objectstack/runtime` | `ExternalValidationPlugin` | `stop` (arrow property) | every armed drift-check `setInterval` | + | `@objectstack/plugin-email` | `EmailServicePlugin` | `dispose` | two metadata subscriptions, the SMTP transport, an engine binding | + | `@objectstack/plugin-webhooks` | `WebhookOutboxPlugin` | `dispose` | the auto-enqueuer (2 realtime subscriptions + a refresh interval) and two engine hooks | + + `ExternalValidationPlugin` is the one with teeth: it is one of only two `Plugin` + implementations in the tree that own `setInterval` directly, it is mounted on + the real `os serve` path, and its `stop()`'s only caller anywhere was the class + itself re-arming. Measured against a real kernel, its drift checker performed + five further reads in the five intervals after a resolved shutdown — the #9371 + mechanism verbatim. `WebhookOutboxPlugin.dispose()` had **zero** callers in the + entire repo, so its teardown had never run in any process at all. + + **Nothing is removed and no signature narrows.** Each old name is retained as a + delegating alias, because it is public API of an exported class and an embedder + may have learned to call it directly precisely BECAUSE the kernel never did. + `stop` stays an arrow property where it was one (so a detached + `const { stop } = plugin` keeps working) and stays synchronous on + `ExternalValidationPlugin` (so a non-awaiting call site is unaffected). The two + `stop(ctx)` aliases widen their parameter to optional. + + One behavioural note for direct callers, since `destroy()` takes no context: + `MetadataPlugin.stop(ctx)` and `AppPlugin.stop(ctx)` now use the context + captured in `init()` and ignore the argument. In a real composition these are + the same object. The visible difference is confined to a plugin whose `init()` + never ran — for `MetadataPlugin` a dropped `warn` line, for `AppPlugin` a + catalog event that is no longer emitted for an app that was never registered. +- a24b7fa: Make the settings ordering contract **declared and enforced**, and make the + residual pre-bind READ audible (#10250). + + `SettingsServicePlugin` binds its data engine from a `kernel:ready` hook + registered in its `start()`. Three shipped plugins read a settings namespace + from a `kernel:ready` hook registered in *their* `start()` — `plugin-email` + (`mail`: SMTP/provider/from-address), `service-sms` (`sms`: provider + credentials and the daily cost ceiling) and `service-storage` (`storage`: + backend and credentials). Hooks fire in registration order, so a reader that + started before the settings plugin read `SettingsService`'s in-memory fallback, + which is empty at boot: the caller received the manifest **default** with + `source: 'default'` and `locked: false`, no diagnostic anywhere, while the + operator's saved row sat unread in `sys_setting`. + + Nothing constrained that order. None of the three declared any dependency on + `com.objectstack.service.settings`, so their position was pure `kernel.use()` + order. It was correct under `os serve` only because the always-on slate happens + to list `settings` first — and `serve` *prepends* an app's declared `requires`, + so an ordinary `requires: ['email']` produced email-before-settings and bypassed + that; cloud's per-tenant runtime mounts the slate from its own wiring. + + Three changes, one contract: + + - **Declared order.** Each of the three plugins now declares + `optionalDependencies: ['com.objectstack.service.settings']`. The kernel + resolves both the init and the start phase from that graph + (`resolvePluginOrder`, ADR-0116), so the bind is ordered ahead of the read + wherever the plugin is composed, in any host. Soft, not hard: a kernel with + no settings service still boots these plugins unchanged. + - **The residual is audible.** A settings read issued while a bind is + *declared but pending* now emits one operator-actionable `warn` per namespace + naming the repair. Deliberately not a refusal — an in-window read of a + setting with genuinely no persisted row must answer the manifest default, and + refusing would turn a correct startup sequence into an error. It stays silent + in every case that is not the window: after `bindEngine`, on a kernel with no + `objectql` at all (`settleWithoutEngine`), for a directly constructed + `SettingsService`, and for a read satisfied by an `OS_*` env override. + - **The slate pin now derives its boundary.** The foundational-prefix + assertion covered `slice(0, 6)` while `sms` — a settings reader — sits at + index 6, one past the end. The new pin + (`packages/cli/src/commands/serve-settings-ordering.pin.test.ts`) states the + rule instead of the count: every always-on entry that is not one of the + services others bind into at `kernel:ready` must be mounted after all of + them. An entry added tomorrow is covered wherever it lands. + + No behaviour changes for a deployment whose order was already correct. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/formula@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 864192f2f5..bbc7420e49 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.1.0", + "version": "17.2.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 10cec99700..512470bf86 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,79 @@ # @objectstack/plugin-hono-server +## 17.2.0 + +### Patch Changes + +- b03a880: docs(plugin-hono-server): boot the kernel with the method it actually ships (#9870) + + `packages/plugins/plugin-hono-server/README.md` is in the package's `files` array + with `private` unset, so it is the page npm renders. Its Usage block ended: + + ```ts + const kernel = new ObjectKernel(); + kernel.use(new HonoServerPlugin({ port: 3000, /* … */ })); + await kernel.start(); + ``` + + Measured against the built type surface: `ObjectKernel` (re-exported by + `@objectstack/runtime` from `@objectstack/core`) declares `bootstrap()` and + `shutdown()` and has **no** `start` member. A reader copying the block gets a + compile error on its last line. + + The line reads plausibly because the `IKernel` *interface* in + `@objectstack/types` does declare `start()` — but the concrete class the fence + constructs does not implement that name, and eight sibling READMEs + (`objectql`, `rest`, `runtime`, `service-cache`, `service-job`, + `service-automation`, `service-package`, `service-cluster-redis`) all spell the + same step `await kernel.bootstrap()`. Fixed to match. + + Found by the call-site widening in the same PR, not by hand: the receiver is + never import-bound, so before that widening this call site was one of the 262 + `check:published-readme-exports` could not read. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/observability@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 33d7500aa7..4295b93756 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.1.0", + "version": "17.2.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 36353d1b39..db87554883 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,28 @@ # @objectstack/plugin-pinyin-search +## 17.2.0 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [2570ab0] +- Updated dependencies [95437e7] +- Updated dependencies [d23e3a0] +- Updated dependencies [f3a8134] +- Updated dependencies [47cd3ec] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [d29e271] +- Updated dependencies [8012960] + - @objectstack/objectql@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index fa7c70108f..7906af828c 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.1.0", + "version": "17.2.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 6755b8ccd5..7c0c2a328a 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/plugin-reports +## 17.2.0 + +### Minor Changes + +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + + +### Patch Changes + +- 6d5c4fa: Release these plugins' resources from `destroy()`, the teardown hook the kernel + actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and + `destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and + `LiteKernel.destroy()`, which walk the plugins in reverse calling + `plugin.destroy()`, walked straight past every plugin whose teardown was spelled + `stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still + armed, the REST/OpenAPI/Slack connectors still registered on the automation + engine, the approvals SLA escalation job still scheduled, and the knowledge + event-sync subscription still open. + + Each teardown body now lives in `destroy()`. `stop()` is retained as a + delegating alias with its parameter made optional, so an embedder that learned + to call it directly — precisely because the kernel never did — keeps working + unchanged. No export is removed and the `Plugin` interface is untouched. + + Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as + fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted + from the merge queue. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 5d936b5096..fbeab87afa 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.1.0", + "version": "17.2.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 7031c94bf9..9fbbcf8c36 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,466 @@ # @objectstack/plugin-security +## 17.2.0 + +### Minor Changes + +- 5337ef1: Batch the identity boot seeds' existence read and stop re-writing rows that + already match the declaration (#10946). + + Every permission set and every position an environment declared cost **exactly + 4 sequential database round trips on every kernel boot** — measured on a real + per-environment kernel build with every `@libsql/client` call counted: slope + 4.0000, R² = 1.000000 on both axes, with a per-statement histogram naming the + four legs (2 × existence `SELECT`, 1 × `UPDATE`, 1 × `SELECT`). Two of the four + were an `UPDATE` that fired even when nothing had changed. On a local file + database the loop is invisible; on a remote libsql/Turso database — every hosted + environment — each leg is its own sequential HTTP request. Schema sync had + already been batched (`TursoDriver.supports.batchSchemaSync`), which is why + objects, views and artifact seeds add 0.00 round trips each on the same rig; + identity content was the one content axis still paying per item. + + Both loops now hoist **one** `{ name: { $in: [...] } }` existence read out of the + loop — the declaration is known in full before the loop starts — and write only + when the stored row actually differs from what would be written. A steady-state + rebuild of both loops is now O(1) round trips: measured in-repo against a + call-counting ObjectQL double, a rebuild of 1, 5, 20 and 40 declared items costs + 1 round trip in every case, for permission sets and positions alike. + + Three things the change is careful **not** to become: + + - **Drift still reconciles.** The skip is on equality, never on "we have seen + this name": a row whose stored value differs — a package version bump, a + hand-edit, a partially applied write — still gets its `UPDATE`. An + implementation that skipped all writes would show the same round-trip curve + and silently stop reconciling, so the round-trip pins are paired one-for-one + with drift pins over the same fixtures. + - **A read that could not answer is not the answer "none exist."** A batched + read fails for the whole set at once, so swallowing its failure into `[]` + would make every boot conclude nothing is seeded and re-create everything. The + seam is judged on whether the driver returned a result set, never on whether + the array came back empty; a failed batched read degrades to the per-item read + (loudly warned), and a name whose record cannot be read at all is declined + rather than inserted. That last step is deliberately stricter than the code it + replaces, which turned a failed read into an insert attempt and leaned on the + `name` unique index to refuse it. + - **A converged publish is still a successful publish.** `PermissionSeedOutcome` + gains `unchanged` (rows that already matched) and `unreadable` (names declined + because their record could not be read). The ADR-0086 P2 publish materializer + asks "did the record end up matching the published body", which was + accidentally identical to "was a write issued" only because the seeder always + wrote; it now reads `seeded + updated + unchanged`, so every case that reported + a materialization before still reports one. A re-publish of a byte-identical + body reports `inserted: 0, updated: 0` instead of `updated: 1` — the one + reporting difference, and the truthful reading. + + `bootstrapDeclaredPositions` likewise returns `unchanged` and `unreadable` + alongside `seeded`/`updated`. +- a16ff50: `SweepLogger` and `ProjectionLogger` now declare `warn` as a REQUIRED channel, so a sink handed to the boot outbox sweep or to permission-set reconciliation can no longer be one that prints nothing (#9754) + + Both interfaces declared every member optional — `info?`, `warn?`, `error?` — which made `{ info }` a legal sink. Against such a sink both durability reports evaporated: each reaches for `error`, finds none, falls back to `warn`, and finds none of that either. For the sweep that is mail the platform accepted and never delivered, summarised to nobody; for reconciliation it is a permission set that will not survive a re-provision, with the `info` "reconciled" line skipped as well, so the sink heard neither the failure nor the reassurance. + + #9657 and #9748 repaired the call-site spellings. This is the other half, and the half that cannot regress: an optional `error` with no guaranteed alternative is a contract that permits silence, so an author reading the interface can write a report that never prints and be right about the type. Requiring `warn` makes that unrepresentable at the point of authoring rather than catchable one gate-run later. + + `error` deliberately stays optional on both types — hosts do inject reduced sinks, and requiring `error` would foreclose the `{ warn }`-only host the drivers were written for. + + If you pass a logger of your own and it declares no `warn`, add one; the kernel `Logger`, `ctx.logger` and `console` all satisfy the tightened shape unchanged. Consumers reach these types through `@objectstack/plugin-security`'s exported `ProjectionDeps`; `SweepLogger` is internal to `@objectstack/plugin-email`. + + The rule now has a checker of its own: `pnpm check:optional-error-sink` scans every sink type in `packages/**`, reports the population as a census on every run, and carries a shrink-only ledger of the 15 sinks that still permit silence. +- 504c8d5: Materialize the RBAC catalog **per organization**, so a walled deployment can + administer positions, permission sets and sharing rules again (#10103). + + On a walled deployment (`group` / `isolated`) every principal — an organization + owner and a platform admin alike — listed **zero** positions, permission sets + and sharing rules while the tables held rows. Nothing could be bound through + Setup, and a declared `hierarchy-security` could never be armed by an operator + however loudly an app declared it. + + Every row in those three tables was organization-less. plugin-security's Layer 0 + composes a strict `organization_id = :tenant` for a walled posture and the + middleware ANDs it into the read AST over the driver's + `(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the + two is the strict equality alone, so the driver's null arm was annihilated on + every authenticated read. + + **The wall is not changed, at either layer.** The rows get an owner instead: + + - `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`, + `bootstrapDeclaredPermissions` (plugin-security) and + `bootstrapDeclaredSharingRules` (plugin-sharing) upsert by + `(name, organization_id)` and run **one pass per organization** under a walled + posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`, + `guest`) included, matching `sys_user_position`, which is already + per-organization, and matching both objects' own `unique: 'organization'` name + index. + - Seeding also fires on **organization creation**, not only at `kernel:ready`, so + a tenant created after startup does not administer an empty catalog until the + next restart. + - `single` posture is **unchanged**: exactly one organization-less pass, which is + the correct shape there. + + An organization-less row is now invalid state under a walled posture. Nothing is + reaped — grants (`sys_user_position`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_record_share`) point at these rows by id, so + deleting them would revoke standing access with no signal at the moment of loss. + Instead a per-organization pass that meets pre-fix organization-less rows for + names it seeds **says so loudly**, naming the rows and the remedy, and still + creates that organization's own copies. The failure this closes is the silent + no-op: a tenant-threaded pass that sees the old row through the driver's + compatibility arm, reads the name as already represented, and creates nothing + while reporting success. + + Two enforcement-plane reads are scoped in the same change, because the exposure + they carry only exists once per-organization copies exist: + + - `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved + `sys_position` by name across **every** organization, so the junction read + behind it collected another organization's `everyone` binding — a cross-organization + grant bleed, and an O(organizations) read on the per-request path. It is now + threaded through the driver's tenant chokepoint, keeping per-request resolution + O(the caller's own organization's catalog). + - plugin-security's permission-set `dbLoader` resolved sets by name unscoped, + with a `limit` equal to the number of names — correct while one row existed per + name, a truncation the moment copies exist. It is now scoped to the caller's + organization and its bound widened. + + Boot reconciliation is O(changed declarations): each pass reads what its + organization already has and writes only where a declaration actually differs, so + the common boot performs no writes at all. Steady state rides the + organization-creation hook. + + Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization + sharing rules cheaper than the unscoped sweep they replace. +- 3ee8ddf: fix(security): **BREAKING** — `sys_position` retires the `permissions` column (ADR-0049 enforce-or-remove, #9885) + + Maintainer ruling 2026-08-20: **REMOVE**. The column — a "JSON-serialized array + of permission strings" textarea — was declared on the platform position table + while **no producer ever wrote it and no runtime path ever read it**. The + object-scoped census (every `sys_position`-naming file, with same-object + positive controls resolving `active` / `delegatable` / `is_default` / `name` + to real readers) measured it at zero on both sides: the builtin and declared + position bootstrappers set `label` / `description` / `managed_by` / `active` / + `is_default` only, and position→grant resolution consults + `sys_position_permission_set` rows plus the position `name` — never this + column. Its only reference was the `clone_position` action copying it between + rows (a copy of a value nothing writes), removed in the same stroke. objectui + was searched under the same discipline: no console surface names the column. + A free-text grant catalogue on a security object that no runtime enforces + tells an author — human or AI — that direct position-level permission strings + are a platform capability; they are not. This is an **accept-set narrowing**: + the platform stops declaring, projecting and accepting the column. + + Migration (FROM → TO): + + | Wrote | Write instead | + |---|---| + | `permissions` on a `sys_position` seed row or data-door write | Delete the key. Capability reaches a position **only** through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder); prose that was documenting intent belongs in `description`. | + + One-line fix: delete `permissions` from any authored `sys_position` row. + + + + Enforcement after the removal is loud, not silent: the engine's schema + preflight refuses an undeclared field with `400 INVALID_FIELD` before the + driver or any hook runs, and `PositionSchema`'s strict parse now rejects a + declared-position `permissions` key with guidance naming the binding table. + Physical columns on already-deployed databases are untouched (ADR-0045 schema + sync is additive). If position-level direct grants ever become a real need, + the column is re-declared **with a runtime reader in the same PR** — + declare-and-enforce or don't declare. + +### Patch Changes + +- 02b3b07: Point every runtime-emitted documentation URL at the canonical host, and retarget the + metadata-protection `docsUrl` at a page that actually exists. + + Two defects, one string. The host half: `docs.objectstack.ai` is an alias that redirects + to `https://objectstack.ai` path-preservingly, so nothing here was a broken link — it was + the unratified spelling sitting in the places a user copies from. The CLI's spec-version + advisory, the Setup and Studio in-app overview pages (English and Chinese alike), and a + showcase demo action now all name the canonical host. + + The path half is the real fix. All 29 `protection.docsUrl` values on the platform's + system objects and apps pointed at `/adr/0010-metadata-protection`, and `/adr/...` is not + a route on any host: the docs site mounts `content/docs` under `/docs`, `docs/adr/` is + not published, and no redirect source lives outside the `/docs` space. The slug was wrong + too — the record is `0010-metadata-protection-model.md`. Studio renders this URL as a + link in the lock banner, so an operator asking why an item is locked was being sent + nowhere. They now point at `https://objectstack.ai/docs/references/shared/protection`, + the published reference for the very schema that carries the field. +- 5886ee6: Stop issuing two DB queries for questions already answered earlier in the same + request (#10757). One authenticated `GET /data/:object?$top=1` measured **24 DB + queries before, 23 after** — **22** when the caller opts out of the count. + Measured with `X-OS-Debug-Timing: json` on `pnpm dev:crm`, whose `Server-Timing` + carries `db;dur=…;desc="N queries"`. + + **`$count=false` now skips the COUNT query** (`@objectstack/metadata-protocol`). + The parameter has been declared (`ODataQuerySchema.$count`), aliased on the wire + (`$count` → `count`), reserved out of the implicit-field-filter bucket, + arity-checked and boolean-coerced for a long time — and then deleted unread, so + every paginated list ran `engine.count()` whether or not the caller wanted a + total. It is honoured now: + + ``` + GET /data/task?$top=25 → { records, total, hasMore } (unchanged) + GET /data/task?$top=25&$count=true → { records, total, hasMore } (unchanged) + GET /data/task?$top=25&$count=false → { records, hasMore } (no COUNT query) + ``` + + Read the shape of that carefully before adopting it: + + - **Only an explicit `false` opts out.** An ABSENT `$count` still counts and + still reports `total`. OData reads absent as "omit the count", and taking that + reading here would silently strip `total` from every existing caller — none of + them send the parameter, all of them read the number. The asymmetry is + deliberate and pinned by tests. + - **`total` is OMITTED, never estimated.** `FindDataResponse.total` is declared + optional ("if requested"), so absent is the declared shape for "not + requested". A caller that opted out and then reads `total` gets `undefined`, + not a plausible-looking guess — guard the read (`total ?? undefined`) or do + not send `$count=false`. + - **`hasMore` is still answered**, from the page alone: a full page means there + may be more. Same page-local rule the `$search` path already uses. + + **A find and its COUNT resolve permission sets once, not twice** + (`@objectstack/plugin-security`). `findData` answers a paginated list with two + engine operations, and the security middleware runs on both; each pass re-read + `sys_permission_set` for the same context with identical bindings. The + resolution is now memoized per execution context — a `WeakMap` keyed on the + context object, which is built once per request and collected with it, so + nothing outlives the caller it was resolved for — and **retired by any write**: + a process-wide epoch is bumped on every `insert`/`update`/`delete` the engine + middleware sees, ahead of the `isSystem` bypass so a seeder, a package publish + or an auto-org-admin grant invalidates too. A context whose grants are rewritten + in place re-resolves as well (the memo key covers `positions`, `permissions`, + `principalKind` and the presence of `userId`). No authorization answer is reused + across a write, across a context, or across a request. + + Not a fix for the whole cost: the remaining ~22 queries per authenticated + request are session resolution, grant resolution, localization and metadata + reads that repeat on every request. Removing those needs cross-request caching + with an invalidation design, which is deliberately not in this change. +- b20c8d2: **Durability fix:** the two boot-time **summary** reports now reach a logger sink that has no `error` method, instead of printing nothing at all (#9748). + + `SweepLogger.error` and `ProjectionLogger.error` are both declared **optional**, and both summaries were spelled `logger?.error?.(…)` — an optional call that emits **nothing** when the method is absent. #9657 repaired the six per-row reports of this shape; it could not see these two, because `check:durability-log-level` only judges a call inside a `catch`, and a summary sits after the loop. Against a `{ info, warn }` sink the result was that the repair made the split **worse**: the per-row detail arrived at `warn` while the count of failures vanished, so the detail and the total reported through different channels. + + - `sweepStrandedOutbox()` — *"N stranded `sys_email` row(s) could NOT be delivered"*. Mail the platform **accepted** and never delivered, previously summarised to nobody. + - `reconcilePermissionSetProjection()` — *"N FAILED backfill(s)"*. Worse than a plain omission here: the `else` branch carrying the `info` "reconciled" line is skipped too, so such a sink heard **neither** — the reassuring half-truth this rule exists to remove, arrived at from the other side. + + Both now reach for `error` and fall back to `warn`, never to silence — the same repair shape #9657 applied to the per-row lines. A sink that **does** have `error` is unaffected and still gets the summary at `error`; a downgraded level is a degradation of the channel, never of the message, so the consequence and the fix survive the fallback intact. + + Also enforced from now on: `check:durability-log-level` grew a **summary limb** that judges a report keyed on the counter a durability-critical `catch` accumulated into, so this class cannot regress silently. The limb never second-guesses a chosen log **level** — it only checks that a call that reaches for `error` can actually print. +- 6ceaa4b: docs: name packages that exist in seven published documents, and gate the class (#10893) + + A published README ships inside the npm tarball, so an install instruction in one + reaches every reader of the package. Nine `@objectstack/` names across seven + published documents named a package that is in **no directory of this repo**, and + five of those sat on `import` lines inside runnable fences. + + `check:published-readme-exports` could not see any of it, by construction. It + resolves a documented import against the package's built type surface through the + workspace member map, so a specifier that is not a member has no type entry to + compare against and the gate reads no further — strict about a member that exists, + silent about one that does not. The gate now makes the member-existence claim + first: an `@objectstack/`-scoped specifier that names no workspace member is a + finding, and the run header prints the scoped population as `N/N` so a recogniser + that stops matching shows up as a denominator that fell. + + What each dead claim now says, and why: + + - **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`** + each misnamed **themselves**. Both READMEs — including their `# ` titles and + every fenced import — said `@objectstack/plugin-trigger-…`, a name that has + never been published. The exported class names (`ScheduleTriggerPlugin`, + `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all + along; only the package name was wrong, so this is a rename pinned by each + package's own `name` field. + - **`@objectstack/plugin-security`** told readers to `install + @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No + such package exists. The organization wall ships as the enterprise + `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the + `org-scoping` service this plugin probes — the name `objectstack serve` and + `objectstack doctor` both print. Asking for the wall without it is a refusal to + boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The + tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the + cloud control-plane runtime from the separate `cloud` repository and not where + the wall comes from either. + - **`@objectstack/service-package`** described packages being "delivered to + runtime kernels that load them through `@objectstack/service-marketplace`". That + package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future + work. The loading half that does exist here is + `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`. + - **`@objectstack/embedder-openai`** had a fenced example importing + `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape, + because a reader pastes it. No knowledge adapter in this repository consumes an + `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder + option, and the adapters the contract is written for are not here. The example + is now the `embed()` surface that does exist, with the gap stated rather than + papered over with a substitute package name. + - **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against + `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has + ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite + through Knex, choosing the client from its optional peers. + - **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code + generators to write `import { User } from '@objectstack/protocol'`. The package + is `@objectstack/spec`, which the same sentence names as the path being + replaced. + + Four `@objectstack/` names that are **not** in this repo are deliberately left as + they are, because prose may name a package this repo does not build and a runnable + import may not: `@objectstack/security-enterprise` (the enterprise edition, whose + install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant` + (the cloud runtime), `@objectstack/framework` (the umbrella install name), and the + two names `service-datasource`'s README recalls as its own past. +- 145ba75: docs: repair the dead repo-relative targets in four published READMEs (#10813) + + A published README ships inside the npm tarball, so a dead relative link in one + is shipped to every reader who installs the package. Nine of them were measured + across four packages, and nothing read them: `check:published-readme-links` + checked docs-site URLs, `check:published-readme-exports` checked fenced import + lines, and the lychee lane never sees `packages/**/README.md`. + + `@objectstack/runtime` carried six dead targets. Each was traced to where the + content actually went rather than deleted: + + - `MINI_KERNEL_GUIDE.md`, `MINI_KERNEL_ARCHITECTURE.md` and + `MINI_KERNEL_IMPLEMENTATION.md` were deleted from the repo root in January as + "redundant markdown files" (d709ecce68 — 14 files, 5051 deletions, nothing + added). The kernel reference they described is the docs site now, so the + Documentation section is the same footer eight sibling READMEs already use. + - `examples/host/` was renamed to `examples/app-host`, then `apps/server`, then + `apps/objectos`, and finally split out to `objectstack-ai/cloud`. In-repo, an + HTTP server in front of the runtime is `@objectstack/plugin-hono-server` plus + the `@objectstack/hono` adapter, so the bullet points there. + - `examples/msw-react-crud/` became `examples/app-react-crud`, then + `apps/console`, and now ships as `@object-ui/console` from another repo. + - `test-mini-kernel.ts` was a root-level scratch script; this package's suite is + 179 test files under `src/`. + - The section also ended on a truncated bullet with an unterminated backtick + (`` - `packages/runtime/src/ ``), which is now a real pointer to that suite. + + The other three packages: `@objectstack/hono` and `@objectstack/service-package` + still spelled `@objectstack/driver-sql` as `../../plugins/driver-sql`, stale + since the driver moved to `packages/drivers/` (#5618). `@objectstack/plugin-security` + and `@objectstack/service-package` linked three packages that are in no directory + of this repo (`plugin-org-scoping`, `service-tenant`, `service-marketplace`); + those links are dropped and the names kept as code spans, which is the spelling + those same files already use for a package they cannot point at in-tree. Whether + those three packages exist at all is a separate question, filed separately. +- b419135: Report a metadata-store OUTAGE as an outage, not as an absent declaration + (#10424). When an object's security posture cannot be resolved, the refusal + now consumes the `degraded` verdict `IMetadataService.getDiagnosed` was already + computing and discarding (#5840), so a store that could not answer no longer + wears the sentence written for an object that was never declared — "Check that + the object is declared and published on this runtime" sent operators to + re-check a healthy declaration in the middle of an incident. The refusal now + names the store, says the declaration may well be fine, and the operator log + line carries a grep-able `DEGRADED` / `metadata-store OUTAGE`. + + Explanation and logging only. The deny is unchanged in every case — same + `PermissionDeniedError`, same `PERMISSION_DENIED`, same 403, still fail-closed + per #3545 — and the set of requests that are accepted or rejected does not + move: the resolving read is untouched and `getDiagnosed` is consulted as a + separate best-effort probe on the path that is already refusing. A metadata + service that does not implement the optional `getDiagnosed` reports `unknown` + and keeps the previous wording; it is never reported as an outage. +- 88e32a8: `SecurityPlugin.start()` binds its report sink **above** the two bail-outs, so a + degraded boot no longer leaves the plugin permanently unable to report (#10706). + + `private logger … = {}` is an empty object from construction, and + `this.logger = ctx.logger` was its only assignment — sitting in the "capture + handles" block, **below** the two `return`s that fire when `objectql`/`metadata` + cannot be resolved, or when the engine carries no `registerMiddleware`. On + either path the field stayed `{}` for the **lifetime of the instance**. Every + report site is written `this.logger.warn?.(…)`, so an unbound sink is not a + state any caller can notice: the reports simply do not happen. The assignment + now runs immediately after the `Starting Security Plugin...` line, before either + bail-out can be taken. + + Boot behaviour is otherwise unchanged, and that is pinned rather than asserted: + both bail-outs still `return`, the middleware and the `security` service are + still **not** registered on those paths, and both bail-outs still report through + `ctx.logger` — which was always a real sink, so the bail-out itself was already + loud. What was silent was the plugin's own field afterwards. + + Scope note: this is independent of the open design call on #10556 about what the + default sink should be. Only the **placement** of the binding changes; the `= {}` + default itself is untouched, and the fix is correct under every option there. + + Reachability, measured rather than assumed: every in-repo caller of the two + public methods that report through the field (`checkAuthoredRowWrite`, + `getReadFilter`) reaches them through the registered `security` service, and + that service is registered *below* the bail-outs too — so on a bailed-out boot + there is no live consumer. The defect was latent, not live. It is still a defect + on its own terms: a sink that can never be bound after an early return is + unrepresentable as a state the code can notice. + + New pin: `start-logger-binding.test.ts`. +- 24ba050: **Message change (no behaviour change):** a data-plane read against an object that exists only as an **unpublished draft** now says so, instead of reporting an internal security step (#10401). + + The refusal itself is unchanged and stays fail-closed (#3545): same `PermissionDeniedError`, same `PERMISSION_DENIED` code, same HTTP 403, same `[Security] Access denied` prefix — which is a **matcher** the transports read as "this is a 403", not house style. Nothing here widens access, and no access decision branches on the new information. + + What changed is what the refusal *says*. One sentence — "the security posture of object 'X' could not be resolved for operation 'find'" — covered two conditions with two different remedies, and described neither: because it named a *security* step, every reader took it for a permissions problem and went looking for a sharing rule to change. Measured downstream (objectstack-ai/cloud#1481): an end-user AI turn asked "how many customers do I have?" against a draft-only object, spent seven tool calls oscillating between a metadata plane that said the object existed and this refusal, then told the user the object was "missing its sharing/visibility setting" — confident, professional, and wrong. On a free plan that one turn also exhausted the daily allowance. + + The two conditions are now separated: + + - **The object has a `sys_metadata` draft and no published row** → *"object 'X' is not published — a draft declaration exists but no published one … Publish the object to make it queryable. This is NOT a permissions problem …"*. + - **The declaration genuinely cannot be read** (never declared, or a metadata-store outage) → the pre-existing clause **verbatim**, so any surface matching `the security posture of object 'X' could not be resolved for operation 'Y'` keeps matching, followed by the remedy and the same explicit statement that permissions are not the lever. + + Both sentences, and the operator log line beside them, are derived from one module (`unresolved-posture.ts`) shared with the explain engine's `object_crud` layer detail. Enforcement and explanation stating one refusal in two drifting wordings is the defect shape this closes, so the wording is a single source rather than two literals. + + The discriminator comes from a **best-effort** `sys_metadata` probe that runs only on the path already refusing, reads under a system context (so it cannot re-enter the middleware), and fails safe in one direction only: any failure — no `sys_metadata` in the deployment, an unprovisioned store, a driver error — reports the both-conditions wording rather than a claim. A posture that resolves never probes at all. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/formula@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 93d91ab6b9..41102170ac 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.1.0", + "version": "17.2.0", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md index df60dbc6c6..32e0b544c0 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,244 @@ # @objectstack/plugin-sharing +## 17.2.0 + +### Minor Changes + +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + +- 504c8d5: Materialize the RBAC catalog **per organization**, so a walled deployment can + administer positions, permission sets and sharing rules again (#10103). + + On a walled deployment (`group` / `isolated`) every principal — an organization + owner and a platform admin alike — listed **zero** positions, permission sets + and sharing rules while the tables held rows. Nothing could be bound through + Setup, and a declared `hierarchy-security` could never be armed by an operator + however loudly an app declared it. + + Every row in those three tables was organization-less. plugin-security's Layer 0 + composes a strict `organization_id = :tenant` for a walled posture and the + middleware ANDs it into the read AST over the driver's + `(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the + two is the strict equality alone, so the driver's null arm was annihilated on + every authenticated read. + + **The wall is not changed, at either layer.** The rows get an owner instead: + + - `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`, + `bootstrapDeclaredPermissions` (plugin-security) and + `bootstrapDeclaredSharingRules` (plugin-sharing) upsert by + `(name, organization_id)` and run **one pass per organization** under a walled + posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`, + `guest`) included, matching `sys_user_position`, which is already + per-organization, and matching both objects' own `unique: 'organization'` name + index. + - Seeding also fires on **organization creation**, not only at `kernel:ready`, so + a tenant created after startup does not administer an empty catalog until the + next restart. + - `single` posture is **unchanged**: exactly one organization-less pass, which is + the correct shape there. + + An organization-less row is now invalid state under a walled posture. Nothing is + reaped — grants (`sys_user_position`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_record_share`) point at these rows by id, so + deleting them would revoke standing access with no signal at the moment of loss. + Instead a per-organization pass that meets pre-fix organization-less rows for + names it seeds **says so loudly**, naming the rows and the remedy, and still + creates that organization's own copies. The failure this closes is the silent + no-op: a tenant-threaded pass that sees the old row through the driver's + compatibility arm, reads the name as already represented, and creates nothing + while reporting success. + + Two enforcement-plane reads are scoped in the same change, because the exposure + they carry only exists once per-organization copies exist: + + - `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved + `sys_position` by name across **every** organization, so the junction read + behind it collected another organization's `everyone` binding — a cross-organization + grant bleed, and an O(organizations) read on the per-request path. It is now + threaded through the driver's tenant chokepoint, keeping per-request resolution + O(the caller's own organization's catalog). + - plugin-security's permission-set `dbLoader` resolved sets by name unscoped, + with a `limit` equal to the number of names — correct while one row existed per + name, a truncation the moment copies exist. It is now scoped to the caller's + organization and its bound widened. + + Boot reconciliation is O(changed declarations): each pass reads what its + organization already has and writes only where a declaration actually differs, so + the common boot performs no writes at all. Steady state rides the + organization-creation hook. + + Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization + sharing rules cheaper than the unscoped sweep they replace. + +### Patch Changes + +- 93304c2: Collapse the two byte-identical `MinimalLogger` declarations in `plugin-sharing` + onto one shared `OptionalSharingLogger` (#10692). Internal types only — none of + the seven local `MinimalLogger` interfaces was exported, so no published surface + and no runtime behaviour changes. + + `plugin-sharing/src` declared **seven** module-local interfaces all named + `MinimalLogger`. The duplication was not the defect; divergence under one name + was. When #10556 made `bulk-recompute.ts`'s `warn` non-optional, `tsc` reported + the forwarding modules as: + + ``` + Type 'MinimalLogger' is not assignable to type 'MinimalLogger'. + Two different types with this name exist, but they are unrelated. + ``` + + `bu-tree-recompute.ts` and `primary-bu-projection.ts` were byte-identical, so + they now share one declaration in `logger-shapes.ts`. The new type is + deliberately given a DIFFERENT name: the next forwarding edge added between it + and a module that still declares its own `MinimalLogger` produces a diagnostic + naming two different types, instead of the same name twice. + + The other five declarations are left alone, each for a stated reason recorded in + `logger-shapes.ts`. Three are genuinely different contracts (`bulk-recompute.ts` + is the guaranteed sink; `rule-hooks.ts` and `record-share-cascade.ts` require + `warn` because they forward into it). Two are *not* the cheap unification the + card assumed: + + - `sharing-rule-provenance.ts` is `{ info?, warn? }` by optionality but carries a + stricter member signature, `(msg: string, meta?: Record)`. + Folding it onto the `(msg: any, ...rest: any[])` spelling would delete real + checking; folding the others onto its spelling would tighten two modules. + - `record-orphan-cleanup.ts`'s bare `Function` members **cannot** be tightened + here: `Function` is not assignable to any concrete signature ("Type 'Function' + provides no match for the signature"), and the two loggers handed to it — + `SharingServiceOptions['logger']` and `ShareLinkServiceOptions['logger']` — + are themselves spelled with bare `Function`. + + `check:optional-error-sink` (#9754) membership is unchanged and was verified + before and after: 37 sinks declare `error`, 2 permit silence, 2 baselined. The + shared shape declares no `error` and must not grow one — that would enrol every + module using it into that gate's population, which is a contract decision for + the #10556 family rather than a side effect of de-duplication. +- bc400af: **Behaviour change (narrowing):** an **org-stamped** sharing rule's criteria sweep is now scoped to that rule's own organization, where it previously swept **every** organization's records (#10119). + + `SharingRuleService.findMatchingRecords` (the whole-rule evaluation pass) and `recordMatches` (the per-record write-hook pass) ran the rule's criteria query under a bare system context carrying no tenant, for every rule. The recipient half was already org-aware — `expandRecipient` threads `rule.organization_id` into the team / business-unit / position graph services — so a rule stamped with an `organization_id` expanded recipients inside its own organization and then matched records belonging to all the others. `reconcile` materialized the cross product: `sys_record_share` rows granting one organization's users access to another organization's records. + + Measured on `main` before the change, through a real `ObjectQL` on a real `SqlDriver`: an `org_a`-stamped rule matched **the same four records as a platform-global rule** (`deal_a1`, `deal_b1`, `deal_b2`, `deal_p1`) and materialized a grant on each; the per-record hook pass minted a grant on `org_b`'s record with `grantsCreated: 1`. + + What changes, and for whom: + + - **Org-stamped rules** (`organization_id` non-null — what any org admin mints through `defineRule`) now run their criteria query with `tenantId` set to the rule's organization. The platform's existing chokepoint does the rest: `ObjectQLEngine.buildDriverOptions` threads it to `DriverOptions.tenantId` and `SqlDriver.applyTenantScope` emits `(organization_id = ? OR organization_id IS NULL)`. So such a rule matches its own organization's records **plus** platform-owned null-org records, and no other tenant's. `SharingRuleEvaluationResult.matchedRecords` falls accordingly, and the next reconcile pass **revokes** the cross-org `sys_record_share` rows it previously created, through the existing revoke-the-remainder branch — no migration is needed. + - **Platform-global rules** (`organization_id = null`) are unchanged: they keep the full unscoped sweep, which is their declared behaviour (documented at the `deleteRule` platform-authority guard). Both directions are pinned. + - **No public contract changes.** No schema, route, error code or accept/reject set moves; the system elevation on the criteria read is retained (the evaluator still sees rows no individual recipient could), only the tenant axis is added. + + The cross-org rows this stops creating were **inert** under a walled posture — the Layer-0 tenant wall AND-composes over sharing's Layer-1 widening, so such a grant could not open a read across the wall. The costs were `sys_record_share` bloat (every org-stamped rule scanning the whole table at `limit: 5000`) and a population that is wrong at rest, which any consumer reading `sys_record_share` directly, or any future softening of the wall, would inherit. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [2570ab0] +- Updated dependencies [95437e7] +- Updated dependencies [d23e3a0] +- Updated dependencies [f3a8134] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/formula@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 2f28e1bd34..adde98c9be 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.1.0", + "version": "17.2.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 849ba6a21b..3733376f46 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,210 @@ # @objectstack/plugin-webhooks +## 17.2.0 + +### Minor Changes + +- cdaa72f: fix(service-messaging,plugin-webhooks): the `update`-op tenant-audit surface on the delivery outboxes is classified — `ack` is a dispatcher sweep, `redeliver` threads the caller's tenant (#10740) + + **BREAKING** signature change on `IHttpOutbox.redeliver` and + `MessagingService.redeliverHttp`, shipped as `minor` under the repo's + launch-window convention for breaking changes. + + `sys_http_delivery` and `sys_notification_delivery` carry three single-record + (`multi: false`) writes that the SQL driver audits under the **`update`** op — + a different op, and a different throttle key, from the `updateMany` half + classified previously. Their correct classifications are **opposite**, and + treating them as one sweep is the dangerous reading: + + | site | reachable from | classification | + | --- | --- | --- | + | `SqlNotificationOutbox.ack` | dispatcher tick only | global sweep | + | `SqlHttpOutbox.ack` | dispatcher tick only | global sweep | + | `SqlHttpOutbox.redeliver` | `POST /api/v1/webhooks/redeliver` | request-contextual | + + **The two `ack` sites** are declared global sweeps through a new + `dispatcherAckOptions()` helper, sibling to `dispatcherSweepOptions()` and + deliberately not the same function — that one returns `& { multi: true }`, so a + `multi: false` site cannot borrow it by accident. The warrant was re-derived + against the current tree rather than inherited: `ack` has exactly two callers, + both inside `runPartition()` on a `setInterval` tick holding a per-partition + cluster lock, so no request context exists to thread; and the row being acked + was claimed by a sweep that crosses organizations by construction + (`hash(refId | notificationId | digestKey) mod N` is a load-spreading key, and + one outbox per environment drains the whole queue). Passing the claimed row's + own `organization_id` is documented at the helper as the tempting wrong answer: + a predicate read off the row you are about to write matches exactly that row, + adds no isolation, and silences the audit anyway — the appearance of scoping + without the substance. + + **`redeliver` is not that**, and it is the reason this shipped separately. The + route in front of it is served to any authenticated user, so on a walled + deployment (`OS_TENANCY_POSTURE=isolated|group`) an unscoped replay is an + authenticated user writing another organization's delivery row — the case the + tenant audit exists to catch. It now carries the caller's tenant, applied to + the rows it reads as well as the row it writes, and it must never be given + `bypassTenantAudit`: a scoped write and a bypassed write produce the same + silence in the log, so the flag would convert a detectable hole into an + undetectable one. The webhook route resolves the session's + `activeOrganizationId` and threads it. + + Behaviour change at the endpoint: a delivery row outside the caller's + organization is now **not found** (`RESOURCE_NOT_FOUND`, HTTP 404) rather than + replayed. It is deliberately invisible rather than forbidden, so the endpoint + is not an existence oracle for other tenants' delivery ids. An in-tenant + redelivery is unchanged. + + Migrating a caller: `redeliver(id, guard?)` becomes + `redeliver(id, { tenantId, guard? })`, and `redeliverHttp(id)` becomes + `redeliverHttp(id, { tenantId })`. `tenantId` is a **required** property typed + `string | undefined`, so omitting it does not compile — a caller with no tenant + has to write `tenantId: undefined` and mean it. That is the point of the shape: + an optional property would let the dangerous case, a request path that simply + forgot, type-check in silence. Passing `undefined` leaves the write unscoped + and the audit line still fires, which is the intended reporting behaviour on a + deployment that cannot resolve an organization for the caller. + + +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + + +### Patch Changes + +- 047ac86: Five `Plugin` implementations now release their resources from `destroy()`, the + only teardown hook the kernel calls (#10772). + + `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and + `destroy?()`. `ObjectKernel.performShutdown()` and `LiteKernel.destroy()` walk + the plugins in reverse calling `plugin.destroy()` — and nothing anywhere calls + `stop()`, `dispose()`, `close()` or `shutdown()` on a plugin. Each of these five + spelled its teardown with one of those names instead, so what it released was + still held after `await kernel.shutdown()` had **resolved**: + + | package | class | was spelled | what outlived shutdown | + |:--|:--|:--|:--| + | `@objectstack/metadata` | `MetadataPlugin` | `stop` (arrow property) | artifact watcher, `manager.dispose()`, repository handle | + | `@objectstack/runtime` | `AppPlugin` | `stop` (arrow property) | the `app:unregistered` catalog event, never emitted | + | `@objectstack/runtime` | `ExternalValidationPlugin` | `stop` (arrow property) | every armed drift-check `setInterval` | + | `@objectstack/plugin-email` | `EmailServicePlugin` | `dispose` | two metadata subscriptions, the SMTP transport, an engine binding | + | `@objectstack/plugin-webhooks` | `WebhookOutboxPlugin` | `dispose` | the auto-enqueuer (2 realtime subscriptions + a refresh interval) and two engine hooks | + + `ExternalValidationPlugin` is the one with teeth: it is one of only two `Plugin` + implementations in the tree that own `setInterval` directly, it is mounted on + the real `os serve` path, and its `stop()`'s only caller anywhere was the class + itself re-arming. Measured against a real kernel, its drift checker performed + five further reads in the five intervals after a resolved shutdown — the #9371 + mechanism verbatim. `WebhookOutboxPlugin.dispose()` had **zero** callers in the + entire repo, so its teardown had never run in any process at all. + + **Nothing is removed and no signature narrows.** Each old name is retained as a + delegating alias, because it is public API of an exported class and an embedder + may have learned to call it directly precisely BECAUSE the kernel never did. + `stop` stays an arrow property where it was one (so a detached + `const { stop } = plugin` keeps working) and stays synchronous on + `ExternalValidationPlugin` (so a non-awaiting call site is unaffected). The two + `stop(ctx)` aliases widen their parameter to optional. + + One behavioural note for direct callers, since `destroy()` takes no context: + `MetadataPlugin.stop(ctx)` and `AppPlugin.stop(ctx)` now use the context + captured in `init()` and ignore the argument. In a real composition these are + the same object. The visible difference is confined to a plugin whose `init()` + never ran — for `MetadataPlugin` a dropped `warn` line, for `AppPlugin` a + catalog event that is no longer emitted for an app that was never registered. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [8163a1c] +- Updated dependencies [cdaa72f] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [900e489] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/service-messaging@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index 5e90c42de2..6bbdb7f72c 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.1.0", + "version": "17.2.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 f6c17d9c24..7dcd160f8d 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,123 @@ # @objectstack/dogfood +## 0.0.42 + +### Patch Changes + +- Updated dependencies [8f04d9a] +- Updated dependencies [4d7c564] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [da891e0] +- Updated dependencies [a38c3ff] +- Updated dependencies [76deca2] +- Updated dependencies [163a162] +- Updated dependencies [5337ef1] +- Updated dependencies [7d2d112] +- Updated dependencies [03bdd14] +- Updated dependencies [5fa0d72] +- Updated dependencies [7bf3fb7] +- Updated dependencies [02b3b07] +- Updated dependencies [dd41df3] +- Updated dependencies [2570ab0] +- Updated dependencies [5886ee6] +- Updated dependencies [bbe643c] +- Updated dependencies [8163a1c] +- Updated dependencies [cdaa72f] +- Updated dependencies [95437e7] +- Updated dependencies [b20c8d2] +- Updated dependencies [d23e3a0] +- Updated dependencies [f3a8134] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b0af2b] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [900e489] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [57e4571] +- Updated dependencies [112a8c6] +- Updated dependencies [13a3dca] +- Updated dependencies [5acb58d] +- Updated dependencies [a16ff50] +- Updated dependencies [e222a53] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [c49007a] +- Updated dependencies [047ac86] +- Updated dependencies [6d5c4fa] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [923c424] +- Updated dependencies [b419135] +- Updated dependencies [88e32a8] +- Updated dependencies [0ab81d1] +- Updated dependencies [a24b7fa] +- Updated dependencies [1ec36b7] +- Updated dependencies [93304c2] +- Updated dependencies [bc400af] +- Updated dependencies [6cca75c] +- Updated dependencies [9e93fc6] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [af1636c] +- Updated dependencies [86a8ec9] +- Updated dependencies [d9353b9] +- Updated dependencies [35ad101] +- Updated dependencies [502dc6f] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [05bc692] +- Updated dependencies [6439f8b] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [45204a5] +- Updated dependencies [9b0172d] +- Updated dependencies [24ba050] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/platform-objects@17.2.0 + - @objectstack/plugin-auth@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/service-storage@17.2.0 + - @objectstack/plugin-audit@17.2.0 + - @objectstack/plugin-security@17.2.0 + - @objectstack/service-analytics@17.2.0 + - @objectstack/service-messaging@17.2.0 + - @objectstack/plugin-webhooks@17.2.0 + - @objectstack/plugin-email@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/plugin-sharing@17.2.0 + - @objectstack/metadata@17.2.0 + - @objectstack/connector-openapi@17.2.0 + - @objectstack/connector-rest@17.2.0 + - @objectstack/example-showcase@0.3.16 + - @objectstack/mcp@17.2.0 + - @objectstack/verify@17.2.0 + - @objectstack/example-crm@4.0.94 + - @objectstack/connector-mcp@17.2.0 + - @objectstack/types@17.2.0 + ## 0.0.41 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index e26cfa5176..0a7b5db2b4 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.41", + "version": "0.0.42", "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 836ade76d4..bf858a1913 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,46 @@ # @objectstack/downstream-contract +## 0.0.40 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + ## 0.0.39 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index bb70093570..190948fb3e 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.39", + "version": "0.0.40", "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 a37558c4e3..11fd47e2bc 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,15 @@ # @objectstack/http-conformance +## 0.1.2 + +### Patch Changes + +- Updated dependencies [47cd3ec] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [504c8d5] + - @objectstack/core@17.2.0 + ## 0.1.1 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 3f89c62b48..196fb75a3a 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.1", + "version": "0.1.2", "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 7cfb33ec8a..ebfe93e3f5 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,298 @@ # @objectstack/rest +## 17.2.0 + +### Minor Changes + +- 67630c4: The `/meta` FSM state route is singular: `meta.getLegalNextStates` moves, the plural registration is retired (#10077) + + Step 2 of the #9180 ruling — the `/meta` type segment is always singular, no + exception and no tolerated plural alias. Maintainer re-weigh, 2026-08-17, + verbatim: 「② 照原样做;只需要修正 objectstack objectui cloud 中错误的写法。」 + + - `client.meta.getLegalNextStates(object, field, from?)` now requests + `GET /api/v1/meta/object/:name/state/:field`. Same method, same arguments, + same response body — only the path segment changes. + - `GET /api/v1/meta/objects/:name/state/:field` is **no longer registered**. + The singular twin has been mounted alongside it since #7526, so the + migration for a hand-rolled HTTP caller is to drop the `s`. A request to the + retired spelling now gets the transport 404, which is the loud answer; the + one shape that changes hands rather than 404ing is a field literally named + `published`, which the compound `/:type/:section/:name/published` route + picks up. + - The two route ledgers follow what is mounted and what the SDK calls: the + plural row is deleted from `rest-route-ledger.ts` and the dispatcher ledger's + mirror row is respelled. + + **What this does not change.** The boundary fold `META_URL_TO_SINGULAR` is + untouched, so no `/meta/:type/...` spelling that is accepted today becomes + refused: the retired route matched a **literal** path segment and never + consulted the fold. The 2026-08-17 re-weigh (item 3) defers that break with no + scheduled window. The legacy dispatcher branch in `runtime/src/domains/meta.ts` + also still matches both literals; narrowing it is not part of this step. + +### Patch Changes + +- e634ecf: fix(rest): `POST /datasources/:name/external/validate` does URL-scoped work (#10537) + + The route asked the `external-datasource` service for `validateAll()` — every + federated object on every federated datasource, each validation driving a live + `introspect(datasource)` remote-schema read — and then kept only the rows whose + `datasource` matched the URL. The rows it kept were correct; the *work* was not + scoped, so one datasource's health check paid for N datasources' remote + round-trips and threw most of the measurement away. An unreachable *unrelated* + remote slowed the answer for the datasource actually asked about (and produced + rows that were then filtered off). + + Measured at the branch point, through the real Hono adapter and the real + `ExternalDatasourceService` over a recording introspector: a request for one of + three federated datasources introspected `['wh_a', 'wh_b', 'wh_c']`. A request + naming a datasource that does not exist introspected all three as well, to + answer the empty report it already answered. + + `ExternalDatasourceService` now carries `validateDatasource(datasource)`, the + scoped twin of the sweep composed from the same primitives (`listObjects` → + filter → `validateObject`) and the same per-object catch, and the route calls + it. Same request answers `['wh_a']`; an unknown name answers `[]`. + + **No response change.** The rows the post-filter used to keep are the rows the + scoped composition returns — same objects, same diffs, same `data.ok` verdict, + same `200`, the same `400 EXTERNAL_DATASOURCE_ERROR` when the service refuses, + the same `503 SERVICE_UNAVAILABLE` when federation is not wired in, and an + unknown `:name` still answers an empty, vacuously `ok` report rather than a + `404`. The selection is keyed on `o.datasource ?? 'default'`, which is exactly + the value `validateObject` reports back as `result.datasource`, so "the rows the + sweep would have kept" and "the objects this selects" are the same set — pinned + directly, in both packages, by comparing the scoped answer against the + sweep-then-filter answer rather than against a remembered body. + + Because the output was already right, the pins that matter here are about the + CALL RECORD, not the body: `external-datasource-validate-scope.test.ts` asserts + which datasources were introspected and that `validateAll()` is not called at + all, over a fixture carrying three federated datasources so the assertion can + actually fail. A body-only test passes on both sides of this change. + + `validateDatasource` is **not** on `IExternalDatasourceService`: the contract + offers `validateObject(objectName)` and `validateAll()`, and adding a + per-datasource spelling to it is a spec-surface decision to take on its own + terms. The composition therefore lives in the service — the only registrant of + the `external-datasource` slot — and the REST registrar probes for it. A wired + service with no scoped spelling takes the same `503` arm every other route in + this family takes when the service cannot serve it, deliberately *not* a silent + fallback to the fan-out: a fallback would leave the old behaviour reachable on a + path no test drives. + + Unchanged: `validateAll()` itself, and the boot-validation sweep in + `packages/runtime` that legitimately validates every federated object. +- 6ce58a7: **Behaviour change (tightening) — `POST /datasources/:name/external/validate` now requires `manage_platform_settings`** (#10255, completing the #9901 federation-family gate). This was the one route of the external-datasource federation family still admitting **any authenticated caller**; it now requires the same capability as the family's two read routes. Maintainer ruling, 2026-08-20 (verbatim: 「同意你的意见。」, accepting option A on #10255). + + **This is published SDK surface.** `datasources.external.validate` on `ObjectStackClient` and the CLI's `os datasource validate` reach exactly this route. An existing integration that presents a valid credential — a better-auth session or a `sys_api_key` — and does not hold `manage_platform_settings` was served before and is **refused now**: `403` with the standard catalog code `PERMISSION_DENIED` (ADR-0112), the message naming the missing capability so the caller knows which grant to request. The anonymous floor is unchanged: no identity is still `401 UNAUTHENTICATED`. + + **Why the read capability.** `validateAll` drives the same live remote-schema introspection the family's gated read routes expose (`introspect` per datasource), and its report — schema diffs naming remote columns and types, driver error strings for unreachable remotes — is a read of the same federation surface. An unentitled caller refused at `GET /:name/external/tables` could previously still trigger live remote introspection through this route and read what it found. One family, one door-type: reads on `manage_platform_settings`, writes on `manage_metadata`. + + **Migration.** Grant the calling credential's permission set `manage_platform_settings` — the same grant the family's read routes have required since #10254, so an integration already migrated for those is covered. The platform's `admin_full_access` set carries it; a purpose-built operator set is the case to check. +- d806081: Render `saveMetaItem`'s `422 INVALID_METADATA` findings clause per write face + + The spec-validation refusal restated its own findings in the message + (`: ` for the first three, plus a `(+N more)` tail) while + attaching the same array as `issues`. On the HTTP 422 both channels ride one + response, so every console rendering both showed each finding twice. + + The clause is now rendered per face. The `/meta` HTTP write doors — REST's + `PUT /meta/:type/:name` and `PUT /meta/:type/:a/:b`, and the runtime + dispatcher's `PUT /meta` — declare that they carry the findings structurally + and get a one-sentence headline instead: the issue count plus up to three + `path [zod code]` locators, the same grammar the seed refusal and the + author-time gate already compose. `issues[]` is attached unchanged on every + face, so nothing is withheld from anyone. + + Faces that carry no structured channel keep the full prose, byte for byte — + `duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s + `rows[].reason`, and the two out-of-package log faces, where this sentence is + the sole carrier of the author's prescription. Silence means "keep the prose": + a write door only ever drops the restatement by declaring itself, never by + omission. +- 9a1ed7a: **Behaviour change (tightening) — a capability is now required on the external-datasource federation family** (`/api/v1/datasources/:name/external/*`, #9901). These routes previously admitted **any authenticated caller**; four of the five now also require a platform capability. Maintainer ruling, 2026-08-20 (verbatim: 「其他接受你的建议。」). + + **This is published SDK surface.** `datasources.external.*` on `ObjectStackClient` reaches exactly these routes, and the CLI's `datasource` commands go through them. An existing integration that presents a valid credential — a better-auth session or a `sys_api_key` — and holds neither capability was served before and is **refused now**. Nothing about the credential itself changed; what changed is what the credential must carry. + + | route | SDK call | now requires | + | --- | --- | --- | + | `GET /:name/external/tables` | `datasources.external.listTables` | `manage_platform_settings` | + | `POST /:name/external/tables/:remote/draft` | `datasources.external.draft` | `manage_platform_settings` | + | `POST /:name/external/tables/:remote/import` | `datasources.external.import` | `manage_metadata` | + | `POST /:name/external/refresh-catalog` | `datasources.external.refreshCatalog` | `manage_metadata` | + | `POST /:name/external/validate` | `datasources.external.validate` | *(unchanged — authentication only)* | + + A refusal is **`403` with the standard catalog code `PERMISSION_DENIED`** (ADR-0112; deliberately not the grandfathered `FORBIDDEN` synonym), and the message names the missing capability so the caller knows which grant to request. The anonymous floor is unchanged: no identity is still `401 UNAUTHENTICATED`. + + **Why these two capabilities.** The first two routes are the declared twins of `GET /:name/remote-tables` and `POST /:name/object-draft` on the datasource-admin spelling, which has required `manage_platform_settings` since #9593 — the same operation was reachable through two mounted routes with two different admission policies, so an agent or integration refused at one spelling was served at the other. The two write routes have no twin and create live metadata (the import mounts a runtime-origin federated object; the refresh rewrites the cached catalog snapshot), so they take `manage_metadata`, this package's existing gate for metadata creation. + + **Migration.** Grant the caller's permission set the capability its routes need — `manage_platform_settings` for remote-schema introspection, `manage_metadata` for import/refresh. The platform's `admin_full_access` set already carries both, so admin-credentialed integrations are unaffected; a purpose-built operator set is the case to check. +- 5b39785: `KernelResolver` gains an optional environment-only member so a REST request + pays ONE kernel-waiter window instead of two (#10988). + + `RestApiPlugin` wraps the host's ADR-0006 `kernel-resolver` so `RestServer` can + ask "which environment is this request in?". It asked `resolveKernel` — a + kernel-ACQUISITION api — and kept only `context.environmentId`. A host resolver + writes the id and then awaits that environment's kernel, so the wrapper paid a + full waiter window and discarded what it bought; `resolveProtocol` then acquired + the kernel again. Free on a warm environment (a cache hit, which is why this was + invisible), a second serial wait on a cold or wedged one. Measured on a live + multi-tenant host with `waiterTimeoutMs: 20s`: REST-owned routes + (`/api/v1/discovery`, `/api/v1/data/:object`) answered 503 after ~42s where + dispatcher-owned routes answered after ~21s. + + `KernelResolver.resolveEnvironment?(context, defaultKernel)` resolves ONLY the + request's environment onto the context, acquiring no kernel; the REST wrapper + prefers it when the host implements it, leaving `resolveProtocol` as the single + kernel-acquisition point on the path. + + **Non-breaking, and no flag day.** The member is `?.`-optional: a host that + implements only `resolveKernel` type-checks and behaves exactly as before (it + keeps paying the discarded acquisition on cold builds), so this ships before any + host implements the new half. Adding an optional member to an interface the + framework CONSUMES cannot invalidate an existing implementation — every resolver + already in the field still satisfies the contract. Marked `minor` on + `@objectstack/runtime` because it is a new public capability on an exported + contract, `patch` on `@objectstack/rest` because the wrapper change is a fix + with no surface of its own. + + Fail-closed is unchanged and pinned: the surviving `getOrCreate` still rejects + for a genuinely unavailable kernel, so the caller still gets the host's declared + 503 — a shorter wait to the same verdict, never a response served against no + kernel. `waiterTimeoutMs` is a host setting and is untouched; the defect was + waiting twice, not waiting wrong. +- 26f3588: **Fix:** the REST `/meta` doors now decide **organization scope on the folded type**, never on the raw URL spelling (#10340). + + Storage folds `/meta/:type` through `META_URL_TO_SINGULAR` — the complete spelling map — while the doors' scope predicate (`declaresOrgOverride`) tolerates only the manifest-collection spellings. For the two registry-derived spellings, `translations` and `email_templates`, the doors therefore read and wrote **env-wide** where the singular twin was org-scoped: an org-active author's `PUT /meta/translations/:name` landed an env-wide row their own org-scoped read then shadowed (persisted, receipted as live, served by nothing), and `GET` under one spelling answered a different partition than the other — one item, two namespaces, addressed by spelling (#4432 / #7894's defect one layer down). + + - All nine `/meta` org-scope call sites (list, single read, layers view, compound read, save, compound save, delete, publish, rollback) fold the segment through `canonicalMetaUrlType` **before** calling `organizationIdForMetaRead` / `organizationIdForMetaWrite`, exactly as `metadata-url-spelling.ts` mandates: folding happens at the boundary and only there. + - The `GET /meta/:type/:name/published` code-store fallback folds too — the smaller second site of the same class: it reads a registry keyed by canonical types, so a recognised plural of a code-published item answered 404 while the singular answered 200. + - **Deliberately unchanged:** `GET /meta/_drafts` still applies no fold (it filters by the draft row's *stored* type, which is canonical because the protocol folds on save), the request `type` handed to the protocol stays the raw segment (the protocol owns its own fold), and `declaresOrgOverride` does **not** absorb the URL map — a predicate below the boundary consuming the URL spelling contract is the repair #7894 forbids. `@objectstack/metadata-core` changes are documentation and pins only: the predicate's header no longer claims parity with the protocol's normalization (measured false), and new tests pin both the composed fold→predicate contract and the predicate's deliberate limit. + + No stored rows move: rows previously minted env-wide through a plural spelling stay env-wide and keep serving org-less callers (and org-active callers until an org overlay exists), which is the same layering the singular spelling always had. +- 9e04c3e: **Additive:** `POST /meta/:type/:name/publish` now accepts `?package=`, so a single-item draft→active promotion can state the package it belongs to (#10063). + + #9612 taught the runtime publish gate to narrow `objects` to the written item's package closure, but only for callers that can NAME a package. Of the three write doors that reach the gate, `saveMetaItem` (`?package=` on the `PUT` door) and `publishPackageDrafts` (the batch names it) both did; the single-item promotion door named nothing — so every HTTP-driven promotion, which is exactly Studio's designer save→publish loop on every edit, handed the gate the whole tenant. The protocol half already existed and was waiting: `promoteDraftForPublish` declares `packageId?: string | null` and threads it into both the gate and `repo.promoteDraft`. Only the REST caller was mute. + + - **Wire spelling:** `?package=`, deliberately the same parameter name and the same normalisation the `PUT` door states it with — `all` and the empty value mean "env-local overlay, no package", not a package literally named `all`. One value, one spelling across both steps of the save→publish loop. + - **Multiplicity:** a repeated `?package=a&package=b` is refused `400 VALIDATION_ERROR` in the ADR-0112 nested envelope, via the shared `refuseRepeatedQueryParams` rule the sibling doors already carry; a single occurrence encoded as a one-element array is unwrapped and accepted. Previously the parameter was ignored outright on this route, so no caller relying on a documented behaviour changes. + - **Ordering:** the read sits AFTER the `manage_metadata` capability gate, so an uncapable caller still gets `403` rather than a `400` that would let it probe the shape of the surface. + - **Absent behaviour is unchanged, deliberately down to key presence.** The key is omitted from the `publishMetaItem` request when no package is stated, rather than passed as `undefined`. `promoteDraftForPublish` forwards to `repo.promoteDraft` on `'packageId' in request` — the KEY, not the value — because `null` there is a meaningful scope (pin the lookup to the unbound row) while an absent key means "match any package". A present-and-`undefined` key would therefore coerce to `null` downstream and stop package-bound drafts from being found, answering `no_draft` on a path this change was not supposed to touch. + + ⚠️ **The acceptance criterion is that the narrowing is now REACHABLE from HTTP, not that publishing got faster.** Package-closure narrowing has a second, independent gate this change does not touch: `narrowObjectsToPackageClosure` keeps any object carrying no `_packageId` provenance, unconditionally, and a tenant-authored overlay corpus carries none. On such a corpus supplying the package still narrows nothing. On a provenance-stamped corpus the shipped deriver measures 421 objects → 45. Both gates must hold; this closes the caller-side one. +- acb4dbc: `GET /api/v1/packages` no longer absorbs a failed durable read into a 200 registry-only listing. + + The handler merged two sources — the in-memory registry and the durable `sys_packages` rows read through `PackageService.list()` — and wrapped the durable half in a bare `catch {}` commented "Database query failed — continue with registry-only packages". A read that could not happen was therefore reported as a read that found nothing: the door answered `200` with `{ packages, total }` built from the registry alone, `total` was presented as a COMPLETE count either way, and the registrar-sourced entries kept `source: 'registry'`, which reads as provenance rather than as a warning that the database half is absent. Nothing on the wire separated "these are all the packages" from "these are the packages I could still see". + + The durable read is no longer caught at this door. `PackageService.list()` still swallows its own driver faults and answers `[]`, and re-throws only the declared seam refusal introduced alongside it (`SERVICE_UNAVAILABLE` / 503, raised when the storage seam accepted the query and returned no result set) — so that refusal now travels to the client through the existing declared envelope, carrying the producer's own status and code. An undeclared throw becomes a `500 INTERNAL_ERROR` through the same envelope. A durable read that answers is unchanged: both sources still merge, `source` is still `registry` / `database` / `both`, and `total` is still the count of what was really read. + + This aligns the two read doors. `GET /api/v1/packages/:id` has no such inner catch and has answered that same refusal since the producer-side change; the list door answering `200` while the detail door refused was the inconsistency. + + **Bump level — why `patch` and not `minor` or `major`.** Nothing an author can write changes: no spec key, export, config field, request shape or response shape is added, removed or renamed, so this carries no migration and is not breaking. No capability is added either, so it is not a feature. What changes is that one door stops reporting a failure as a successful complete answer — a correctness fix to an existing contract, and the same disposition the producer-side half of this fix shipped under. Callers that treated a `200` from this door as "the complete package list" were already being told something untrue when the durable read failed; they now receive the declared refusal instead, exactly as they already did from the sibling detail route. +- 490879a: Declare `packageId` on `publishMetaItem`'s request type, and correct three + in-tree comments that claimed the per-item publish door "names no package" + (#10350). + + `publishMetaItem` declared `type / name / organizationId / actor / message / + _skipSeedApply` and no `packageId`, while since #10063 `POST + /meta/:type/:name/publish?package=PKG_ID` states one on every HTTP-driven + promotion that names a package — which is Studio's designer save-then-publish + loop. + + **No runtime behaviour changes, and nothing was broken at runtime.** The value + already flowed end to end: `publishMetaItem` forwards its whole request object, + the one transform in between (`canonicalizeMetaRequestType`) is a spread that + drops no key, and `promoteDraftForPublish` already declared + `packageId?: string | null` and threaded it into both the #9612 gate closure and + `repo.promoteDraft`. What was wrong was the *declared* contract: the binding was + invisible to every typed caller, and the only caller that states one reaches the + method through a cast, so it was enforced by nothing — one destructuring + refactor away from being dropped in silence. + + `packageId` is `string | null | undefined`, and the three states are distinct: + an **absent** key keeps the historical "match any package" resolution, `null` + pins the lookup to the unbound row, and a present-and-`undefined` key coerces to + `null` downstream and makes a package-bound draft unfindable. Spread it in + conditionally; never write `packageId: maybeUndefined`. + + `environmentId` is deliberately **not** added, though it sits in the same + cast-hidden position on the REST call site. It is the multi-kernel routing key + and is out of the protocol request shape by the maintainer ruling recorded + 2026-08-18 on #9741 — `resolveProtocol(environmentId)` selects the kernel before + the call, and `request.environmentId` is read nowhere in + `@objectstack/metadata-protocol`. `packages/rest` types that one transport-level + member on top of the declared shape (`TransportScopedMetaRequest`) instead. + + Three pins land in `protocol-publish-drafts-package-scope.test.ts`, on the same + two-colliding-drafts fixture the #8907 batch-door cases use, so a promote that + loses the package dimension resolves the *wrong* row rather than merely + succeeding. +- 4389fe9: Reworded the `501 NOT_IMPLEMENTED` message on `GET /meta/:type/:name/published` (and its + compound-name arity) to state its true post-#8278 condition. Since #8278 put the + runtime-published overlay consult ahead of this arm, the 501 no longer means "this kernel + cannot answer `/published`" — it means "nothing is runtime-published for this item, and + this kernel has no code/package store" (i.e. `metadata.getPublished()` is unavailable). + The old message ("metadata.getPublished() is not available in this kernel") overstated + that condition. Status code, `error.code`, and routing order are unchanged — only the + message text changed. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [ab47f69] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/observability@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/service-package@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 812ffadfcd..7cfe5cf1cd 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.1.0", + "version": "17.2.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 cf9cc8deaa..ac0a49ec38 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,444 @@ # @objectstack/runtime +## 17.2.0 + +### Minor Changes + +- 914c413: fix(observability): **BREAKING** — `http_request_errors_total` is retired (ADR-0049 enforce-or-remove, #9834) + + **⛔ If you have a Grafana panel, an alert rule or a recording rule keyed on + `http_request_errors_total`, it will read a FLAT ZERO after this upgrade.** That + zero is the removal, not a healthy server, and it is the one way this change can + hurt you — nothing throws, nothing warns, the series simply stops receiving + samples. Rewrite the query before you deploy. + + Maintainer ruling 2026-08-20: **RETIRE**. The name was declared in `SEMCONV` as + part of a stable namespace *"so hosts can wire alerts/dashboards against it"*, + but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, + applied only by the dispatcher's own route Proxy — so the series never saw + auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other + inbound surface. Its two siblings in the same HTTP family moved to the + `IHttpServer.afterResponse` transport seam (`http_requests_total`, #9650/#9835; + `http_request_duration_ms`, #9834/#10004) and this one could not follow: + `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` + and **no throw signal of any kind**, so every transport-side shape would have + counted a *different* population rather than the same one more widely. + + Migration (FROM → TO): + + | Wrote | Write instead | + |---|---| + | `rate(http_request_errors_total[5m])` in a panel or alert | `rate(http_requests_total{status=~"5.."}[5m])` — emitted by the transport, so it covers every inbound surface instead of the dispatcher's routes only | + | `sum by (route) (http_request_errors_total)` | `sum by (route) (http_requests_total{status=~"5.."})` | + | `SEMCONV.httpRequestErrorsTotal` / `RUNTIME_METRICS.httpRequestErrorsTotal` in host code | Delete the read. Both members are gone; `tsc` reports the missing property at the read site. | + + One-line fix: replace the metric name with `http_requests_total{status=~"5.."}`. + + + + **The replacement is wider, not merely different.** The retired counter was + divergent from a 5xx rate in *both* directions, measured: the dispatcher answers + its own errors through `errorResponseBase`, which sets a status and does **not** + re-throw — so the counter **missed** those — while its `catch` incremented + unconditionally, so a **thrown 4xx WAS counted** as an error. And + `http_requests_total` already carries a `status` label, so a status-class error + counter was fully derivable from data the transport already publishes. Prove the + new query wider rather than merely non-empty: make an auth route or a REST + data-API route answer 5xx and confirm it moves, where the retired counter would + not have moved at all. + + **If what you were actually alerting on was "a handler threw rather than + returning an error envelope"** — the one signal this counter uniquely carried — + that is the `errorReporter`, not a metric. Wire an `ErrorReporter` adapter + (Sentry / Datadog / your own); it still fires on every 5xx throw and is + untouched by this change. + + What is NOT removed: `http_requests_total`, `http_request_duration_ms`, + request-id propagation, the 5xx error reporter, and the + `res.__obsRecordedError` side channel that carries a swallowed error to it. The + dispatcher still instruments every route it mounts; it just no longer publishes + a fourth series whose name promised more coverage than it had. +- 5b39785: `KernelResolver` gains an optional environment-only member so a REST request + pays ONE kernel-waiter window instead of two (#10988). + + `RestApiPlugin` wraps the host's ADR-0006 `kernel-resolver` so `RestServer` can + ask "which environment is this request in?". It asked `resolveKernel` — a + kernel-ACQUISITION api — and kept only `context.environmentId`. A host resolver + writes the id and then awaits that environment's kernel, so the wrapper paid a + full waiter window and discarded what it bought; `resolveProtocol` then acquired + the kernel again. Free on a warm environment (a cache hit, which is why this was + invisible), a second serial wait on a cold or wedged one. Measured on a live + multi-tenant host with `waiterTimeoutMs: 20s`: REST-owned routes + (`/api/v1/discovery`, `/api/v1/data/:object`) answered 503 after ~42s where + dispatcher-owned routes answered after ~21s. + + `KernelResolver.resolveEnvironment?(context, defaultKernel)` resolves ONLY the + request's environment onto the context, acquiring no kernel; the REST wrapper + prefers it when the host implements it, leaving `resolveProtocol` as the single + kernel-acquisition point on the path. + + **Non-breaking, and no flag day.** The member is `?.`-optional: a host that + implements only `resolveKernel` type-checks and behaves exactly as before (it + keeps paying the discarded acquisition on cold builds), so this ships before any + host implements the new half. Adding an optional member to an interface the + framework CONSUMES cannot invalidate an existing implementation — every resolver + already in the field still satisfies the contract. Marked `minor` on + `@objectstack/runtime` because it is a new public capability on an exported + contract, `patch` on `@objectstack/rest` because the wrapper change is a fix + with no surface of its own. + + Fail-closed is unchanged and pinned: the surviving `getOrCreate` still rejects + for a genuinely unavailable kernel, so the caller still gets the host's declared + 503 — a shorter wait to the same verdict, never a response served against no + kernel. `waiterTimeoutMs` is a host setting and is untouched; the defect was + waiting twice, not waiting wrong. +- 67630c4: The `/meta` FSM state route is singular: `meta.getLegalNextStates` moves, the plural registration is retired (#10077) + + Step 2 of the #9180 ruling — the `/meta` type segment is always singular, no + exception and no tolerated plural alias. Maintainer re-weigh, 2026-08-17, + verbatim: 「② 照原样做;只需要修正 objectstack objectui cloud 中错误的写法。」 + + - `client.meta.getLegalNextStates(object, field, from?)` now requests + `GET /api/v1/meta/object/:name/state/:field`. Same method, same arguments, + same response body — only the path segment changes. + - `GET /api/v1/meta/objects/:name/state/:field` is **no longer registered**. + The singular twin has been mounted alongside it since #7526, so the + migration for a hand-rolled HTTP caller is to drop the `s`. A request to the + retired spelling now gets the transport 404, which is the loud answer; the + one shape that changes hands rather than 404ing is a field literally named + `published`, which the compound `/:type/:section/:name/published` route + picks up. + - The two route ledgers follow what is mounted and what the SDK calls: the + plural row is deleted from `rest-route-ledger.ts` and the dispatcher ledger's + mirror row is respelled. + + **What this does not change.** The boundary fold `META_URL_TO_SINGULAR` is + untouched, so no `/meta/:type/...` spelling that is accepted today becomes + refused: the retired route matched a **literal** path segment and never + consulted the fold. The 2026-08-17 re-weigh (item 3) defers that break with no + scheduled window. The legacy dispatcher branch in `runtime/src/domains/meta.ts` + also still matches both literals; narrowing it is not part of this step. + +### Patch Changes + +- 128684d: **Behaviour change (security tightening):** the `/api/v1/automation` **definition writes** now require the `manage_metadata` capability (#10145). + + `POST /api/v1/automation`, `PUT /api/v1/automation/:name` and `DELETE /api/v1/automation/:name` — `automation.create` / `automation.update` / `automation.delete` on the SDK — were reachable by **any authenticated caller**. They now answer **403 `PERMISSION_DENIED`** unless the caller holds `manage_metadata` (ADR-0066 D1's authoring capability), the same key the sibling `PUT /api/v1/meta/:type/:name` and every state-changing `/api/v1/packages/*` route already demand. Engine self-invocation (`isSystem`) bypasses, as on every other capability gate. + + **Existing credentialed callers that author flows over HTTP will start getting 403** and must be granted `manage_metadata`. A flow is authored metadata: this closes the last write door onto the metadata plane that did not ask the metadata plane's question. + + What was measured on a walled multi-organization deployment (`OS_TENANCY_POSTURE=isolated`): a plain tenant org owner holding `organization_admin` — the same session answered 403 by `PUT /meta/:type/:name`, `POST /ai/tools/:tool/execute` and `POST /packages/*` — created, modified and deleted flows through this door, all 200. Flow definitions are registered at **environment** scope, not organization scope, so the write crossed the tenant wall: a shipped flow deleted by one tenant read 404 for the actor, for an unrelated tenant **and** for the platform admin, and an injected flow read 200 for all three. + + **Deliberately unchanged — execution is not authoring:** + + - `POST /automation/:name/trigger` and the legacy `POST /automation/trigger/:name` **run** a flow. They keep their existing posture (authenticated, plus the flow's own `runAs` authorization envelope). + - `POST /automation/:name/runs/:runId/resume` is already fail-closed through the suspended node's `resumeAuthority`; a metadata capability in front of it would refuse the very user the flow paused for. + - `POST /automation/:name/toggle` mutates engine enablement rather than a definition, and is filed separately rather than folded into a security fix. + - The reads (`GET /automation`, `GET /automation/:name`, the run surfaces) are untouched; run-state reads keep their `sys_automation_run` grant. + + The gate sits ahead of the service probe and ahead of body validation, so a refused caller neither writes anything nor learns from a 501-vs-403 whether the deployment mounts automation at all. +- d806081: Render `saveMetaItem`'s `422 INVALID_METADATA` findings clause per write face + + The spec-validation refusal restated its own findings in the message + (`: ` for the first three, plus a `(+N more)` tail) while + attaching the same array as `issues`. On the HTTP 422 both channels ride one + response, so every console rendering both showed each finding twice. + + The clause is now rendered per face. The `/meta` HTTP write doors — REST's + `PUT /meta/:type/:name` and `PUT /meta/:type/:a/:b`, and the runtime + dispatcher's `PUT /meta` — declare that they carry the findings structurally + and get a one-sentence headline instead: the issue count plus up to three + `path [zod code]` locators, the same grammar the seed refusal and the + author-time gate already compose. `issues[]` is attached unchanged on every + face, so nothing is withheld from anyone. + + Faces that carry no structured channel keep the full prose, byte for byte — + `duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s + `rows[].reason`, and the two out-of-package log faces, where this sentence is + the sole carrier of the author's prescription. Silence means "keep the prose": + a write door only ever drops the restatement by declaring itself, never by + omission. +- 047ac86: Five `Plugin` implementations now release their resources from `destroy()`, the + only teardown hook the kernel calls (#10772). + + `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and + `destroy?()`. `ObjectKernel.performShutdown()` and `LiteKernel.destroy()` walk + the plugins in reverse calling `plugin.destroy()` — and nothing anywhere calls + `stop()`, `dispose()`, `close()` or `shutdown()` on a plugin. Each of these five + spelled its teardown with one of those names instead, so what it released was + still held after `await kernel.shutdown()` had **resolved**: + + | package | class | was spelled | what outlived shutdown | + |:--|:--|:--|:--| + | `@objectstack/metadata` | `MetadataPlugin` | `stop` (arrow property) | artifact watcher, `manager.dispose()`, repository handle | + | `@objectstack/runtime` | `AppPlugin` | `stop` (arrow property) | the `app:unregistered` catalog event, never emitted | + | `@objectstack/runtime` | `ExternalValidationPlugin` | `stop` (arrow property) | every armed drift-check `setInterval` | + | `@objectstack/plugin-email` | `EmailServicePlugin` | `dispose` | two metadata subscriptions, the SMTP transport, an engine binding | + | `@objectstack/plugin-webhooks` | `WebhookOutboxPlugin` | `dispose` | the auto-enqueuer (2 realtime subscriptions + a refresh interval) and two engine hooks | + + `ExternalValidationPlugin` is the one with teeth: it is one of only two `Plugin` + implementations in the tree that own `setInterval` directly, it is mounted on + the real `os serve` path, and its `stop()`'s only caller anywhere was the class + itself re-arming. Measured against a real kernel, its drift checker performed + five further reads in the five intervals after a resolved shutdown — the #9371 + mechanism verbatim. `WebhookOutboxPlugin.dispose()` had **zero** callers in the + entire repo, so its teardown had never run in any process at all. + + **Nothing is removed and no signature narrows.** Each old name is retained as a + delegating alias, because it is public API of an exported class and an embedder + may have learned to call it directly precisely BECAUSE the kernel never did. + `stop` stays an arrow property where it was one (so a detached + `const { stop } = plugin` keeps working) and stays synchronous on + `ExternalValidationPlugin` (so a non-awaiting call site is unaffected). The two + `stop(ctx)` aliases widen their parameter to optional. + + One behavioural note for direct callers, since `destroy()` takes no context: + `MetadataPlugin.stop(ctx)` and `AppPlugin.stop(ctx)` now use the context + captured in `init()` and ignore the argument. In a real composition these are + the same object. The visible difference is confined to a plugin whose `init()` + never ran — for `MetadataPlugin` a dropped `warn` line, for `AppPlugin` a + catalog event that is no longer emitted for an app that was never registered. +- a79bd35: Publish refusals no longer render each validation finding twice (#10524) — declare-then-trim. + + **Declared (spec, additive):** `PublishPackageDraftsResponseSchema.failed[]` elements now + declare `issues[]` (the `RuntimeAuthoringIssueSchema` findings the producer has emitted + since #8333 but no declared parse could carry), and `seedApplied` declares `issues[]` + (`{ path, message, code? }`, the seed-body schema refusal's findings). Typed consumers — + the SDK's `PublishPackageDraftsResponse`, any `parse` through the schema — can now read + the structured findings back instead of having them silently stripped. + + **Trimmed (producers):** the #4463 author-time gate's 422 message and + `seedRequestValidationError`'s message are one-sentence headlines — total count plus up to + three `path [rule]` / `path [zod-code]` locators — instead of restating the issue prose + that `issues[]` carries on the same response. Consumers that render only `error` (CLI, + logs) keep what failed, where, under which rule, and how many; consumers that render both + channels stop repeating themselves. The old `(+N more)` tail is subsumed by the leading + count. Both catches that surface the seed refusal onto `seedApplied` now thread the + structured findings beside the headline. + + Error `code`/`status` vocabularies, `advisories`, the DESTRUCTIVE_CHANGE (409) message, + and `saveMetaItem`'s spec-validation 422 message are unchanged. Messages are not contract + (the machine-readable channels are `code` and `issues[]`), so this is not a breaking + change and registers no migration. +- 145ba75: docs: repair the dead repo-relative targets in four published READMEs (#10813) + + A published README ships inside the npm tarball, so a dead relative link in one + is shipped to every reader who installs the package. Nine of them were measured + across four packages, and nothing read them: `check:published-readme-links` + checked docs-site URLs, `check:published-readme-exports` checked fenced import + lines, and the lychee lane never sees `packages/**/README.md`. + + `@objectstack/runtime` carried six dead targets. Each was traced to where the + content actually went rather than deleted: + + - `MINI_KERNEL_GUIDE.md`, `MINI_KERNEL_ARCHITECTURE.md` and + `MINI_KERNEL_IMPLEMENTATION.md` were deleted from the repo root in January as + "redundant markdown files" (d709ecce68 — 14 files, 5051 deletions, nothing + added). The kernel reference they described is the docs site now, so the + Documentation section is the same footer eight sibling READMEs already use. + - `examples/host/` was renamed to `examples/app-host`, then `apps/server`, then + `apps/objectos`, and finally split out to `objectstack-ai/cloud`. In-repo, an + HTTP server in front of the runtime is `@objectstack/plugin-hono-server` plus + the `@objectstack/hono` adapter, so the bullet points there. + - `examples/msw-react-crud/` became `examples/app-react-crud`, then + `apps/console`, and now ships as `@object-ui/console` from another repo. + - `test-mini-kernel.ts` was a root-level scratch script; this package's suite is + 179 test files under `src/`. + - The section also ended on a truncated bullet with an unterminated backtick + (`` - `packages/runtime/src/ ``), which is now a real pointer to that suite. + + The other three packages: `@objectstack/hono` and `@objectstack/service-package` + still spelled `@objectstack/driver-sql` as `../../plugins/driver-sql`, stale + since the driver moved to `packages/drivers/` (#5618). `@objectstack/plugin-security` + and `@objectstack/service-package` linked three packages that are in no directory + of this repo (`plugin-org-scoping`, `service-tenant`, `service-marketplace`); + those links are dropped and the names kept as code spans, which is the spelling + those same files already use for a package they cannot point at in-tree. Whether + those three packages exist at all is a separate question, filed separately. +- 13a6cb4: **Tests (log hygiene):** the sixteen remaining passing `@objectstack/runtime` + fixtures that printed expected `refused a read on` failures into the shared + shard log now **withhold and assert** that noise instead of emitting it + (#10629). No runtime behaviour changes and no test was skipped, loosened or + removed — the same 78 tests pass, and 268 lines of expected-failure output + (134 `[sql-driver] DATABASE_ERROR — the backend refused a read on '
'` + envelopes plus their 134 matching `ERROR Find operation failed` engine frames) + leave the `Test Core` log. + + Why this is worth a release note at all: turbo interleaves package logs without + attribution, so an ERROR-shaped line from a **green** test is indistinguishable + from a real failure in a shard log. Lines of exactly this shape were once + lifted verbatim into a p1 flake signature (#10293) and sent a whole dispatch + cycle at the wrong mechanism. Expected-failure noise from a passing test is a + diagnosis tax on every future red shard. + + Each fixture provokes a **fail-soft probe** — a read the runtime issues to find + out whether something is installed, and whose missing-table answer it swallows + by design: `resolveUserAuthzGrants`' six `sys_*` `tryFind`s, + `ObjectQL.probeInstallOrganizations`, `SeedLoaderService.resolveSoleOrganizationId`, + the lifecycle governance snapshot, `runBuildProbes`' view read, and the boot + metadata load. Every one of them was judged expected rather than diagnostic; + none was silenced on the strength of "it looks like noise". + + ⛔ This is not a mute. PR #10630 ruled the shape for this class on two files — + withhold only a line that names an expected table **and** carries that same + table's `no such table` reason, count what was withheld, and assert the counts — + and this applies that shape verbatim through one shared, test-only module, + `packages/runtime/src/expected-read-refusal-noise.ts`. A fixture that stopped + provoking its probe, or whose table started resolving, now goes **red** instead + of merely going quiet; the engine frame is withheld only when it sits directly + above a driver refusal the capture already recognised, so an identically-shaped + fault from any other cause still reaches the log with both halves intact. +- 9f483d9: Repair six false API claims in the published `@objectstack/runtime` README + (#10368). The README is in the package's `files` array, so it is the page npm + renders — a reader following it wrote code that could not compile. + + Found by hand-adjudicating every call site in that document that + `check:published-readme-exports` reports under `NOT read:` — receivers built + from free variables, parameters and globals, which neither the gate nor a human + reader can type by looking. 30 sites on 17 receivers were read; the repairs below + are what came out. + + - `engine.update('user', user.id, { name: 'Jane' })` → `engine.update('user', + { id: user.id, name: 'Jane' })`. `IDataEngine.update` is + `(objectName, data, options?)`; there is no `id` parameter. A by-id update is + identified by a truthy scalar `data.id` (or `options.where.id`) — the rule + `resolveEngineUpdateDispatch` in `@objectstack/metadata-core` defines. + - `engine.delete('user', user.id)` → `engine.delete('user', { where: { id: user.id } })`. + `IDataEngine.delete` is `(objectName, options?)`; the id belongs in + `options.where.id` (`assertEngineDeleteDispatch`). Passing it positionally + landed the id in the options bag. + - The **Interface Methods** bullet list restated both wrong signatures, so it is + corrected in the same edit — a repaired example beside a bullet list that still + contradicts it is not a repair. + - `reply.code(429).send({ retryAfterMs })` in the rate-limiting recipe → + `res.status(429).json({ retryAfterMs })`. `reply.code()` is Fastify; this + package's HTTP contract is `IHttpResponse`, which spells the step + `status(code)` and whose `send` takes `string | Uint8Array | ArrayBuffer`, not + an object. The `docs/HARDENING.md` recipe the same section links to already + answers 429 through the framework's own JSON responder. + - `status: res.statusCode` in the middleware example → dropped. + `IHttpResponse` has no `statusCode`; a response's status is observed through + `IHttpServer.afterResponse` (`HttpResponseObservation.status`), not read off + the response inside middleware. + - The `PluginContext` interface block declared `logger: Console` and + `getKernel?(): any`. The real contract (`@objectstack/core`) is + `logger: Logger` and a required `getKernel(): ObjectKernel`. + + Documentation only — no runtime, type or export change. +- Updated dependencies [8f04d9a] +- Updated dependencies [4d7c564] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [163a162] +- Updated dependencies [5337ef1] +- Updated dependencies [7d2d112] +- Updated dependencies [03bdd14] +- Updated dependencies [5fa0d72] +- Updated dependencies [7bf3fb7] +- Updated dependencies [02b3b07] +- Updated dependencies [7d81c88] +- Updated dependencies [2570ab0] +- Updated dependencies [5886ee6] +- Updated dependencies [bbe643c] +- Updated dependencies [e634ecf] +- Updated dependencies [02d56b4] +- Updated dependencies [95437e7] +- Updated dependencies [46cfa5b] +- Updated dependencies [82cb6e8] +- Updated dependencies [b20c8d2] +- Updated dependencies [f76fe42] +- Updated dependencies [4257e4e] +- Updated dependencies [3e26359] +- Updated dependencies [6ce58a7] +- Updated dependencies [d806081] +- Updated dependencies [d23e3a0] +- Updated dependencies [9a1ed7a] +- Updated dependencies [f3a8134] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b0af2b] +- Updated dependencies [5b39785] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [9e04c3e] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [2866d5f] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [a16ff50] +- Updated dependencies [e222a53] +- Updated dependencies [acb4dbc] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [c49007a] +- Updated dependencies [047ac86] +- Updated dependencies [3ee8ddf] +- Updated dependencies [0c24898] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [490879a] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [4389fe9] +- Updated dependencies [923c424] +- Updated dependencies [b419135] +- Updated dependencies [88e32a8] +- Updated dependencies [38bc74e] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [f59035c] +- Updated dependencies [af1636c] +- Updated dependencies [86a8ec9] +- Updated dependencies [d9353b9] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [45204a5] +- Updated dependencies [9b0172d] +- Updated dependencies [24ba050] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/plugin-auth@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/driver-sql@17.2.0 + - @objectstack/driver-memory@17.2.0 + - @objectstack/plugin-security@17.2.0 + - @objectstack/service-i18n@17.2.0 + - @objectstack/metadata-protocol@17.2.0 + - @objectstack/rest@17.2.0 + - @objectstack/service-datasource@17.2.0 + - @objectstack/observability@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/metadata-core@17.2.0 + - @objectstack/metadata@17.2.0 + - @objectstack/driver-sqlite-wasm@17.2.0 + - @objectstack/formula@17.2.0 + - @objectstack/service-cluster@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 293a47af3e..253bf53522 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.1.0", + "version": "17.2.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 411287dfda..0c1ffb8678 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/sdui-parser +## 17.2.0 + ## 17.1.0 ## 17.0.0 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 9a7e619452..11b301b892 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.1.0", + "version": "17.2.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 7e3d7eddc4..ad2a93afd4 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,193 @@ # Changelog — @objectstack/service-analytics +## 17.2.0 + +### Minor Changes + +- 57e4571: **BREAKING**: `/analytics/query` now refuses a cross-object filter nested inside a + combinator on the ObjectQL path, instead of silently answering the wrong number + (#10759). + + `ObjectQLStrategy` runs one cross-object envelope check, from two call sites. + `generateSql()` (the `/analytics/sql` preview) asked it about every member the + `where` touches, flattened out of the filter tree. `execute()` asked it about the + built engine filter — where an AND-ed leaf sits at the top level and is seen, but + anything structural (an `$or`, a `$not`, a nested `$and` that cannot merge) has + been folded into `filter.$and`, so the only key readable for it was the literal + `$and`, which is never a field name. + + One query therefore got two answers, measured over one fixture in one run: + + ``` + where: { $or: [{ 'account.region': 'West' }, { stage: 'won' }] } + + before /analytics/sql 400 INVALID_FIELD cross-object filter "account.region" + /analytics/query 200, rows + after both 400 INVALID_FIELD cross-object filter "account.region" + ``` + + `engine.aggregate` cannot join. The half that returned rows was not answering the + cross-object query: the disjunct naming a column the base object does not have + can never match, so the query silently collapsed to its remaining branches and + reported a narrower figure as if it were the answer. Both call sites now derive + the member list from one shared view, so the invariant the strategy already + stated for itself — the preview accepts and rejects the same set the execution + door does — holds by construction rather than by two call sites agreeing. + + Who is affected: a deployment whose driver reports `objectqlAggregate` but not + `nativeSql` (Mongo, the memory driver), running an analytics query that puts a + related object's field inside `$or` or `$not`. Such a query now returns + `400 INVALID_FIELD` naming the member. The refusal already existed and already + had these words; what changed is that the execution door reaches it too. Nothing + an author writes in metadata changes, no stored shape is affected, and queries + whose combinators name only base-object fields are untouched — that set is pinned + in `crossobject-conjunct-refusal.test.ts` alongside the new refusal, because a + fix that refused every combinator would have looked identical from the refusal + side alone. + + The remedy for an affected query is the one the error message has always carried: + run it on a native-SQL driver, which can join, or drop the cross-object member + from the filter. + + +- 13a3dca: **BREAKING**: on the ObjectQL path, a compiled dataset whose definition-level + `filter` is itself cross-object is now refused by both analytics doors instead + of reaching `engine.aggregate` with a predicate it cannot join (#10861). + + PR #10758 gave the dataset's own definition-level `filter` a route onto this + door for the first time. That route was outside the member view the cross-object + envelope check judges, so nothing ever saw it: + + ``` + dataset: object 'opportunity', include: ['account'], + filter: { 'account.region': 'West' } + + before /analytics/query 200, rows -> engine.aggregate received + {"$and":[{"account.region":"West"}]} + /analytics/sql 200, SQL + after both 400 INVALID_FIELD, member "account.region", + cube ""; the engine is never reached + ``` + + `engine.aggregate` cannot join. `account.region` is not a column of + `opportunity`, so on any driver that evaluates the predicate honestly it matches + nothing, and the widget answered a number that was neither the scoped number nor + the unscoped one — with no error anywhere. That is the silent mis-bucket #3654's + loud refusal exists to prevent, arriving through a producer #3654 predates. + + **Breaking, and argued rather than assumed.** A query that returns `200` with + rows today starts answering `400`, on a *saved* dataset rather than on anything + in the request — a dashboard that renders today can start showing an error. That + is the strongest reading of "breaking" and it is why this is called out here + rather than filed as a quiet fix. What is *not* lost is any correct answer: the + rows that stop being served were already wrong, and wrong in the way that hides + itself. The refusal names the member, names the dataset, and says the same + definition is valid on a native-SQL deployment, so the operator has somewhere to + go; the previous behaviour gave them a plausible number and nothing to notice. + Rejecting the dataset at compile time in `dataset-compiler.ts` was considered and + not taken (maintainer ruling, 2026-08-22): the compiler cannot see which driver + will serve the dataset, and the same definition is legal on a native-SQL one. + + Who is affected: a deployment whose driver reports `objectqlAggregate` but not + `nativeSql` (Mongo, the memory driver), serving a dataset whose definition-level + `filter` names a field on a related object. Nothing an author writes changes + shape, no stored document is rewritten, and an **ordinary** dataset scope + (`filter: { is_deleted: false }`) still passes both doors and still reaches the + engine carrying its predicate — that direction is pinned one character away from + the new refusal in `crossobject-conjunct-refusal.test.ts`, because an + implementation that refused *every* dataset scope would look identical from the + refusal side alone and would break every scoped dataset shipping today. + + + +### Patch Changes + +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- 112a8c6: Apply a dataset's definition-level `filter` on the ObjectQL analytics path + (#10413, phase 1). `/api/v1/analytics/query` served by a driver that reports + `objectqlAggregate` but not `nativeSql` (MongoDB, the memory driver) reached + `engine.aggregate` with no `filter` key at all: the dataset's own scope — a + `filter: { is_deleted: false }` on the dataset definition — was dropped, so + every measure aggregated the whole table while the dashboard door, on the same + cube and the same measure names, answered the scoped numbers. The scope is now + ANDed into the strategy's whole-call filter (never merged key-by-key, so a + caller's own `where` and the time windows cannot be overwritten by it), and the + representative SQL echo renders it too. + + Per-MEASURE `filter`s on this path are still not applied: an + `engine.aggregate` aggregation is `{ field, method, alias }` and cannot carry a + predicate of its own. Widening that contract is #10576; lowering the measure + filters into it is phase 2 of #10413. The native-SQL path already applies both + (#10298). +- 6439f8b: Analytics measures are now compiled from everything they declare — `aggregate`, `field` **and** `filter` — on both the dashboard path and `POST /api/v1/analytics/query`. + + **Reported figures change, and the new ones are the declared ones.** Two corrections, both of which move numbers a dashboard or an API consumer is already reading: + + - A measure written `{ aggregate: 'count', field: 'some_column' }` used to compile to `COUNT(*)` and count **rows**. It now compiles to `COUNT("some_column")` and counts **non-null values**. Any such measure will report the same number as before or a **smaller** one, and a rate built on top of it (a numerator over a total) will drop accordingly — a "100%" tile whose column was mostly empty was reading its own denominator. + - `POST /api/v1/analytics/query` used to drop every per-measure `filter`, and the dataset's definition-level `filter` with it, returning unfiltered aggregates under the author's measure names. It now applies both, so the endpoint answers what the dashboard already answered for the same cube. Figures pulled through the API — agent tools, exports, downstream reports — will move to the filtered values; a measure declaring `filter: { stage: 'closed_won' }` stops counting every row. + + Measures that declare no `field` still compile to `COUNT(*)`, and a cube that is not a compiled dataset (an inferred or manifest cube) emits byte-for-byte the statement it did before. Measure filters lower to portable `CASE WHEN` conditional aggregates rather than `FILTER (WHERE …)`, which MySQL does not have. + + If a saved figure or a screenshot disagrees with what the platform now reports, the new number is the one the metadata declares. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index c3a4f21276..c2cf5a3b37 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.1.0", + "version": "17.2.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 598a17146c..b10dc17a2e 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,87 @@ # @objectstack/service-automation +## 17.2.0 + +### Minor Changes + +- 73d9795: Time-relative sweeps are now idempotent per matched window (#10220). Previously the sweep + held no cross-tick memory, so every re-scan of the same window re-dispatched the same + records — a 5s-interval flow minted 15 duplicate reminders in ~70s, and even under a daily + cron a kernel rebuild re-dispatched the day's window. + + - `@objectstack/service-automation` — new platform object `sys_flow_dispatch`: a persisted + dispatch-claim ledger (ADR-0057 telemetry retention, 30 days), registered alongside + `sys_automation_run` and exposed as `AutomationEngine.claim(key): Promise` on + the automation service surface (check-and-record; a concurrent duplicate insert re-reads + and reports the key as already claimed). When no ObjectQL engine / registration is + available the engine degrades to in-process dedup and logs the weakened guarantee once; + when the ledger errors, the claim falls back to the in-process check for that key so a + store outage never blocks a dispatch (availability over strict-once). + - `@objectstack/trigger-schedule` — the time-relative sweep computes a dispatch key from + the MATCHED WINDOW's identity and claims it before launching: offset mode keys on + `(flowName, recordId, windowDay, offset)` — so a dateField edit that moves the window + legitimately re-fires — and range mode keys on `(flowName, recordId, sweepDay, + rangeSpec)`, preserving the documented `withinDays` semantic ("fires every day the + record stays in range") while never firing twice in one day. The trigger resolves the + claim surface structurally from the automation service; without one it dedups + in-process and warns once. + - `@objectstack/spec` — `sys_flow_dispatch` added to `PLATFORM_OBJECTS_BY_PACKAGE` under + `service-automation` (registry conformance). + +### Patch Changes + +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/formula@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 7762aa89ec..387a648d14 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.1.0", + "version": "17.2.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 acfd34bdbe..8842f75679 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,61 @@ # @objectstack/service-cache +## 17.2.0 + +### Patch Changes + +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/observability@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 2ec3979965..5be7d646b4 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.1.0", + "version": "17.2.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 9c3d3f1465..90be6c42cb 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,47 @@ # @objectstack/service-cluster-redis +## 17.2.0 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/service-cluster@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 782dd3efd1..2fded5360b 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.1.0", + "version": "17.2.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 2e59c61b29..20eba17bed 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/service-cluster +## 17.2.0 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index d50e2b1776..af9ba8af13 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.1.0", + "version": "17.2.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 2c5bb7c7a0..24a381217a 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,201 @@ # @objectstack/service-external-datasource +## 17.2.0 + +### Patch Changes + +- e634ecf: fix(rest): `POST /datasources/:name/external/validate` does URL-scoped work (#10537) + + The route asked the `external-datasource` service for `validateAll()` — every + federated object on every federated datasource, each validation driving a live + `introspect(datasource)` remote-schema read — and then kept only the rows whose + `datasource` matched the URL. The rows it kept were correct; the *work* was not + scoped, so one datasource's health check paid for N datasources' remote + round-trips and threw most of the measurement away. An unreachable *unrelated* + remote slowed the answer for the datasource actually asked about (and produced + rows that were then filtered off). + + Measured at the branch point, through the real Hono adapter and the real + `ExternalDatasourceService` over a recording introspector: a request for one of + three federated datasources introspected `['wh_a', 'wh_b', 'wh_c']`. A request + naming a datasource that does not exist introspected all three as well, to + answer the empty report it already answered. + + `ExternalDatasourceService` now carries `validateDatasource(datasource)`, the + scoped twin of the sweep composed from the same primitives (`listObjects` → + filter → `validateObject`) and the same per-object catch, and the route calls + it. Same request answers `['wh_a']`; an unknown name answers `[]`. + + **No response change.** The rows the post-filter used to keep are the rows the + scoped composition returns — same objects, same diffs, same `data.ok` verdict, + same `200`, the same `400 EXTERNAL_DATASOURCE_ERROR` when the service refuses, + the same `503 SERVICE_UNAVAILABLE` when federation is not wired in, and an + unknown `:name` still answers an empty, vacuously `ok` report rather than a + `404`. The selection is keyed on `o.datasource ?? 'default'`, which is exactly + the value `validateObject` reports back as `result.datasource`, so "the rows the + sweep would have kept" and "the objects this selects" are the same set — pinned + directly, in both packages, by comparing the scoped answer against the + sweep-then-filter answer rather than against a remembered body. + + Because the output was already right, the pins that matter here are about the + CALL RECORD, not the body: `external-datasource-validate-scope.test.ts` asserts + which datasources were introspected and that `validateAll()` is not called at + all, over a fixture carrying three federated datasources so the assertion can + actually fail. A body-only test passes on both sides of this change. + + `validateDatasource` is **not** on `IExternalDatasourceService`: the contract + offers `validateObject(objectName)` and `validateAll()`, and adding a + per-datasource spelling to it is a spec-surface decision to take on its own + terms. The composition therefore lives in the service — the only registrant of + the `external-datasource` slot — and the REST registrar probes for it. A wired + service with no scoped spelling takes the same `503` arm every other route in + this family takes when the service cannot serve it, deliberately *not* a silent + fallback to the fan-out: a fallback would leave the old behaviour reachable on a + path no test drives. + + Unchanged: `validateAll()` itself, and the boot-validation sweep in + `packages/runtime` that legitimately validates every federated object. +- f76fe42: Restore the introspected primary key in the persisted `external_catalog` + (#10676). `ExternalDatasourceService` reads `column.primaryKey` — the + `packages/spec` `IntrospectedColumn` spelling — but `plugin.ts` hands it the + driver's `introspectSchema()` result unmodified, and `SqlDriver` (and + `SqliteWasmDriver`, which extends it) speaks the other `IntrospectedColumn` + contract, from `packages/objectql/src/util.ts`: it sets `column.isPrimary` and + fills `table.primaryKeys`, never `column.primaryKey`. + + Measured against a live SQLite database: for a table declared + `primary key (id)`, the driver's `id` column carries `isPrimary: true` and the + table carries `primaryKeys: ['id']`, while `primaryKey` is `undefined`. Because + `ExternalCatalogSchema` defaults `primaryKey` to `false`, `refreshCatalog` + persisted a catalog in which **every** column of **every** remote table claimed + not to be part of the remote key — so Studio's schema browser and the boot gate + read a catalog that shows no primary keys at all. + + The seam now reads the union of all three signals (`primaryKey`, `isPrimary`, + `table.primaryKeys`) rather than any one of them. No in-tree producer uses a + `false` to negate a key another signal asserts, and taking the union means a + producer that fills only the table-level list — or only the per-column flag — + cannot lose half a composite key. No response or record shape changes: a field + that should always have carried the introspected value starts carrying it. + + The regression pin drives the service off a **real** `SqlDriver.introspectSchema()` + result rather than a hand-written fixture. The pre-existing suite could not see + this defect precisely because it hand-wrote its fixture in the spec spelling, so + no test ever fed the service what a driver actually emits. + + Not fixed here: `generateObjectDraft` still drops the key from the generated + object definition. Its destination is an open contract question rather than a + missing read — `fields..primaryKey` is **not** an authorable spec field + key (an object literal carrying it fails `tsc` against `ServiceObject` with + TS2353, and `ObjectSchema.safeParse` with `unrecognized_keys`), and there is no + key on `ObjectExternalBindingSchema` to hold a remote primary key either. See + #10676 for the routing decision. +- 4257e4e: `os datasource introspect --primary-key` (and `POST /object-draft` with + `primaryKey`) now generates an object draft that compiles and parses (#11000). + + The generator emitted a field-level `primaryKey: true` — into the definition + and onto the rendered field line. `primaryKey` is **not a key of the spec field + schema**, so the `*.object.ts` the review-before-commit flow handed the user was + refused by both instruments the file is annotated for: + + - `tsc --noEmit` against `ServiceObject` — `TS2353: Object literal may only + specify known properties, and 'primaryKey' does not exist in type …`; + - `ObjectSchema.safeParse` — `unrecognized_keys` at `["fields",""]`. + + This was the last reason the `opts.primaryKey` path did not build. With #10712's + namespace/`sharingModel` repairs already landed, **both** paths — `primaryKey` + set and unset — now clear `defineStack()`'s namespace check, the + `authoringRulesFor('build')` rule set, and `tsc --noEmit` over the rendered + source. + + The introspected key is not discarded: it is preserved as a comment above the + `fields` block, naming the column(s) the draft was given as the key — + + ```ts + // Remote primary key: order_id, line_no + ``` + + — with the reason it is a comment rather than a field key, and an explicit + caveat that for a composite key some drivers report only the first column + (#10997), so the list is a lower bound rather than a verified complete key. A + table with no reported key gets no comment at all. + + Per the maintainer ruling of 2026-08-22, an authorable spelling for a federated + object's remote key (`external.primaryKey: string[]` on the binding schema) is + **deferred, not rejected** — it returns as its own `packages/spec` change when + federated upsert has a live runtime consumer to justify the surface. +- 3e26359: `os datasource introspect` now generates an object draft that `os build` + accepts (#10712). The review-before-commit flow was handing the user a + `*.object.ts` the platform's own validator refuses, on two independent counts: + + - **The object name carried no `${namespace}_` prefix**, so `defineStack()` + refused it outright (ADR-0028) — measured as + `Object 'customers' is missing the package namespace prefix.` The prefix is + now derived from the datasource's OWN owning package (`_packageId` → + that package's `manifest.namespace`), and applied through + `validateObjectNamespacePrefix` — the same function `defineStack()` and the + runtime publish gate call, so an already-prefixed remote table + (`wh_accounts` under namespace `wh`) is not double-prefixed. + - **No `sharingModel` was emitted**, so the author-time rule set refused it + (`security-owd-unset`, ADR-0090 D1) — the same rule family #9666 hit for the + `os init` template. The draft now declares `sharingModel: 'private'` + explicitly, following the shape #9666 settled on for generated scaffolds: + the rule's own recommended default, rendered with the reason attached. + + When no namespace can be resolved (a datasource with no package provenance, or + a package that declares none) the draft keeps the bare remote-table name and + the rendered source carries a loud `TODO(namespace)`. It does not invent a + prefix — mirroring `defineStack`, which skips the check entirely rather than + inventing one, and avoiding an `_customers` that would trade one invalid draft + for another. + + At the time this landed, the `opts.primaryKey` path still did not build: it + emitted `fields..primaryKey`, which is not an authorable spec field key. + That was #11000, and it is fixed separately in this same release — both paths + build now. See that changeset for what replaced the key. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 7ad826c94d..b80c7a6e19 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.1.0", + "version": "17.2.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 70cb91861e..16c32594a3 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,61 @@ # @objectstack/service-i18n +## 17.2.0 + +### Patch Changes + +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 0990e19c4e..092f4db47a 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.1.0", + "version": "17.2.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 1f44dcf02b..384640f208 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,65 @@ # @objectstack/service-job +## 17.2.0 + +### Patch Changes + +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 770bf5bc54..f4938e76b1 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.1.0", + "version": "17.2.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 9b5a6eac39..c6dc08b4e1 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,132 @@ # @objectstack/service-knowledge +## 17.2.0 + +### Minor Changes + +- e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an + optional `error` now declare a **non-optional** `warn`, so a durability report + always has somewhere to land (#9754, #10556). + + `minor`, not `major`: during the launch window this stack ships breaking changes + as `minor` — every publishable package versions in lockstep, so a `major` would + promote the whole release. `patch` would be wrong in the other direction, because + this *can* break a consumer's build. + + `error` stays optional on every one of these types — hosts legitimately inject + reduced sinks, and requiring `error` was measured and rejected as #9754 option C. + What changes is that its *absence* now has a declared, guaranteed destination. + Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the + type cannot reach, so **no runtime behaviour changes**: nothing that printed + before stops printing, and nothing silent starts printing. + + ### Who has to change, and what to do + + Only a caller that hands one of these sinks an object with **no `warn` method** — + for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no + rename, no removal, and no stored value or metadata key to rewrite. Every + construction site inside this repo already supplied one, so the in-repo cost was + zero; the compile error is reserved for the callers that were silently discarding + these reports. + + The affected types, by package: + + - `@objectstack/cloud-connection` — the internal `PluginContext['logger']` + - `@objectstack/metadata-protocol` — `IndexMigrationLogger` + - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks` + - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger` + - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal + `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']` + - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions` + - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']` + - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`, + `rule-hooks` and `record-share-cascade` + - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions` + - `@objectstack/service-knowledge` — `KnowledgeLogger` + + `AuthManagerOptions['logger']` is the one most likely to be reached from outside: + `AuthManager` is public surface, its `logger` option stays optional, and a logger + that *is* supplied must now carry `warn`. The only non-test construction site in + this repo passes the kernel `Logger`, whose `warn` is already required. + + `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger + field to `{}`. The field is now honestly optional rather than holding an empty + object that declared it could report and discarded everything. Behaviour is + unchanged in both directions. + + + +### Patch Changes + +- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in + the project `create-objectstack` scaffolds — at the canonical docs origin + `https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling. + + Both spellings reach the same pages (the alias redirects to the apex, + path-preserving), so no link was broken. The reason it needs a release rather + than an in-repo fix alone: a README ships inside the npm tarball, so the + version already on npm keeps showing the old host to every reader of the + package page until a new one is published. +- 6d5c4fa: Release these plugins' resources from `destroy()`, the teardown hook the kernel + actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and + `destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and + `LiteKernel.destroy()`, which walk the plugins in reverse calling + `plugin.destroy()`, walked straight past every plugin whose teardown was spelled + `stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still + armed, the REST/OpenAPI/Slack connectors still registered on the automation + engine, the approvals SLA escalation job still scheduled, and the knowledge + event-sync subscription still open. + + Each teardown body now lives in `destroy()`. `stop()` is retained as a + delegating alias with its parameter made optional, so an embedder that learned + to call it directly — precisely because the kernel never did — keeps working + unchanged. No export is removed and the `Plugin` interface is untouched. + + Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as + fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted + from the merge queue. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 2fabe783f6..0b8bc18c9b 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.1.0", + "version": "17.2.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 e75c5f5dc6..64f1807e89 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,146 @@ # @objectstack/service-messaging +## 17.2.0 + +### Minor Changes + +- cdaa72f: fix(service-messaging,plugin-webhooks): the `update`-op tenant-audit surface on the delivery outboxes is classified — `ack` is a dispatcher sweep, `redeliver` threads the caller's tenant (#10740) + + **BREAKING** signature change on `IHttpOutbox.redeliver` and + `MessagingService.redeliverHttp`, shipped as `minor` under the repo's + launch-window convention for breaking changes. + + `sys_http_delivery` and `sys_notification_delivery` carry three single-record + (`multi: false`) writes that the SQL driver audits under the **`update`** op — + a different op, and a different throttle key, from the `updateMany` half + classified previously. Their correct classifications are **opposite**, and + treating them as one sweep is the dangerous reading: + + | site | reachable from | classification | + | --- | --- | --- | + | `SqlNotificationOutbox.ack` | dispatcher tick only | global sweep | + | `SqlHttpOutbox.ack` | dispatcher tick only | global sweep | + | `SqlHttpOutbox.redeliver` | `POST /api/v1/webhooks/redeliver` | request-contextual | + + **The two `ack` sites** are declared global sweeps through a new + `dispatcherAckOptions()` helper, sibling to `dispatcherSweepOptions()` and + deliberately not the same function — that one returns `& { multi: true }`, so a + `multi: false` site cannot borrow it by accident. The warrant was re-derived + against the current tree rather than inherited: `ack` has exactly two callers, + both inside `runPartition()` on a `setInterval` tick holding a per-partition + cluster lock, so no request context exists to thread; and the row being acked + was claimed by a sweep that crosses organizations by construction + (`hash(refId | notificationId | digestKey) mod N` is a load-spreading key, and + one outbox per environment drains the whole queue). Passing the claimed row's + own `organization_id` is documented at the helper as the tempting wrong answer: + a predicate read off the row you are about to write matches exactly that row, + adds no isolation, and silences the audit anyway — the appearance of scoping + without the substance. + + **`redeliver` is not that**, and it is the reason this shipped separately. The + route in front of it is served to any authenticated user, so on a walled + deployment (`OS_TENANCY_POSTURE=isolated|group`) an unscoped replay is an + authenticated user writing another organization's delivery row — the case the + tenant audit exists to catch. It now carries the caller's tenant, applied to + the rows it reads as well as the row it writes, and it must never be given + `bypassTenantAudit`: a scoped write and a bypassed write produce the same + silence in the log, so the flag would convert a detectable hole into an + undetectable one. The webhook route resolves the session's + `activeOrganizationId` and threads it. + + Behaviour change at the endpoint: a delivery row outside the caller's + organization is now **not found** (`RESOURCE_NOT_FOUND`, HTTP 404) rather than + replayed. It is deliberately invisible rather than forbidden, so the endpoint + is not an existence oracle for other tenants' delivery ids. An in-tenant + redelivery is unchanged. + + Migrating a caller: `redeliver(id, guard?)` becomes + `redeliver(id, { tenantId, guard? })`, and `redeliverHttp(id)` becomes + `redeliverHttp(id, { tenantId })`. `tenantId` is a **required** property typed + `string | undefined`, so omitting it does not compile — a caller with no tenant + has to write `tenantId: undefined` and mean it. That is the point of the shape: + an optional property would let the dangerous case, a request path that simply + forgot, type-check in silence. Passing `undefined` leaves the write unscoped + and the audit line still fires, which is the intended reporting behaviour on a + deployment that cannot resolve an organization for the caller. + + + +### Patch Changes + +- 8163a1c: Classify the delivery dispatchers' predicate writes on `sys_http_delivery` and + `sys_notification_delivery` as global environment sweeps (#10673). On a walled + deployment (`OS_TENANCY_POSTURE=isolated|group`) the SQL driver's tenant-audit + gate reported every `updateMany` these outboxes issue from the claim path as an + un-isolated write. The audit was right to ask: both objects are tenant-scoped + via `organization_id`. The answer is that these six writes — the + visibility-timeout reap and the atomic claim in `SqlHttpOutbox.claim`, + `SqlNotificationOutbox.claim` and `SqlNotificationOutbox.claimDigest` — are + issued by a `setInterval` dispatcher tick under a cluster lock, with no request + context and no tenant anywhere in the `ClaimOptions` contract, and they must + cross organizations: one outbox drains the whole environment's queue, so a + per-organization predicate would strand every other organization's deliveries. + They now pass `bypassTenantAudit` through a single documented helper that + carries that warrant. Diagnostics only — per its spec the flag never changes + what a write touches, and the row-level `ack` / `redeliver` writes are + unaffected. +- 900e489: **Fix:** `MessagingServicePlugin` now releases its delivery dispatchers on `kernel.shutdown()`. Previously they kept running after shutdown had resolved (#9371). + + The plugin starts two `setInterval` dispatchers at `kernel:ready` — `NotificationDispatcher` over `sys_notification_delivery` and `HttpDispatcher` over `sys_http_delivery` — and released them from a method named `stop()`. The kernel's plugin teardown hook is `destroy()` (`Plugin.destroy?()` in `@objectstack/core`; the only teardown `ObjectKernel.performShutdown()` and `LiteKernel.destroy()` invoke), and `stop()` is not on that interface, so **nothing ever called it**. Both dispatchers went on claiming and updating delivery rows after `await kernel.shutdown()` returned. Measured on the new pin: 48 further delivery reads/writes in the 80 ms following a resolved shutdown. + + The teardown body now lives on `destroy()`. `stop()` is **retained as an alias** — it is public API of an exported class, and an embedder may well have learned to call it directly precisely because the kernel never did. No call site has to change, and no accept/reject behaviour of any contract moves. + + **Why it was invisible in production, and where the bill landed.** `start()` `unref()`s both timers, so a long-lived host process still exits and the leak is silent. Under vitest the worker process is alive throughout teardown, so a tick fires *after* a test file is over, reads a delivery table through a driver the suite already disconnected, and `SqlDriver`'s console fallback warns. `console.*` inside a vitest worker is an RPC to the main process (`onUserConsoleLog`); one issued after `rpcDone()` has snapshotted the pending set is rejected by `$rejectPendingCalls` as `EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending`. Nothing awaits that promise, so it lands as an unhandled rejection and fails a run in which every test passed — twice measured on `examples/app-showcase` (334/334 and 337/337 green, exit 1, a merge-queue eviction each time). The width of the window is the duration of `rpcDone()`, which is why it only ever fired on a loaded queue runner and never on the PR-side run of the identical diff. + + Suites that boot a kernel with this plugin get quieter and finish cleaner as a result: over 48 loaded runs of the affected showcase file, console output emitted after the file's own `afterAll` went 3 → 0, and console RPC round-trips per run roughly halved (6574 → 3456 in aggregate). +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index b472e583d2..6669ce7f65 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.1.0", + "version": "17.2.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 fa580e6e1a..8cee714490 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,157 @@ # @objectstack/service-package +## 17.2.0 + +### Patch Changes + +- ab47f69: `get()` and `list()` no longer report "not installed" / "nothing installed" over a storage seam they never queried. + + A driver that cannot run raw SQL returns no result set rather than throwing (`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns `null`), and the service's row flattener mapped that to `[]` — the same value a working driver returns when a package genuinely is not installed. Both read paths then handed that back as a product answer, and the boot-time `sys_packages` rehydration skipped silently because of it. + + Reads now establish that the seam ANSWERED before reading emptiness as a fact. A seam that returns no result set is refused with `SERVICE_UNAVAILABLE` / 503 and a message saying the answer is unknown; boot logs the skipped rehydration at `warn` instead of passing over it. A seam that answers with genuinely zero rows is unchanged: `get()` still returns `null` and `list()` still returns `[]`. +- 6ceaa4b: docs: name packages that exist in seven published documents, and gate the class (#10893) + + A published README ships inside the npm tarball, so an install instruction in one + reaches every reader of the package. Nine `@objectstack/` names across seven + published documents named a package that is in **no directory of this repo**, and + five of those sat on `import` lines inside runnable fences. + + `check:published-readme-exports` could not see any of it, by construction. It + resolves a documented import against the package's built type surface through the + workspace member map, so a specifier that is not a member has no type entry to + compare against and the gate reads no further — strict about a member that exists, + silent about one that does not. The gate now makes the member-existence claim + first: an `@objectstack/`-scoped specifier that names no workspace member is a + finding, and the run header prints the scoped population as `N/N` so a recogniser + that stops matching shows up as a denominator that fell. + + What each dead claim now says, and why: + + - **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`** + each misnamed **themselves**. Both READMEs — including their `# ` titles and + every fenced import — said `@objectstack/plugin-trigger-…`, a name that has + never been published. The exported class names (`ScheduleTriggerPlugin`, + `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all + along; only the package name was wrong, so this is a rename pinned by each + package's own `name` field. + - **`@objectstack/plugin-security`** told readers to `install + @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No + such package exists. The organization wall ships as the enterprise + `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the + `org-scoping` service this plugin probes — the name `objectstack serve` and + `objectstack doctor` both print. Asking for the wall without it is a refusal to + boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The + tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the + cloud control-plane runtime from the separate `cloud` repository and not where + the wall comes from either. + - **`@objectstack/service-package`** described packages being "delivered to + runtime kernels that load them through `@objectstack/service-marketplace`". That + package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future + work. The loading half that does exist here is + `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`. + - **`@objectstack/embedder-openai`** had a fenced example importing + `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape, + because a reader pastes it. No knowledge adapter in this repository consumes an + `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder + option, and the adapters the contract is written for are not here. The example + is now the `embed()` surface that does exist, with the gap stated rather than + papered over with a substitute package name. + - **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against + `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has + ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite + through Knex, choosing the client from its optional peers. + - **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code + generators to write `import { User } from '@objectstack/protocol'`. The package + is `@objectstack/spec`, which the same sentence names as the path being + replaced. + + Four `@objectstack/` names that are **not** in this repo are deliberately left as + they are, because prose may name a package this repo does not build and a runnable + import may not: `@objectstack/security-enterprise` (the enterprise edition, whose + install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant` + (the cloud runtime), `@objectstack/framework` (the umbrella install name), and the + two names `service-datasource`'s README recalls as its own past. +- 145ba75: docs: repair the dead repo-relative targets in four published READMEs (#10813) + + A published README ships inside the npm tarball, so a dead relative link in one + is shipped to every reader who installs the package. Nine of them were measured + across four packages, and nothing read them: `check:published-readme-links` + checked docs-site URLs, `check:published-readme-exports` checked fenced import + lines, and the lychee lane never sees `packages/**/README.md`. + + `@objectstack/runtime` carried six dead targets. Each was traced to where the + content actually went rather than deleted: + + - `MINI_KERNEL_GUIDE.md`, `MINI_KERNEL_ARCHITECTURE.md` and + `MINI_KERNEL_IMPLEMENTATION.md` were deleted from the repo root in January as + "redundant markdown files" (d709ecce68 — 14 files, 5051 deletions, nothing + added). The kernel reference they described is the docs site now, so the + Documentation section is the same footer eight sibling READMEs already use. + - `examples/host/` was renamed to `examples/app-host`, then `apps/server`, then + `apps/objectos`, and finally split out to `objectstack-ai/cloud`. In-repo, an + HTTP server in front of the runtime is `@objectstack/plugin-hono-server` plus + the `@objectstack/hono` adapter, so the bullet points there. + - `examples/msw-react-crud/` became `examples/app-react-crud`, then + `apps/console`, and now ships as `@object-ui/console` from another repo. + - `test-mini-kernel.ts` was a root-level scratch script; this package's suite is + 179 test files under `src/`. + - The section also ended on a truncated bullet with an unterminated backtick + (`` - `packages/runtime/src/ ``), which is now a real pointer to that suite. + + The other three packages: `@objectstack/hono` and `@objectstack/service-package` + still spelled `@objectstack/driver-sql` as `../../plugins/driver-sql`, stale + since the driver moved to `packages/drivers/` (#5618). `@objectstack/plugin-security` + and `@objectstack/service-package` linked three packages that are in no directory + of this repo (`plugin-org-scoping`, `service-tenant`, `service-marketplace`); + those links are dropped and the names kept as code spans, which is the spelling + those same files already use for a package they cannot point at in-tree. Whether + those three packages exist at all is a separate question, filed separately. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [05bc692] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] +- Updated dependencies [f334d66] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/metadata-core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 9c835484cd..ffcf73eab4 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.1.0", + "version": "17.2.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 6d344cad5a..e7ded99801 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/service-queue +## 17.2.0 + +### Patch Changes + +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index a6c869ac55..09d560f8d3 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.1.0", + "version": "17.2.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 c816a917a3..cc07a743e6 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/service-realtime +## 17.2.0 + +### Patch Changes + +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 9fa6110fa7..0299160bd9 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.1.0", + "version": "17.2.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 127d15619a..2159ba2c38 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,118 @@ # @objectstack/service-settings +## 17.2.0 + +### Patch Changes + +- a24b7fa: Make the settings ordering contract **declared and enforced**, and make the + residual pre-bind READ audible (#10250). + + `SettingsServicePlugin` binds its data engine from a `kernel:ready` hook + registered in its `start()`. Three shipped plugins read a settings namespace + from a `kernel:ready` hook registered in *their* `start()` — `plugin-email` + (`mail`: SMTP/provider/from-address), `service-sms` (`sms`: provider + credentials and the daily cost ceiling) and `service-storage` (`storage`: + backend and credentials). Hooks fire in registration order, so a reader that + started before the settings plugin read `SettingsService`'s in-memory fallback, + which is empty at boot: the caller received the manifest **default** with + `source: 'default'` and `locked: false`, no diagnostic anywhere, while the + operator's saved row sat unread in `sys_setting`. + + Nothing constrained that order. None of the three declared any dependency on + `com.objectstack.service.settings`, so their position was pure `kernel.use()` + order. It was correct under `os serve` only because the always-on slate happens + to list `settings` first — and `serve` *prepends* an app's declared `requires`, + so an ordinary `requires: ['email']` produced email-before-settings and bypassed + that; cloud's per-tenant runtime mounts the slate from its own wiring. + + Three changes, one contract: + + - **Declared order.** Each of the three plugins now declares + `optionalDependencies: ['com.objectstack.service.settings']`. The kernel + resolves both the init and the start phase from that graph + (`resolvePluginOrder`, ADR-0116), so the bind is ordered ahead of the read + wherever the plugin is composed, in any host. Soft, not hard: a kernel with + no settings service still boots these plugins unchanged. + - **The residual is audible.** A settings read issued while a bind is + *declared but pending* now emits one operator-actionable `warn` per namespace + naming the repair. Deliberately not a refusal — an in-window read of a + setting with genuinely no persisted row must answer the manifest default, and + refusing would turn a correct startup sequence into an error. It stays silent + in every case that is not the window: after `bindEngine`, on a kernel with no + `objectql` at all (`settleWithoutEngine`), for a directly constructed + `SettingsService`, and for a read satisfied by an `OS_*` env override. + - **The slate pin now derives its boundary.** The foundational-prefix + assertion covered `slice(0, 6)` while `sms` — a settings reader — sits at + index 6, one past the end. The new pin + (`packages/cli/src/commands/serve-settings-ordering.pin.test.ts`) states the + rule instead of the count: every always-on entry that is not one of the + services others bind into at `kernel:ready` must be mounted after all of + them. An entry added tomorrow is covered wherever it lands. + + No behaviour changes for a deployment whose order was already correct. +- 1ec36b7: **Behaviour change (tightening, boot-time only):** a settings write issued before `SettingsService`'s data engine is bound is now **refused loudly** instead of resolving successfully while nothing reaches `sys_setting` (#10159). + + `upsertRow` picks its store on `if (this.engine)`, and the engine is bound in exactly one place — `SettingsServicePlugin` registers a `kernel:ready` hook from its `start()` and calls `bindEngine` inside it. `kernel:ready` handlers run in registration order and every plugin's `init()` runs before any plugin's `start()`, so **every `kernel:ready` hook registered from an `init()` fires inside that window**. A `set()` from there landed in the in-process memory fallback, re-resolved off that same array, and handed the caller a fully resolved value; `sys_setting` received nothing, and neither audit ledger recorded anything (both sinks bind on the same `bindEngine` call). Nothing was logged at any level, because the write did not fail — it succeeded against the wrong store. + + **What an operator will now observe.** A write in that window throws `SettingsEngineNotBoundError` — code `SETTINGS_ENGINE_NOT_BOUND`, status **503** — whose message names the window, the reason, and the fix: move the write to `kernel:bootstrapped` (or later), which fires strictly after every `kernel:ready` handler has settled. Previously that same call returned a resolved value and the setting was silently absent after restart. + + **Nothing outside the window changes.** The refusal is armed only by the new opt-in `SettingsServiceOptions.engineBindPending`, which `SettingsServicePlugin` sets in `init()` and clears on both branches of its `kernel:ready` hook — by `bindEngine` when `objectql` is present, or by the new `SettingsService.settleWithoutEngine()` when it is not. So: + + - a `SettingsService` constructed directly (unit tests, bootstrap, control-plane mock) keeps the in-memory fallback exactly as before — it declares no pending bind, and the guard never arms; + - a lean kernel with no `objectql` keeps the plugin's deliberate degradation: once its `kernel:ready` hook has established that no engine is coming, writes resolve into the memory fallback again (now with a `warn` saying those values are lost on restart); + - reads are untouched in every state, so an ordinary boot-time read of a setting still resolves. + + No shipped caller wrote settings inside the window, so no existing startup sequence becomes an error. + + `SETTINGS_ENGINE_NOT_BOUND` is registered in `ERROR_CODE_LEDGER` per ADR-0112. The status is declared on the error class rather than at an HTTP door because no door can reach it: the window closes at `kernel:ready`, and HTTP servers open their socket at `kernel:listening`, strictly after. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index d883f40592..821a7a0b1a 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.1.0", + "version": "17.2.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 37f7acb166..1a8b3df0ee 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,111 @@ # @objectstack/service-sms +## 17.2.0 + +### Patch Changes + +- a24b7fa: Make the settings ordering contract **declared and enforced**, and make the + residual pre-bind READ audible (#10250). + + `SettingsServicePlugin` binds its data engine from a `kernel:ready` hook + registered in its `start()`. Three shipped plugins read a settings namespace + from a `kernel:ready` hook registered in *their* `start()` — `plugin-email` + (`mail`: SMTP/provider/from-address), `service-sms` (`sms`: provider + credentials and the daily cost ceiling) and `service-storage` (`storage`: + backend and credentials). Hooks fire in registration order, so a reader that + started before the settings plugin read `SettingsService`'s in-memory fallback, + which is empty at boot: the caller received the manifest **default** with + `source: 'default'` and `locked: false`, no diagnostic anywhere, while the + operator's saved row sat unread in `sys_setting`. + + Nothing constrained that order. None of the three declared any dependency on + `com.objectstack.service.settings`, so their position was pure `kernel.use()` + order. It was correct under `os serve` only because the always-on slate happens + to list `settings` first — and `serve` *prepends* an app's declared `requires`, + so an ordinary `requires: ['email']` produced email-before-settings and bypassed + that; cloud's per-tenant runtime mounts the slate from its own wiring. + + Three changes, one contract: + + - **Declared order.** Each of the three plugins now declares + `optionalDependencies: ['com.objectstack.service.settings']`. The kernel + resolves both the init and the start phase from that graph + (`resolvePluginOrder`, ADR-0116), so the bind is ordered ahead of the read + wherever the plugin is composed, in any host. Soft, not hard: a kernel with + no settings service still boots these plugins unchanged. + - **The residual is audible.** A settings read issued while a bind is + *declared but pending* now emits one operator-actionable `warn` per namespace + naming the repair. Deliberately not a refusal — an in-window read of a + setting with genuinely no persisted row must answer the manifest default, and + refusing would turn a correct startup sequence into an error. It stays silent + in every case that is not the window: after `bindEngine`, on a kernel with no + `objectql` at all (`settleWithoutEngine`), for a directly constructed + `SettingsService`, and for a read satisfied by an `OS_*` env override. + - **The slate pin now derives its boundary.** The foundational-prefix + assertion covered `slice(0, 6)` while `sms` — a settings reader — sits at + index 6, one past the end. The new pin + (`packages/cli/src/commands/serve-settings-ordering.pin.test.ts`) states the + rule instead of the count: every always-on entry that is not one of the + services others bind into at `kernel:ready` must be mounted after all of + them. An entry added tomorrow is covered wherever it lands. + + No behaviour changes for a deployment whose order was already correct. +- Updated dependencies [8f04d9a] +- Updated dependencies [4d7c564] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [163a162] +- Updated dependencies [7d2d112] +- Updated dependencies [03bdd14] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [bbe643c] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b0af2b] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [e222a53] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [c49007a] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [af1636c] +- Updated dependencies [86a8ec9] +- Updated dependencies [d9353b9] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [45204a5] +- Updated dependencies [9b0172d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/plugin-auth@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index daa2f7bd65..74a98f4a4c 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.1.0", + "version": "17.2.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 243e259312..971e0cc44b 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,151 @@ # @objectstack/service-storage +## 17.2.0 + +### Patch Changes + +- da891e0: **Behaviour change (tightening):** updates of `sys_attachment` rows are now authorization-gated, where they previously ran with **no record-level check at all** (#10091). + + `installAttachmentAccessHooks` gated insert (parent-edit access, `uploaded_by` server-stamped) and delete (uploader-or-parent-editor), but registered **no `beforeUpdate` hook** — so under the default member permission sets (wildcard CRUD, no row scoping) any member could rewrite any attachment row: re-point `parent_id` at a record they cannot read, or rewrite `uploaded_by` and then walk through the delete gate's uploader shortcut. The `sys_comment` kit — explicitly derived from this one — has gated update with the same rule since #4630; the source kit was missing the limb its derivative copied. + + The new `beforeUpdate` gate narrows the accept set as follows; if a currently-working update starts failing, the caller lacked rights the other two verbs already required: + + - **Row rule:** the caller must be the attachment's uploader OR hold edit on its parent record (`ISharingService.canEdit`; degrades to caller-scoped parent READ visibility when no sharing service is present). A multi-row update requires EVERY matched row to pass. Refusals are HTTP 403 with the **standard catalog code `RECORD_NOT_ACCESSIBLE`** (ADR-0112: generic permission conditions take the catalog — the same envelope the comment kit's update gate emits; the insert/delete gates keep their grandfathered `ATTACHMENT_*` codes). + - **Re-point rule:** an update that changes `parent_object`/`parent_id` must additionally satisfy the attach rule on the NEW parent (edit access, read visibility in degraded mode) — 403 `ATTACHMENT_PARENT_ACCESS` otherwise, and a re-point half that names no record (`null`/empty) is refused rather than left to validation. + - **Unscoped shape:** an unscoped `multi: true` update (no `where` at all) is refused outright via the `dispatchUnscopedMultiWrite` whole-operation dispatch (#9974), mirroring the delete verb's #4757 refusal. The explicit match-all `where: {}` is still accepted and authorized per row. + + System-context operations and context-less programmatic calls on bare kernels bypass the gate exactly as the insert/delete gates do. `uploaded_by` is deliberately not re-stamped on update: the caller is already verified as uploader or parent editor before the write proceeds, so the rewrite-then-uploader-delete escalation is closed by the row rule itself. +- a38c3ff: **Bug fix (retention leak):** an UPDATE that re-points a `sys_attachment` row's `file_id` now detaches the PRIOR file the same way deleting that row would — tombstoning it when the re-pointed row was its last reference (#10171). + + `installAttachmentLifecycleHooks` registered only delete-side and insert-side handlers, so a `file_id` re-point left the old `sys_file` sitting at `status='committed'` with zero join rows and no `deleted_at`. That is not the module's "fail toward retention" bias, which buys a **later** look: `sys_file`'s declared lifecycle nominates a row for the sweep only through `ttl { field: 'deleted_at' }` or `retention { onlyWhen: { status: 'pending' } }`, and a silently detached file matches neither — so the reap guard is never asked about it and the storage bytes are stranded permanently, with no later re-examination. + + The new `afterUpdate` handler fires only when the payload actually carries `file_id` and the value actually changes, then runs the existing orphan rule (zero remaining join rows, attachments-scope, committed) on the prior id. It is best-effort like its siblings and never blocks the user's write; with no pre-image available it tombstones nothing, keeping the file. + + The departed id comes from the engine-bound pre-image `ctx.previous`, **not** from a `beforeUpdate` stash mirroring the delete pair. Since #5574 (ADR-0058 Addendum II D1/D2) a predicate write dispatches one fresh context per matched row in each phase, so a stash written in `beforeUpdate` reaches `afterUpdate` on the by-id path and is lost on the predicate path — a stash-based twin would have been silently half-dead on exactly the multi-row updates that orphan the most files. Reading `previous` also adds no driver round trip: the prior-row read is memoized per operation and already demanded on this object. + + **No revival leg was added**, deliberately. Re-pointing a row ONTO a grace-window tombstone is already handled by the reap guard's sweep-time re-verification, which resolves current references, un-tombstones the file and vetoes the reap rather than reclaiming bytes. A second revival mechanism here would be a duplicate answer to a question that already has one. +- a24b7fa: Make the settings ordering contract **declared and enforced**, and make the + residual pre-bind READ audible (#10250). + + `SettingsServicePlugin` binds its data engine from a `kernel:ready` hook + registered in its `start()`. Three shipped plugins read a settings namespace + from a `kernel:ready` hook registered in *their* `start()` — `plugin-email` + (`mail`: SMTP/provider/from-address), `service-sms` (`sms`: provider + credentials and the daily cost ceiling) and `service-storage` (`storage`: + backend and credentials). Hooks fire in registration order, so a reader that + started before the settings plugin read `SettingsService`'s in-memory fallback, + which is empty at boot: the caller received the manifest **default** with + `source: 'default'` and `locked: false`, no diagnostic anywhere, while the + operator's saved row sat unread in `sys_setting`. + + Nothing constrained that order. None of the three declared any dependency on + `com.objectstack.service.settings`, so their position was pure `kernel.use()` + order. It was correct under `os serve` only because the always-on slate happens + to list `settings` first — and `serve` *prepends* an app's declared `requires`, + so an ordinary `requires: ['email']` produced email-before-settings and bypassed + that; cloud's per-tenant runtime mounts the slate from its own wiring. + + Three changes, one contract: + + - **Declared order.** Each of the three plugins now declares + `optionalDependencies: ['com.objectstack.service.settings']`. The kernel + resolves both the init and the start phase from that graph + (`resolvePluginOrder`, ADR-0116), so the bind is ordered ahead of the read + wherever the plugin is composed, in any host. Soft, not hard: a kernel with + no settings service still boots these plugins unchanged. + - **The residual is audible.** A settings read issued while a bind is + *declared but pending* now emits one operator-actionable `warn` per namespace + naming the repair. Deliberately not a refusal — an in-window read of a + setting with genuinely no persisted row must answer the manifest default, and + refusing would turn a correct startup sequence into an error. It stays silent + in every case that is not the window: after `bindEngine`, on a kernel with no + `objectql` at all (`settleWithoutEngine`), for a directly constructed + `SettingsService`, and for a read satisfied by an `OS_*` env override. + - **The slate pin now derives its boundary.** The foundational-prefix + assertion covered `slice(0, 6)` while `sms` — a settings reader — sits at + index 6, one past the end. The new pin + (`packages/cli/src/commands/serve-settings-ordering.pin.test.ts`) states the + rule instead of the count: every always-on entry that is not one of the + services others bind into at `kernel:ready` must be mounted after all of + them. An entry added tomorrow is covered wherever it lands. + + No behaviour changes for a deployment whose order was already correct. +- 9e93fc6: Fix attachment tombstoning silently no-opping on a predicate (`multi: true`) delete + + Deleting `sys_attachment` join rows by PREDICATE left the file they referenced at + `status='committed'` with `deleted_at` NULL, even when the deleted row was the + file's last reference. The tombstone hooks handed file ids from `beforeDelete` to + `afterDelete` on the hook context itself, on the premise that the engine passes + the same `HookContext` to both events. Since ADR-0058 Addendum II (D1/D2) a + predicate write dispatches one FRESH context per matched row in each phase, so + that hand-off never arrived and no tombstone was written. + + The bytes were stranded permanently rather than late: `sys_file`'s declared + lifecycle nominates a sweep candidate only via `ttl { field: 'deleted_at' }` or + `retention { onlyWhen: { status: 'pending' } }`, and an untombstoned orphan + matches neither — so the reap guard was never asked about it. The by-id delete + verb, and both dispatch paths of the update verb, were unaffected. + + The departed id now comes from `ctx.previous.file_id`, which the engine binds on + both phases and both dispatch paths — the same slot the update verb's detach leg + already reads. + + **What an upgrader needs to know.** New predicate deletes tombstone correctly + from this version on. Files ALREADY stranded by the old behaviour are not + retro-actively tombstoned by this change: they sit at `status='committed'` with + live storage bytes and no join row, and nothing in the platform sweep will + nominate them. Recovering that existing backlog needs a one-off reconciliation + pass over `sys_file` (attachments-scope, `status='committed'`, zero + `sys_attachment` references) and is deliberately not part of this fix. +- Updated dependencies [8f04d9a] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [0ab81d1] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/observability@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index 932cd450ca..eac112af63 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.1.0", + "version": "17.2.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 b52dd0cfd8..0e04a52290 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,977 @@ # @objectstack/spec +## 17.2.0 + +### Minor Changes + +- 6936d07: `engine.aggregate` honours a per-aggregation `filter` (#10576, the contract + half of #10413). `AggregationNodeSchema.filter` — declared since #4286 but + marked experimental and enforced by nothing — is now live with SQL + `FILTER (WHERE …)` semantics: the predicate narrows the SOURCE rows that one + aggregation reads while sibling aggregations in the same call keep seeing + every row of the group, so a measure-scoped filter (`stage: 'closed_won'`) + can finally reach the engine instead of being silently dropped (the #10413 + wrong-numbers defect on the ObjectQL analytics path). The + `StrategyContext.executeAggregate` bridge (`@objectstack/spec/contracts`) + gains the same optional `filter` on its aggregation entries so analytics + strategies can lower measure filters into it (#10413 phase 2 consumes this + seam next). + + Execution is the correct-first two-tier shape date bucketing and HAVING use: + the engine lowers filtered aggregations in memory for every driver (unknown + operators refuse loudly with `INVALID_FILTER`/400 naming the aggregation + position; a group emptied by its filter answers the ruled empty-group values + — count/sum 0, avg/min/max null). No driver compiles conditional aggregation + natively today, so each native aggregate face (driver-sql — inherited by + driver-sqlite-wasm and Turso local —, the Turso remote transport, + driver-mongodb's pipeline builder, driver-memory's `performAggregation`) + refuses a directly-delivered per-aggregation filter with + `NOT_IMPLEMENTED`/501 instead of silently aggregating the unfiltered rows. + Aggregations without a `filter` are byte-identically unchanged, including + their native pushdown path. +- 9f05b7d: Declare `organization_id?: string | null` on `ApprovalRequestRow` and + `ApprovalActionRow` (#10331). The approval service has always stamped the + tenancy placement on the rows it inserts — and returns it on request-row + reads — but the published contract types omitted the field, so consumers had + to cast past the contract to reach it. Type-only widening: one declared + optional field per row, no runtime change. +- 7d2d112: Add an optional `sharingModel` slot (enum `private | public_read | public_read_write | controlled_by_parent`) to `BlueprintObjectSchema` and, as a required-but-nullable key, to the OpenAI-strict structured-output mirror (`SolutionBlueprintStrictSchema`). The propose-stage LLM can now author a deliberate Org-Wide Default (OWD) choice — e.g. `private` for an object the user described as personal/sensitive — instead of having the platform's deterministic default silently override the intent expressed at propose time. Omitting the key (or emitting `null` in the strict mirror) still defers to the platform default (business object → `public_read_write`, master-detail child → `controlled_by_parent`). +- 914c413: fix(observability): **BREAKING** — `http_request_errors_total` is retired (ADR-0049 enforce-or-remove, #9834) + + **⛔ If you have a Grafana panel, an alert rule or a recording rule keyed on + `http_request_errors_total`, it will read a FLAT ZERO after this upgrade.** That + zero is the removal, not a healthy server, and it is the one way this change can + hurt you — nothing throws, nothing warns, the series simply stops receiving + samples. Rewrite the query before you deploy. + + Maintainer ruling 2026-08-20: **RETIRE**. The name was declared in `SEMCONV` as + part of a stable namespace *"so hosts can wire alerts/dashboards against it"*, + but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, + applied only by the dispatcher's own route Proxy — so the series never saw + auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other + inbound surface. Its two siblings in the same HTTP family moved to the + `IHttpServer.afterResponse` transport seam (`http_requests_total`, #9650/#9835; + `http_request_duration_ms`, #9834/#10004) and this one could not follow: + `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` + and **no throw signal of any kind**, so every transport-side shape would have + counted a *different* population rather than the same one more widely. + + Migration (FROM → TO): + + | Wrote | Write instead | + |---|---| + | `rate(http_request_errors_total[5m])` in a panel or alert | `rate(http_requests_total{status=~"5.."}[5m])` — emitted by the transport, so it covers every inbound surface instead of the dispatcher's routes only | + | `sum by (route) (http_request_errors_total)` | `sum by (route) (http_requests_total{status=~"5.."})` | + | `SEMCONV.httpRequestErrorsTotal` / `RUNTIME_METRICS.httpRequestErrorsTotal` in host code | Delete the read. Both members are gone; `tsc` reports the missing property at the read site. | + + One-line fix: replace the metric name with `http_requests_total{status=~"5.."}`. + + + + **The replacement is wider, not merely different.** The retired counter was + divergent from a 5xx rate in *both* directions, measured: the dispatcher answers + its own errors through `errorResponseBase`, which sets a status and does **not** + re-throw — so the counter **missed** those — while its `catch` incremented + unconditionally, so a **thrown 4xx WAS counted** as an error. And + `http_requests_total` already carries a `status` label, so a status-class error + counter was fully derivable from data the transport already publishes. Prove the + new query wider rather than merely non-empty: make an auth route or a REST + data-API route answer 5xx and confirm it moves, where the retired counter would + not have moved at all. + + **If what you were actually alerting on was "a handler threw rather than + returning an error envelope"** — the one signal this counter uniquely carried — + that is the `errorReporter`, not a metric. Wire an `ErrorReporter` adapter + (Sentry / Datadog / your own); it still fires on every 5xx throw and is + untouched by this change. + + What is NOT removed: `http_requests_total`, `http_request_duration_ms`, + request-id propagation, the 5xx error reporter, and the + `res.__obsRecordedError` side channel that carries a swallowed error to it. The + dispatcher still instruments every route it mounts; it just no longer publishes + a fourth series whose name promised more coverage than it had. +- 55809a0: fix(spec): reject the retired `key`/`defaultValue` spellings in inline locale maps BY NAME, in any combination — and stop claiming the retired form "resolves to nothing" (#10492) + + Two legs, both on `InlineLocaleMapSchema` in `packages/spec/src/ui/i18n.zod.ts`: + + 1. **Message accuracy.** The `INLINE_LOCALE_KEY` rejection message said the + retired key-reference form (#5055) "resolves to nothing". Measured false: + both resolvers — `resolveI18nLabel` here and objectui's `pickLocalized`, + parity-pinned — fall through to their last resort (first string value, in + key insertion order) and return the raw dotted key, which renders as the + visible label. The message now states the measured behaviour. + + 2. **Enforcement hole closed.** `key` is three letters — syntactically a valid + BCP-47 primary subtag — so `{ key: 'common.save' }` alone parsed as a + "language `key` inline locale map" and painted `common.save` on screen; the + pair form was rejected only because `defaultValue` fails the tag grammar. + The key pattern now refuses the two retired spellings by name, in any + combination, matching the emitted type's `{ key?: never; defaultValue?: + never }` narrowing (#9925, maintainer ruling 2026-08-19, option B). This is + an enforcement gap of the #5055 retirement, not a new contract: nothing else + is denied — real 2–3 letter subtags (`deu`, `fra`, `yue`) still parse. + + FROM → TO: a label authored as `{ key: '' }` (or any inline map + carrying a `key`/`defaultValue` entry) is now refused at parse time with the + named message; write the inline locale map form `{ en: '…', 'zh-CN': '…' }`, + or a plain string resolved through a translation bundle. This is the same + prescription the #5055 retirement and the #9925 type narrowing already carry — + the runtime now enforces what the type already refused. + + +- 2306a76: fix(spec): `theme` / `analytics_cube` are validated at the `/meta` write door (#10194) + + The two doors #6245 left open, closed the same way. Both are declared, + authorable stack collections with real `.strict()` schemas — + `defineStack({ themes })` validates with `ThemeSchema`, + `defineStack({ analyticsCubes })` with `CubeSchema` — yet neither was bound in + `UNREGISTERED_KIND_SCHEMAS`, so `getMetadataTypeSchema()` answered `undefined` + and `saveMetaItem` took its documented "unregistered type → store without + validation" branch: a body the stack door strictly refuses was stored, + unvalidated and badged `success: true`, through the metadata door. For `theme` + that is the console's own styling surface — a malformed one failed at render + rather than at write, with nothing at the write point to say so. + + **FROM** `PUT /meta/theme/:name` / `PUT /meta/analytics_cube/:name` with any + JSON → `200 { success: true }`, stored unvalidated. + **TO** a malformed body → `422 INVALID_METADATA` with structured `issues[]`, + the same envelope every other kind already returned. A well-formed body is + accepted exactly as before. + + Each entry binds the **same schema its stack collection is validated against** + (`ThemeSchema` at `stack.zod.ts` `themes:`, `CubeSchema` at `analyticsCubes:`), + and that closing invariant is now pinned by identity for all five map entries. + + **No new capability surface.** Shape validation only: no `MetadataTypeSchema` + member, no `DEFAULT_METADATA_TYPE_REGISTRY` entry, so every authorization + verdict keeps taking the identical "no static entry ⇒ synthesised + `allowRuntimeCreate: true`" branch. The write *door* is unchanged; only the + 422 is new. #2657's B/C decision on whether these should become kinds is + untouched and unprejudged. `rag_pipeline` is deliberately not bound — it has + no stack collection to take a schema from (#6242 row 2). + + Graded **minor**, following #6245's landed precedent for the identical change + (itself following #5271): a write that previously returned 200 can now return + 422. Nothing well-formed changes behaviour, but a caller relying on the API + accepting malformed bodies will see the difference. + + **One schema change rides along per kind, and it is load-bearing.** + `Theme` and `Cube` now declare the ADR-0010 protection envelope (`_lock`, + `_lockReason`, `_lockSource`, `_lockDocsUrl`, `_packageId`, `_packageVersion`, + `_provenance`) — the sharing_rule precedent from #6245: both metadata load + paths call `applyProtection` on **every** type, and these shapes are + `.strict()`, so binding the door without the spread would have aimed the new + 422 at the runtime's own stamp instead of at malformed author input. Additive + and internal-only — no authored field changes. +- a40dcc1: feat(spec): retire `MetricSchema.filters` — the per-metric raw-SQL filter nothing read (#10414, 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). + + `filters` on a cube metric (`filters: [{ sql: string }]`) was a real authoring + surface — `defineCube()` parses an author literal and + `defineStack({ analyticsCubes })` carries every cube through `StackSchema.parse` + — with ZERO consumers, measured with a positive control: no `.filters` read in + `service-analytics` or any driver's non-test code, while the neighbouring + `format` key IS read. `NativeSQLStrategy.resolveMeasureSql` and + `ObjectQLStrategy.resolveMeasureAggregation` both wrap the metric's `sql` in + the aggregate and never look at `filters` — so a hand-authored + `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently + returned the UNFILTERED aggregate under the author's metric name. That is the + #10298 dataset-measure failure for a hand-authored cube; the dataset half was + repaired through its own structured channel (#10411), which left this key inert + with the fix built around it. The raw-SQL fragment also ran against the + platform's structured-`FilterCondition` direction: it cannot be parameterized, + re-targeted per driver dialect, or walked by the lint filter rules + (`packages/lint/src/filter-walk.ts` deliberately never enumerated it). + + **What is refused:** `filters` on a metric. `MetricSchema` is `strictObject`, + so the key is deleted from the shape and the unknown-key rejection carries the + retirement prescription via the schema's `guidance` entry (fully-qualified key, + why it was inert, the replacement channels, the `os migrate meta` pointer). + The nested `strictObject` the key carried (closed by #4001 batch D) is gone + with it. + + **What stays accepted:** every other metric key (`name`, `label`, + `description`, `type`, `sql`, `format`) parses byte-identically. Filtering + that actually works is unchanged: the query's `where` (canonical Query DSL + `FilterCondition`), the condition folded into the metric's own `sql` + expression, or an ADR-0021 dataset measure's structured `filter`. + + The retirement kit: + + - strict deletion + `guidance` prescription at the schema + (`packages/spec/src/data/analytics.zod.ts`); the `AnalyticsQuerySchema` + `filters` guidance no longer points authors at the removed key + - ADR-0087 registration: retired-key entry `data/Metric:filters` and the D2 + conversion `metric-filters-removed` (protocol 18), wired into the step-18 + chain — `os migrate meta --from 17` strips the key from every metric in + `analyticsCubes[].measures` (pure lossless delete; it never had an effect to + lose) + - pin tests (`analytics.test.ts` — the old parse-survival pin flips to a + refusal pin asserting the prescription; `analytics-strictness-batchd.test.ts` + records the nested batch-D surface as superseded) + - generated baselines/docs follow the schema (`authorable-surface/`, + spec-changes, upgrade guide, reference docs) + + ## FROM → TO + + ```ts + // before — parsed green; both SQL strategies ignored it and the query + // returned the unfiltered aggregate + defineCube({ + name: 'orders', + sql: 'orders', + measures: { + closed_won_revenue: { + name: 'closed_won_revenue', label: 'Closed-Won Revenue', + type: 'sum', sql: 'amount', + filters: [{ sql: "stage = 'closed_won'" }], + }, + }, + dimensions: {}, + }); + + // after — delete the key; express the condition where something reads it: + // query time: { where: { stage: 'closed_won' } } + // in the metric: { type: 'sum', sql: "CASE WHEN stage = 'closed_won' THEN amount END" } + // dataset measure: a structured `filter` (ADR-0021, the #10411 channel) + ``` + + +- 3ee8ddf: fix(security): **BREAKING** — `sys_position` retires the `permissions` column (ADR-0049 enforce-or-remove, #9885) + + Maintainer ruling 2026-08-20: **REMOVE**. The column — a "JSON-serialized array + of permission strings" textarea — was declared on the platform position table + while **no producer ever wrote it and no runtime path ever read it**. The + object-scoped census (every `sys_position`-naming file, with same-object + positive controls resolving `active` / `delegatable` / `is_default` / `name` + to real readers) measured it at zero on both sides: the builtin and declared + position bootstrappers set `label` / `description` / `managed_by` / `active` / + `is_default` only, and position→grant resolution consults + `sys_position_permission_set` rows plus the position `name` — never this + column. Its only reference was the `clone_position` action copying it between + rows (a copy of a value nothing writes), removed in the same stroke. objectui + was searched under the same discipline: no console surface names the column. + A free-text grant catalogue on a security object that no runtime enforces + tells an author — human or AI — that direct position-level permission strings + are a platform capability; they are not. This is an **accept-set narrowing**: + the platform stops declaring, projecting and accepting the column. + + Migration (FROM → TO): + + | Wrote | Write instead | + |---|---| + | `permissions` on a `sys_position` seed row or data-door write | Delete the key. Capability reaches a position **only** through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder); prose that was documenting intent belongs in `description`. | + + One-line fix: delete `permissions` from any authored `sys_position` row. + + + + Enforcement after the removal is loud, not silent: the engine's schema + preflight refuses an undeclared field with `400 INVALID_FIELD` before the + driver or any hook runs, and `PositionSchema`'s strict parse now rejects a + declared-position `permissions` key with guidance naming the binding table. + Physical columns on already-deployed databases are untouched (ADR-0045 schema + sync is additive). If position-level direct grants ever become a real need, + the column is re-declared **with a runtime reader in the same PR** — + declare-and-enforce or don't declare. +- 16cef97: Declare `outcome: 'published' | 'refused' | 'nothing_to_publish'` as a required + key on the `publishPackageDrafts` response (#10462) — the first-class + discriminant for WHICH exit answered, the fact `success` compresses into one + boolean. Before this field, a publish with nothing to promote and a genuine + refusal (pre-flight violation or ADR-0067 D2 rollback) were indistinguishable: + both answer `success: false` with `publishedCount: 0` on a 200, and the no-op + left no trace at all — an AI consumer graded the no-op as "refused and rolled + back" and burned two repair rounds on artifacts that were already correct + (cloud#1488; cloud#1492's patch discriminates on `failed.length > 0`, an + invariant the producer never stated). + + The producer invariants, now stated and pinned in the conformance suites, both + directions of each: `outcome === 'refused'` ⟺ `failed.length > 0`; + `outcome === 'nothing_to_publish'` ⟺ + `published.length === 0 && failed.length === 0`; + `success === (outcome === 'published')`. `success` keeps its exact pre-#10462 + value on every exit — a no-op still answers `success: false` — so consumers + reading only `success` see no change, and cloud#1492's `failed.length` + discrimination stays valid during its convergence onto `outcome`. The no-op + exit additionally logs one `info` line naming the package and both facts + (nothing pending, nothing refused), so that exit is no longer traceless. + + Additive for response consumers. A custom protocol implementation that serves + `publishPackageDrafts` must now emit `outcome` on every return — + `PublishPackageDraftsResponseSchema` declares it required, and the conformance + suites treat a producer return without it as a drifted seam. +- a79bd35: Publish refusals no longer render each validation finding twice (#10524) — declare-then-trim. + + **Declared (spec, additive):** `PublishPackageDraftsResponseSchema.failed[]` elements now + declare `issues[]` (the `RuntimeAuthoringIssueSchema` findings the producer has emitted + since #8333 but no declared parse could carry), and `seedApplied` declares `issues[]` + (`{ path, message, code? }`, the seed-body schema refusal's findings). Typed consumers — + the SDK's `PublishPackageDraftsResponse`, any `parse` through the schema — can now read + the structured findings back instead of having them silently stripped. + + **Trimmed (producers):** the #4463 author-time gate's 422 message and + `seedRequestValidationError`'s message are one-sentence headlines — total count plus up to + three `path [rule]` / `path [zod-code]` locators — instead of restating the issue prose + that `issues[]` carries on the same response. Consumers that render only `error` (CLI, + logs) keep what failed, where, under which rule, and how many; consumers that render both + channels stop repeating themselves. The old `(+N more)` tail is subsumed by the leading + count. Both catches that surface the seed refusal onto `seedApplied` now thread the + structured findings beside the headline. + + Error `code`/`status` vocabularies, `advisories`, the DESTRUCTIVE_CHANGE (409) message, + and `saveMetaItem`'s spec-validation 422 message are unchanged. Messages are not contract + (the machine-readable channels are `code` and `issues[]`), so this is not a breaking + change and registers no migration. +- c684d00: feat(spec): retire `record:highlights` highlight-field `icon` — advertised on six surfaces, drawn by nothing (#10054, 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). + + `icon` on the object arm of `RecordHighlightsField` (`fields: [{ name, label?, + icon?, … }]` on a `record:highlights` component) was a real authoring surface + advertised on six author-facing surfaces — the union's own describe, the + `fields` describe, the lint entry-shape prose, the reference docs, and + objectui's input description — with ZERO read points, measured at the + 2026-08-20 census in every direction: objectui's renderer normalizes the + authored object and carries `icon: f?.icon` into `HeaderHighlight`, whose chip + has no icon slot (its only `icon` occurrence is a button `size="icon"`); the + key is structurally unable to travel `useRegisterHighlightFields`, which + registers `names: string[]`; the Studio block designer publishes the field + list as a `string[]` input, so the key was never designer-publishable; and + every in-tree `record:highlights` producer authors bare string arrays. So an + authored `icon` parsed clean and was drawn by nothing — the #8691 + reference-rail-`icon` shape, on the highlight chip. + + **What is refused:** `icon` on an object-form highlight field. The arm is + `strictObject`, so the key is deleted from the shape and the unknown-key + rejection carries the retirement prescription via the arm's `guidance` entry + (fully-qualified key, why it was inert, the no-replacement guidance, the + `os migrate meta` pointer) — surfaced through the zod-4 union collapse by + `packages/lint/src/zod-issue-format.ts`'s arm unpacking. + + **What stays accepted:** bare-string entries and `{name, label?, type?, + readonly?}` objects parse byte-identically. `readonly` behaviour is untouched + — it is the arm's one enforced key (#5176, HeaderHighlight's inline-edit + gate). There is no replacement for `icon`: the highlight chip renders label + and value only. + + The retirement kit: + + - strict deletion + `guidance` prescription at the schema + (`packages/spec/src/ui/component.zod.ts`); the two advertising describes + (the union's and `RecordHighlightsProps.fields`') no longer spell the key + - ADR-0087 registration: retired-key entry `ui/RecordHighlightsField:icon` and + the D2 conversion `record-highlights-field-icon-removed` (protocol 18), + wired into the step-18 chain — `os migrate meta --from 17` strips the key + from the object entries of every `record:highlights` `fields[]` (pure + lossless delete; it never had an effect to lose) + - pin tests (`component.test.ts` — the old parse-survival pin respells to the + surviving surface; a refusal pin asserts the named `unrecognized_keys` + rejection and its prescription through the union collapse) + - generated baselines/docs follow the schema (spec-changes, upgrade guide, + reference docs); `packages/lint`'s entry-shape prose corrected + - objectui's plugin-detail input-description advertisement is cross-repo and + follows on its own card + + ## FROM → TO + + ```ts + // before — parsed green; the renderer normalized `icon` into a chip with no + // icon slot, so the strip rendered identically with or without it + { + type: 'record:highlights', + properties: { + fields: ['status', { name: 'budget', label: 'Budget', icon: 'dollar-sign' }], + }, + } + + // after — delete the key; nothing replaces it (the chip renders label and + // value only) + { + type: 'record:highlights', + properties: { + fields: ['status', { name: 'budget', label: 'Budget' }], + }, + } + ``` +- 923c424: Schema-free `/meta` spelling entry, and the package becomes tree-shakeable (#10096, #10031). + + - New fine-grained export `@objectstack/spec/meta-spelling`: the `/meta/:type` + URL-spelling contract — `META_URL_TO_SINGULAR`, `canonicalMetaUrlType`, + `metaUrlSpellingRefusal`, `unrecognisedMetaTypeRefusal` — importable for a few + hundred bytes instead of the schema graph the same symbols cost through + `/shared` (measured +246.9 KB minified / +69.7 KB gzipped marginal on a graph + already carrying `/ui` + `/kernel`). `/shared` keeps all four symbols + (re-exported from the one declaration); nothing moves or breaks. + - The map is now materialized at build time (`gen:meta-url-spelling`, gated by + `check:meta-url-spelling`). The module-load `assertMetaUrlSpellingsAgree()` + moved into that gate — same assertion, build-time enforcement home. + - `package.json` declares `sideEffects: false` (module-scope evaluation purity + measured per entry), and emitted bundles carry `/* @__PURE__ */` on deferred + schema construction, so consumer bundlers can drop schemas an entry never + reaches instead of retaining a subpath's whole module graph. + - Standing principle recorded in the package docs: a browser-reachable spec + export surface must be schema-free (maintainer ruling 2026-08-20, #10096). +- 35ad101: feat(spec): retire the `themes` carrier key and `ThemeSchema` — the authoring surface nothing ever applied (#10485, 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-21, recorded verbatim on #10485: 「B:退役授权面 — + 收掉 `themes` 载体键与 schema,`app.branding` 留作唯一颜色面;objectui 引擎代码 + 与单测保留。」 + + `defineStack({ themes })` was a real authoring surface — parsed strictly at the + authoring gate, ingested and stored by artifact ingest + (`ARTIFACT_FIELD_TO_TYPE`) — with ZERO consumers past that point, measured: + no non-test read of `.themes` or of stored `theme` items anywhere in + core/runtime/rest/services/plugins; `theme` never in `MetadataTypeSchema`, + `DEFAULT_METADATA_TYPE_REGISTRY` or `BUILTIN_METADATA_TYPE_SCHEMAS`; the only + mounted `ThemeProvider` is the app-shell chrome light/dark toggle (unrelated to + `ThemeSchema`); and no stack- or app-level key ever selected an active theme. + An author who wrote a theme shipped it through every green gate and the console + looked exactly the same. + + **What is refused:** the top-level `themes:` key. `ObjectStackDefinitionSchema` + is a `strictObject`, so the key is deleted from the shape and the unknown-key + rejection carries the retirement prescription via the schema's `guidance` entry + (removal citation, why it was inert, and the `app.branding` replacement). + `ThemeSchema`, `ColorPaletteSchema`, `TypographySchema`, `BorderRadiusSchema`, + `ShadowSchema`, `ThemeModeSchema`, `defineTheme` and the `Theme` / + `ThemeParsed` / `ColorPalette` / `Typography` / `BorderRadius` / `Shadow` / + `ThemeMode` types are removed from `@objectstack/spec` / `@objectstack/spec/ui` + (orphaned value schemas leave with their one consumer, #3950). `PUT + /api/v1/meta/theme/:name` now gets the #8421 unrecognised-type refusal — the + `themes: 'theme'` fold left `PLURAL_TO_SINGULAR` and with it the generated + URL-spelling contract — instead of the pre-#10194 store-anything branch. + + **What stays:** `app.branding.primaryColor` / `accentColor` — the one live + colour surface (objectui's `AppShell` reads it and derives `--primary`, + `--accent` and friends) — plus objectui's `ThemeEngine` / `ThemeContext` engine + code and their unit tests, explicitly retained by the ruling. Legacy stored + `theme` rows are untouched: reads still answer, DELETE still works, and + `applyConversionsToStoredItem` passes them through unchanged. + + The retirement kit: + + - strict deletion + `guidance` prescription at the stack schema + (`packages/spec/src/stack.zod.ts`); `packages/spec/src/ui/theme.zod.ts` + deleted whole + - ADR-0087 registration: retired-def entries `ui/Theme`, `ui/ThemeMode`, + `ui/ColorPalette`, `ui/Typography`, `ui/BorderRadius`, `ui/Shadow` and the + D3 **semantic** entry `stack-themes-carrier-retired` (protocol 18). Semantic + rather than a D2 conversion on the lossless-only scope guard: a stack may + declare N themes and M apps, so which palette entry becomes which app's + `branding.primaryColor` is a judgment the transform cannot make — the entry + prescribes the hand move instead of auto-deleting authored content + - ingest mapping removed (`packages/metadata/src/plugin.ts`), CLI stats row + removed, showcase example re-based on app branding + - pin tests: `stack-top-level-strict.test.ts` (refusal carries `#10485` + + `app.branding` + no rename suggestion; replacement parses green; no theme + export survives on `./ui`) and `protocol.unrecognised-meta-type.test.ts` + (`/meta/theme` refused with the ADR-0112 envelope, nothing stored) + - generated baselines/docs follow the schema (`authorable-surface/`, + `json-schema.manifest/`, api-surface, export-origins, meta-url-spelling, + spec-changes, upgrade guide, reference docs, skill references) + + ## FROM → TO + + ```ts + // before — parsed green, stored by artifact ingest, applied by NOTHING: + defineStack({ + themes: [{ name: 'corporate', label: 'Corporate', mode: 'light', + colors: { primary: '#7C3AED' } }], + }); + + // after — delete the key; colour the console where something reads it: + defineApp({ + name: 'my_app', + label: 'My App', + branding: { primaryColor: '#7C3AED', accentColor: '#06B6D4' }, + }); + // a custom CSS variable your own stylesheet consumed has no spec slot any + // more — move it into your own CSS. + ``` + + +- ceb33a9: Add `nameField` to the solution-blueprint strict mirror's object schema (required-but-nullable, matching the strict convention), so the design-stage structured output can author the ADR-0079 record-title choice instead of always deferring to the platform auto-pick. The key-parity pin between the strict mirror and the lenient schema is widened from the field schemas to the object schemas, so the next object-level divergence fails a test. +- 8012960: `lifecycle.ttl` now accepts an `onlyWhen` row filter mirroring `retention.onlyWhen`, and the shared `onlyWhen` value union gains the platform's canonical null predicate `{$null: boolean}` (on both blocks). A `transient` object that interleaves live rows with terminal audit tombstones can now spare rows defined by a value's absence — e.g. `ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }` — instead of the TTL reaping backdated tombstones first. The LifecycleService Reaper passes `ttl.onlyWhen` into the same reap scope `retention.onlyWhen` already rides; declaring `ttl.onlyWhen` together with rotation storage or archive is refused at parse time, mirroring retention's guards. +- 75e9301: fix(spec): read the unknown-key lint's posture from the schema the parse applies, and report each record exactly once (#10039) + + An otherwise valid view container carrying one undeclared key produced two + contradicting messages from `defineStack`: + + ``` + WARN: defineStack: views.v1.bogusViewKey: 'bogusViewKey' is not a declared view + key, so its value is dropped at load. + THREW: ✗ views.0: Unrecognized key(s) on this view container: `bogusViewKey`. … + ``` + + The warning promises a silent drop — the view loads, minus one key — and the + refusal one step later says nothing loads at all. An author who reads the + warning and stops there draws the opposite conclusion from the truth, and a + warn channel that is sometimes really an error trains readers to discount it. + + **Root cause: the lint read posture off a different schema than the parse.** + `lintUnknownAuthoringKeys` took each collection's unknown-key posture from + `getMetadataTypeSchema(type)`. That registry answers a different question — it + names the schema for a *persisted metadata body* of that type. What + `defineStack` applies to a *stack collection entry* is the element schema in + `ObjectStackDefinitionSchema`'s own shape, and for `view` the two are not the + same object: + + - `getMetadataTypeSchema('view')` → `ViewMetadataSchema`, a strip-mode **union** + over the three persisted runtime shapes; + - `ObjectStackDefinitionSchema.shape.views` → `z.array(ViewSchema)`, and + `ViewSchema` is the `.strict()` defineView **container**. + + `lintUnknownStackKeys` has always avoided exactly this at the top level, and its + own source says why: a schema that rejects loudly must make the lint go quiet + "rather than become a second, possibly disagreeing voice". The per-collection + walker read the same rule off the wrong schema. + + The posture source is now the stack schema's own slot for the collection. + Measured across all 29 collections `PLURAL_TO_SINGULAR` names, the registry and + the stack slot agree everywhere except: + + | collection | type registry | stack slot | effect | + | --- | --- | --- | --- | + | `views` | `strip` / 91 keys | `strict` / 15 keys | **leaves the lintable set** | + | `themes`, `analyticsCubes` | unregistered | `strict` | skipped either way | + + So `connectors` is the honest remainder — it genuinely warns and drops — and no + other collection changes. + + **Second defect, same walk: every finding on a union root was emitted twice.** + `lintUnknownKeysAgainstSchema` reported the root record itself and *also* handed + that same record to `descend`, whose object arm skipped `depth === 0` ("already + reported by the caller") while its union arm had no such guard. `view` was the + only union root in the wild, so `defineStack` never showed it — the warn-once + set in `warnUnknownAuthoringKeys` absorbed the second copy — while every other + consumer of the exported walker saw both. The root report now lives in `descend` + alone, so each record is reported by exactly one place. That also closes a + latent third copy: a discriminated-union root whose branch the author *did* pick + was reported once against the merged key set and again against the branch's, and + is now reported once, against the branch — the narrower and more accurate set. + + **Nothing about what `defineStack` accepts or rejects changes.** The parse is + untouched; only which of the two existing voices speaks. + + ### API change + + `lintUnknownAuthoringKeys` and `listLintableAuthoringCollections` now take + `ObjectStackDefinitionSchema` as a **required** parameter, injected the same way + and for the same reason `lintUnknownStackKeys` already required it — + `stack.zod.ts` imports this module, so importing the schema back would close a + cycle. Required rather than optional deliberately: an omitted argument falling + back to the type registry would silently reinstate the bug, which is the + silent-loss shape this whole rule family exists to report. Every in-repo call + site (`defineStack`, `os validate`, `os compile`) already had the schema in hand + for the sibling call on the adjacent line. + + Marked `minor` rather than `patch` because of that signature, not because of any + behavioural widening — the fix itself only makes one voice go quiet. + +### Patch Changes + +- 59eb04d: Stop documenting bare `POST /api/v1/ai/chat` as agent-resolved (#10510). Two + shipped docblocks described a resolution step the route does not perform: + `client.ai.agents` claimed `/ai/chat` "talks to the environment's default + agent", and `App.defaultAgent` claimed that endpoint auto-resolves the app's + agent from `context.appName`. The bare route loads no agent and never reads + `context.appName`; the default-agent chain (explicit > `defaultAgent` of the + named app > first active) is driven by the assistant chat endpoint, + `POST /api/v1/ai/assistant/chat`, and `client.ai.agents.chat()` is the only SDK + method that reaches an agent at all. + + Both sites read as a security-relevant scoping guarantee — an agent-resolved + endpoint would have its tool offer scoped by that agent's skills (ADR-0063 + §1/§5) — so a reader auditing "which endpoints are surface-scoped?" from these + declarations got the wrong answer at both. Documentation text only: no schema + key, no parse behaviour and no runtime path changes. +- 5fa0d72: docs(spec): record the live read points of `element:button.icon` and `object-metric.icon` — the last two icon slots whose describes stated only the vocabulary (#10053) + + Both keys parsed and rendered while saying only what alphabet their value is + drawn from: `Icon name (Lucide icon)` and `Icon name (Lucide)`. That sentence is + equally true of the `page:header` `icon` retired in #6946 — refused *precisely + because no render path reads it* — so the prose could not separate a live key + from a dead one. It is the same absence that sent #9397 through a full dispatch + cycle re-deriving the accordion read point from scratch before the retirement + candidate was closed premise-overtaken. #9881 and #9972 recorded the accordion + and tab items; these two close the set for `component.zod.ts`. + + **Both are live**, re-measured rather than transcribed from the card. Note the + pin: the earlier records cite `82a94170c`, but `.objectui-sha` moved to + `9a3daf8d3` in #10137, and these were measured there. + + - `element:button.icon` — `packages/components/src/renderers/form/button.tsx:44-47` + resolves `schema.icon`, and `:69` / `:71` draw it either side of the label per + `iconPosition`, both suppressed while `loading`. + - `object-metric.icon` — `plugin-dashboard/src/index.tsx:161` publishes it as a + designer input; `ObjectMetricWidget.tsx:142` destructures it and forwards it at + `:474` to `MetricWidget`, which resolves it at `MetricWidget.tsx:312-321` and + draws it at `:373-382` in the `colorVariant`-tinted square. + + **The button is the one authorable icon on this surface that does not go through + `LazyIcon`**, and the docblock now says so, because the two paths are not + interchangeable: + + - button: `toPascalCase` (splits on `-` only) → a one-entry rename map + (`Home` → `House`) → `icons[name]` from `lucide-react`. An unknown name + resolves to `undefined` and the button renders with **no icon and no + diagnostic**. + - `LazyIcon` / `getLazyIcon` (`components/src/lib/lazy-icon.tsx:66-92`, the slot + the metric tile and every container icon use): normalises to kebab-case, + validates against Lucide's own name list, and degrades an unknown name to the + `Database` glyph. + + So a spelling that draws an icon in a tab trigger can draw nothing on a button — + previously discoverable only by reading two objectui files. + + **Nothing about what parses changes.** Both keys were already declared and + already optional; no key is widened, narrowed, retired or renamed. What is added + is the prose that makes each liveness verdict readable from the spec side alone, + and the accept-pins that keep it readable: per key, an accept carried through to + the parsed output, an undeclared-sibling refusal so the accept is not vacuous, + and an assertion that the `.describe()` still names its consumer. +- 02b3b07: Point every runtime-emitted documentation URL at the canonical host, and retarget the + metadata-protection `docsUrl` at a page that actually exists. + + Two defects, one string. The host half: `docs.objectstack.ai` is an alias that redirects + to `https://objectstack.ai` path-preservingly, so nothing here was a broken link — it was + the unratified spelling sitting in the places a user copies from. The CLI's spec-version + advisory, the Setup and Studio in-app overview pages (English and Chinese alike), and a + showcase demo action now all name the canonical host. + + The path half is the real fix. All 29 `protection.docsUrl` values on the platform's + system objects and apps pointed at `/adr/0010-metadata-protection`, and `/adr/...` is not + a route on any host: the docs site mounts `content/docs` under `/docs`, `docs/adr/` is + not published, and no redirect source lives outside the `/docs` space. The slug was wrong + too — the record is `0010-metadata-protection-model.md`. Studio renders this URL as a + link in the lock banner, so an operator asking why an item is locked was being sent + nowhere. They now point at `https://objectstack.ai/docs/references/shared/protection`, + the published reference for the very schema that carries the field. +- 52db1d1: Correct two stale author-facing contract statements in `Object.enable` / `Object.lifecycle` — text only, no change to what parses. + + - `lifecycle.ttl.onlyWhen` × `archive` (#10526): the refusal's rejection message no longer says "the Archiver moves rows by age alone". Since #10347 the Archiver selects candidates by the declared ttl cutoff, so that reason had gone stale; the reason it states now is the one that holds — the ttl **window** carries over to the Archiver, the `onlyWhen` **filter** does not, so the filtered-out rows would still be archived. The refusal itself is unchanged. + - `enable.files` / `enable.feeds` (#10336): the two `.describe()` strings said the flags reject *creation*. Since #10170 both capability gates are registered on `beforeUpdate` as well, so they refuse any write that makes a row **target** the walled object — a create and an update that re-points/re-threads an existing row alike (403 `FILES_DISABLED` / `FEEDS_DISABLED`). The strings now state that, matching the docblocks above them. `enable.activities` is unaffected and untouched. +- 5649efb: `LifecycleSchema` now refuses the `retention` + `ttl` + `archive` triple at + parse time unless the ttl restates the age bound exactly — `ttl.field: + 'created_at'` with `ttl.expireAfter` equal to `retention.maxAge` (#10527). + + Since #10347 the Archiver selects the rows it moves by the declared ttl cutoff + (`ttl.field` older than `ttl.expireAfter`) whenever `ttl` is declared, and by + `created_at`/`archive.after` only when it is not. On a diverging triple that + leaves `retention.maxAge` (pinned equal to `archive.after` by the existing + alignment refine) declared but enforced by nothing — a row whose `ttl.field` + sits in the future stays hot past `retention.maxAge`, silently. A declared + bound nothing enforces is the class this block already refuses loudly, so the + divergence is now rejected at authoring time with a named message instead of + being resolved by whichever column the sweep happens to read. + + No shipped or example object declares the triple (censused in #10527: + `sys_audit_log` and `sys_metadata_audit` are the only archive-declaring + objects, both `retention` + `archive` pairs) — so no bundled object changes + behaviour, and the ruled-legal shapes are unchanged: `retention` + `archive` + aligned pairs and `ttl` + `archive` pairs parse exactly as before. +- def0d3e: Runtime publish-gate findings for collection-resident write types (`object` / + `permission` / `book`) now key the top-level collection entry in + `issues[].path` / `advisories[].path` by NAME — + `objects.acme_invoice.sharingModel` — instead of by the gate's private + per-write snapshot index (`objects[417].sharingModel`), which no caller could + resolve: that index numbered an in-memory array a Studio / MCP / REST receiver + has never seen. Single-member write types keep their trivially-stable + positional form (`flows[0].nodes[1]…`), and nested positions inside one named + item (`objects.acme_invoice.indexes[1]`) stay positional — they index the + author's own document. An entry with no splice-safe name falls back to the + positional spelling. The accepted metadata set is unchanged; only the spelling + of the emitted finding `path` changes, and `RuntimeAuthoringIssueSchema.path`'s + description now states the convention. CLI (`os validate` / `os lint`) output + is unchanged — there the index resolves against the author's own config file. +- 8d0bb79: **Liveness-ledger verdict:** `app.navigation[].runAction` moves `planned` → `live`, and drops its `authorWarn` (#10068). + + The declared deep-link slot (`ObjectNavItemSchema.runAction`, #4848/#7253) now has a real consumer in a shipped shell, so authoring it changes runtime behaviour. **What changes for authors:** setting `runAction` no longer raises the liveness advisory that told you the auto-run does not fire from this declaration yet. Nothing about the schema, the accept set, or the authoring-time validation changed — `defineStack`'s cross-reference walk and lint's `validate-action-name-refs` nav arm still reject a name that resolves to no defined action, exactly as before. + + The row carries **two** evidence pointers, not one, and the split is the point: + + - **`producer`** — objectui `packages/layout/src/NavigationRenderer.tsx`: defines `NAV_RUN_ACTION_PARAM` (the wire name's one definition) and applies `withRunAction` inside `resolveHref`'s object branch, on the **list landings only** — never the `recordId` branch. It *writes* the deep link and runs nothing. + - **`evidence`** — objectui `packages/app-shell/src/hooks/useNavRunAction.ts`: the single read-once/consume-once consumer, wired generically at `ObjectView.tsx` (every object list) and behind the entitlement gate at `EnvironmentListToolbar.tsx`. + + A renderer-only pointer would have said the slot is live because something *emits* it; what makes the key live is that a shell *consumes* it, and that lives in `app-shell`, not `layout`. Both were read at the `.objectui-sha` pin `9a3daf8`, which postdates the consumer's merge (objectui#5216 via objectui PR #5354). + + ⚠️ **Recorded on the row: enforcement is not consumption.** The published `@objectstack/spec@17.0.0` does **not** enforce the `runAction` × `recordId` exclusivity — the `objectNavTargetExclusivity` refinement exists on `main` but is outside the GA build — and it accepts `runAction: ''`. That is the merged-but-unpublished window, not a defect. The consequence worth carrying: objectui's list-surface-only precedence and its empty-string-is-absent handling are **load-bearing rather than defensive**, because the pinned schema refuses neither input for it. Generalising: merged upstream ≠ published ≠ pinned downstream, and unlike a missing key, a missing **refinement fails silent** — the input is let through and the consumer proceeds. +- 5acb58d: Docs accuracy: correct the `AgentSchema` example and four stale `.strict()` tombstone rationales + + `AgentSchema`'s own `@example` wrote `knowledge: { sources: …, indexes: … }`, a key the + same schema declares as `retiredKey()` — so the canonical example an author (very often an + AI, ADR-0033) copies taught a key the schema rejects, and typed `never` fails `tsc` at the + authoring site. The line is dropped; the example keeps `skills`, which is the block's point. + + Four tombstone rationales still argued from "the schema is not `.strict()`, so a plain + deletion would silently strip the key". The #4001 `strictObject` conversion made that false + for the schemas named: `AgentSchema` (`agent.tools`), `FieldSchema` + (`field.conditionalRequired`), `ActionSchema` (`action.execute`), and the module docblock of + `shared/retired-key.ts` itself. Each now rests on the reason that is load-bearing today — + the prescription is the payload, since an unknown-key rejection carries neither the + FROM → TO mapping nor the migration command, and the key is typed `never` so the mistake + still fails `tsc` first. Every tombstone stays; only the stated reason changes. + + Prose only — no schema shape, acceptance behaviour or `.describe()` semantic is touched. +- 2e3cf95: Name the real per-tier styling primitive in `PageSchema`'s `kind` and `source` + descriptions, replacing the "JSX/HTML+Tailwind" framing that ADR-0080's 2026-06-30 + amendment retracted on styling. + + A page's `source` is runtime metadata, so the console's build-time Tailwind never + scans it — authored utility `className`s silently produce no CSS. The descriptions + now say what each tier actually styles with: `kind:'html'` via the registered + components' structured props plus a JSON `style` object with `hsl(var(--token))` + theme colors, `kind:'react'` via inline `style` with the same token colors, and + neither with Tailwind classes. + + Text-only correction, no schema shape or acceptance change — the accepted page set + is unchanged, and every other claim in the two descriptions survives verbatim + (parse-never-execute, the compiler package per tier, `source` authoritative over + `regions`, the ADR-0081 `OS_PAGE_REACT=off` gating). + + - `packages/spec/src/ui/page.zod.ts` — the `kind` and `source` `.describe()` + strings and the `source` TSDoc block, which regenerate + `content/docs/references/ui/page.mdx`. +- 4c93387: Document the retry/durable-pause boundary on a flow's `errorHandling` block: a durable + pause (`approval`, `screen`, `wait` — ADR-0019) **ends the retry-governed segment**. + `errorHandling.strategy: 'retry'` describes one synchronous dispatch, so a run that pauses + and later resumes gets exactly one attempt for anything that fails after the pause. + + Prose only — no validation change. The accepted flow set is unchanged and every flow that + parsed before parses identically; what changes is that the boundary is now stated where an + author meets it (the `errorHandling` and `strategy` `describe()` text, which is what the + generated reference tables render) instead of having to be inferred from engine behaviour. + + The boundary is deliberate rather than a gap: the retry knobs (`backoffMs`, + `backoffMultiplier`, `jitter`) model an in-process loop, which a pause of arbitrary + duration is not, and the durable continuation carries no attempt counter. To protect the + half of a flow that runs after a pause, give that half its own failure handling in the + flow — a `try_catch` node with its own `retry` around the post-resume work, or a `fault` + edge to a handler node. `content/docs/automation/flows.mdx` carries the recipe. +- a037f7c: Fix JSON-field writes on Postgres deployments that manage DDL out-of-band + (`skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`): a non-empty array and a bare + string were rejected with a 500, and an empty array was **silently stored as an + empty object** (#10995). + + The SQL driver does `JSON.stringify` a JSON field's value on every non-SQLite + dialect — but only for fields listed in its per-object `jsonFields` registry, + and that registry (like the boolean / numeric / date / datetime / time / + auto_number registries and the tenant-isolation column) was filled **only** as + the first step of a DDL call. A deployment that skips boot schema sync therefore + served every write knowing nothing about its objects, and values reached + node-postgres to be encoded by its per-type defaults: + + - an **object** became JSON text — accidentally correct; + - an **array** became a Postgres ARRAY LITERAL (`{…}`) — `22P02 invalid input + syntax for type json`, a 500 on every write; + - **except `[]`**, whose array literal `{}` is valid JSON, so an empty array was + accepted and stored as an empty **object** — corruption, not an error; + - a **bare string** was passed raw (`x` is not JSON text, `"x"` is) — a 500, + while a number survived because `42` already is valid JSON. + + SQLite never showed any of it: `formatInput` ends with a bind-safety net gated + on that dialect, so the same empty registry is invisible there — which is why + tenant environments on Turso/SQLite and the suites that run on them were blind + to a defect live on every Postgres deployment. + + The registration is now separable from the DDL, on the ruling #7737/#10629 + already made for federated objects — that flag is about DDL, and a binding that + is DDL-free must not ride on it: + + - `SqlDriver.registerObjectMetadata(objects)` installs a managed object's + coercion metadata with no `CREATE TABLE`, no `ALTER TABLE`, no existence probe + and no round-trip — the managed sibling of `registerExternalObject`, declared + optional on `IDataDriver` so drivers that don't need it omit it; + - a `skipSchemaSync` boot (and metadata reload) now takes that route instead of + doing nothing, keeping the cold-start budget the flag exists to protect; + - `initObjects` registers before the ADR-0015 DDL gate refuses, so objects on a + datasource ObjectStack is only a guest in are encoded from their declared + field types too. The refusal itself is unchanged. +- 6ceaa4b: docs: name packages that exist in seven published documents, and gate the class (#10893) + + A published README ships inside the npm tarball, so an install instruction in one + reaches every reader of the package. Nine `@objectstack/` names across seven + published documents named a package that is in **no directory of this repo**, and + five of those sat on `import` lines inside runnable fences. + + `check:published-readme-exports` could not see any of it, by construction. It + resolves a documented import against the package's built type surface through the + workspace member map, so a specifier that is not a member has no type entry to + compare against and the gate reads no further — strict about a member that exists, + silent about one that does not. The gate now makes the member-existence claim + first: an `@objectstack/`-scoped specifier that names no workspace member is a + finding, and the run header prints the scoped population as `N/N` so a recogniser + that stops matching shows up as a denominator that fell. + + What each dead claim now says, and why: + + - **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`** + each misnamed **themselves**. Both READMEs — including their `# ` titles and + every fenced import — said `@objectstack/plugin-trigger-…`, a name that has + never been published. The exported class names (`ScheduleTriggerPlugin`, + `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all + along; only the package name was wrong, so this is a rename pinned by each + package's own `name` field. + - **`@objectstack/plugin-security`** told readers to `install + @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No + such package exists. The organization wall ships as the enterprise + `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the + `org-scoping` service this plugin probes — the name `objectstack serve` and + `objectstack doctor` both print. Asking for the wall without it is a refusal to + boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The + tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the + cloud control-plane runtime from the separate `cloud` repository and not where + the wall comes from either. + - **`@objectstack/service-package`** described packages being "delivered to + runtime kernels that load them through `@objectstack/service-marketplace`". That + package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future + work. The loading half that does exist here is + `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`. + - **`@objectstack/embedder-openai`** had a fenced example importing + `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape, + because a reader pastes it. No knowledge adapter in this repository consumes an + `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder + option, and the adapters the contract is written for are not here. The example + is now the `embed()` surface that does exist, with the gap stated rather than + papered over with a substitute package name. + - **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against + `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has + ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite + through Knex, choosing the client from its optional peers. + - **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code + generators to write `import { User } from '@objectstack/protocol'`. The package + is `@objectstack/spec`, which the same sentence names as the path being + replaced. + + Four `@objectstack/` names that are **not** in this repo are deliberately left as + they are, because prose may name a package this repo does not build and a runnable + import may not: `@objectstack/security-enterprise` (the enterprise edition, whose + install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant` + (the cloud runtime), `@objectstack/framework` (the umbrella install name), and the + two names `service-datasource`'s README recalls as its own past. +- 15ea214: docs: make the root README's example-app size claim and package-directory count agree with what they describe (#10320) + + Two numbers on the front door of the repo restated a fact that lived somewhere else, and had already drifted from it: + + - The `examples/app-crm` size blurb hard-coded **31 files, 1,792 lines, roughly 16k tokens**, then handed the reader the exact `find examples/app-crm/src -name '*.ts' -not -name '*.test.ts' | xargs cat | wc -l` command and invited them to verify it under "Count it yourself:". Running that command against `origin/main` returns **1,930 lines**, not 1,792 — a reader who took the invitation got a different number than the one two lines above it. + - The Package Directory's `
` summary claimed **72 published packages**; the table beneath it actually lists **45** rows (a curated set of highlights, not every package — three of those rows are the example apps, whose `package.json` is `"private": true` and never published at all), while the repo's true count of non-private `package.json` files is **69**. + + Rather than re-hardcoding a fresh pair of numbers that will silently drift again at the next merge to `examples/app-crm` or the next row added to the table, both passages now name their own source of truth explicitly and defer to it instead of duplicating it: the CRM blurb states its numbers "as of this writing" and says outright that the command below it, not the sentence, is authoritative; the package-directory summary's count is now the table's actual row count and says the table itself is the source of truth for that count. +- de19489: docs: note that the root README's `claude mcp add` one-liner needs a follow-up sign-in step (#10319) + + The "Your app is AI-operable, for free" section's copy-paste command + (`claude mcp add --transport http my-app http://localhost:3000/api/v1/mcp`) + registers the server correctly, but running it alone and then calling a tool + 401s — measured live, at head, against a freshly booted `examples/app-crm`: + unauthenticated `initialize` returns + `401 {"code":"UNAUTHENTICATED","message":"Unauthorized: a valid OAuth access + token or API key is required"}`, exactly as the finding this closes reported. + The README gave no hint that a sign-in step follows the command. + + The linked docs page, [Connect an MCP + Client](https://objectstack.ai/docs/ai/connect-mcp), already carries the step + in full (interactive OAuth browser login, plus a headless API-key flow for + CI/containers) — confirmed by reading it and by reproducing both paths live: + the same unauthenticated call 401s with a `WWW-Authenticate` header + advertising OAuth metadata, and minting a key via `POST /api/v1/keys` with a + session cookie and sending it back as `x-api-key` returns `200` with a valid + `initialize` response. So the fix is a one-sentence pointer in the README, not + a rewrite of the docs page it already correctly delegates to. +- 1ec36b7: **Behaviour change (tightening, boot-time only):** a settings write issued before `SettingsService`'s data engine is bound is now **refused loudly** instead of resolving successfully while nothing reaches `sys_setting` (#10159). + + `upsertRow` picks its store on `if (this.engine)`, and the engine is bound in exactly one place — `SettingsServicePlugin` registers a `kernel:ready` hook from its `start()` and calls `bindEngine` inside it. `kernel:ready` handlers run in registration order and every plugin's `init()` runs before any plugin's `start()`, so **every `kernel:ready` hook registered from an `init()` fires inside that window**. A `set()` from there landed in the in-process memory fallback, re-resolved off that same array, and handed the caller a fully resolved value; `sys_setting` received nothing, and neither audit ledger recorded anything (both sinks bind on the same `bindEngine` call). Nothing was logged at any level, because the write did not fail — it succeeded against the wrong store. + + **What an operator will now observe.** A write in that window throws `SettingsEngineNotBoundError` — code `SETTINGS_ENGINE_NOT_BOUND`, status **503** — whose message names the window, the reason, and the fix: move the write to `kernel:bootstrapped` (or later), which fires strictly after every `kernel:ready` handler has settled. Previously that same call returned a resolved value and the setting was silently absent after restart. + + **Nothing outside the window changes.** The refusal is armed only by the new opt-in `SettingsServiceOptions.engineBindPending`, which `SettingsServicePlugin` sets in `init()` and clears on both branches of its `kernel:ready` hook — by `bindEngine` when `objectql` is present, or by the new `SettingsService.settleWithoutEngine()` when it is not. So: + + - a `SettingsService` constructed directly (unit tests, bootstrap, control-plane mock) keeps the in-memory fallback exactly as before — it declares no pending bind, and the guard never arms; + - a lean kernel with no `objectql` keeps the plugin's deliberate degradation: once its `kernel:ready` hook has established that no engine is coming, writes resolve into the memory fallback again (now with a `warn` saying those values are lost on restart); + - reads are untouched in every state, so an ordinary boot-time read of a setting still resolves. + + No shipped caller wrote settings inside the window, so no existing startup sequence becomes an error. + + `SETTINGS_ENGINE_NOT_BOUND` is registered in `ERROR_CODE_LEDGER` per ADR-0112. The status is declared on the error class rather than at an HTTP door because no door can reach it: the window closes at `kernel:ready`, and HTTP servers open their socket at `kernel:listening`, strictly after. +- 5f2e54c: **Docs:** the `skill.tools[]` docblock now states ADR-0109's authoring model instead of its rejected alternative (#10356). + + `SkillSchema.tools`' docblock told authors that "Tools should also be registered as first-class metadata (type: 'tool') unless they are dynamically materialised at runtime" — the shape ADR-0109 explicitly **rejected** ("a required tool record per exposed action": a second authoring step, a second namespace to keep consistent, and a second surface for AI authors to hallucinate into, for zero added capability). It also inverted the exemption, treating the materialised path as the exception when ADR-0109 makes it — together with the platform registry — the rule. The sibling docblock over `stack.zod.ts`'s `tools` already said the opposite, so the package shipped two contradictory answers to the same question. + + The text now mirrors the resolution universe `@objectstack/lint`'s `validate-ai-tool-references` actually implements: a `tool` record is never required and the default third-party path declares none; a `skill.tools[]` name resolves against the stack's own `stack.tools[]` names, `PLATFORM_PROVIDED_TOOL_NAMES`, and the `action_` family the runtime materialises from AI-exposed declarative actions (`ai.exposed` + `ai.description` on a headless action type, per ADR-0011). It also records that `stack.tools` is the optional Phase-2 AI-presentation refinement layer with no runtime reader until that phase lands — so a record authored today is inert, which the old sentence recommended authoring without saying. + + Prose only: no schema shape, no `.describe()` text, no runtime behaviour and no authorable-surface change (`check:authorable-surface` and the whole `check:generated` set are unmoved by this diff). It is graded rather than skipped because the text ships to consumers: `@objectstack/spec`'s `files` list publishes `src/**/*.zod.ts`, so this docblock travels in the npm tarball as source. It does **not** reach `dist/*.d.ts` — property-level comments inside the `z.object({ … })` literal are dropped from the emitted declarations, which is measurable in the built chunk (`tools: z.ZodArray;`, no comment). Published source is the surface that matters here anyway: this is the docblock an AI author reads while writing `skill.tools[]`, the exact surface ADR-0109 was written to keep clean. +- 189373b: Declare the package's browser boundary in the `exports` map (#11072): the five + entries whose module graph reaches the driver-config validators (`.`, `./data`, + `./system`, `./kernel`, `./cloud`) now carry a `browser` export condition + pointing at bundles (`dist/browser/**`) in which the postgres `url` + refinement's pg-grammar arm is excluded. `pg-connection-string` — the parser + `pg` itself uses, and the one the #9091 refusal deliberately asks — statically + resolves `require('fs')`, so any browser bundler whose client graph reached one + of these entries failed on `Can't resolve 'fs'` (measured on objectui's docs + site, Next.js/Turbopack). + + Patch, not minor/major, because the change is additive resolution surface with + zero Node-side movement: Node's resolver never matches `browser`, every + existing `import`/`require` condition still points at the same files, and the + full #9091 DSN refusal (multi-host, non-numeric port, scheme-less non-URL) + still runs for every Node consumer — the existing `postgres.test.ts` pins hold + it. In the browser-conditioned bundles the refinement degrades to the + shape-only checks it already performs before `parse` (the unix-socket + short-circuit and the fs-reading `?sslcert=`/`?sslkey=`/`?sslrootcert=` + refusal); publish-time validation never legitimately runs in a browser. + + The boundary is enforced at this producer from now on: + `check:browser-reachable-entries` refuses any browser-resolvable bundle — + browser-conditioned or not — that links a Node builtin or a declared + server-only package, with a positive control on the Node side, so the next + Node-only import fails this package's own CI instead of a downstream bundler. +- 73d9795: Time-relative sweeps are now idempotent per matched window (#10220). Previously the sweep + held no cross-tick memory, so every re-scan of the same window re-dispatched the same + records — a 5s-interval flow minted 15 duplicate reminders in ~70s, and even under a daily + cron a kernel rebuild re-dispatched the day's window. + + - `@objectstack/service-automation` — new platform object `sys_flow_dispatch`: a persisted + dispatch-claim ledger (ADR-0057 telemetry retention, 30 days), registered alongside + `sys_automation_run` and exposed as `AutomationEngine.claim(key): Promise` on + the automation service surface (check-and-record; a concurrent duplicate insert re-reads + and reports the key as already claimed). When no ObjectQL engine / registration is + available the engine degrades to in-process dedup and logs the weakened guarantee once; + when the ledger errors, the claim falls back to the in-process check for that key so a + store outage never blocks a dispatch (availability over strict-once). + - `@objectstack/trigger-schedule` — the time-relative sweep computes a dispatch key from + the MATCHED WINDOW's identity and claims it before launching: offset mode keys on + `(flowName, recordId, windowDay, offset)` — so a dateField edit that moves the window + legitimately re-fires — and range mode keys on `(flowName, recordId, sweepDay, + rangeSpec)`, preserving the documented `withinDays` semantic ("fires every day the + record stays in range") while never firing twice in one day. The trigger resolves the + claim surface structurally from the automation service; without one it dedups + in-process and warns once. + - `@objectstack/spec` — `sys_flow_dispatch` added to `PLATFORM_OBJECTS_BY_PACKAGE` under + `service-automation` (registry conformance). +- f399618: Retarget four `roles` → `positions` action-session provenance strings from "v11" to + "v16" — the release that actually shipped the `#3280` deprecate → `#3290` remove + session-alias precedent they cite (`content/docs/releases/v16.mdx` is the only release + page citing `#3290`). + + Text-only provenance correction, no schema shape or acceptance change: + + - `ActionSessionSchema`'s `positions` and `roles` `.describe()` strings + (`packages/spec/src/ui/action-params.zod.ts`) — regenerates + `content/docs/references/ui/action-params.mdx`. + - The `action-session-roles-to-positions` migration rationale + (`packages/spec/src/migrations/registry.ts` and + `packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts`) + — regenerates `spec-changes.json` and `docs/protocol-upgrade-guide.md`. + ## 17.1.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 186c612535..a2f00b28ba 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.1.0", + "version": "17.2.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 79b87809e1..c6873862de 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/trigger-api +## 17.2.0 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 036da662ee..51b7049bea 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.1.0", + "version": "17.2.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 e69a6d5ce6..e5e8160488 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,113 @@ # @objectstack/plugin-trigger-record-change +## 17.2.0 + +### Patch Changes + +- 6ceaa4b: docs: name packages that exist in seven published documents, and gate the class (#10893) + + A published README ships inside the npm tarball, so an install instruction in one + reaches every reader of the package. Nine `@objectstack/` names across seven + published documents named a package that is in **no directory of this repo**, and + five of those sat on `import` lines inside runnable fences. + + `check:published-readme-exports` could not see any of it, by construction. It + resolves a documented import against the package's built type surface through the + workspace member map, so a specifier that is not a member has no type entry to + compare against and the gate reads no further — strict about a member that exists, + silent about one that does not. The gate now makes the member-existence claim + first: an `@objectstack/`-scoped specifier that names no workspace member is a + finding, and the run header prints the scoped population as `N/N` so a recogniser + that stops matching shows up as a denominator that fell. + + What each dead claim now says, and why: + + - **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`** + each misnamed **themselves**. Both READMEs — including their `# ` titles and + every fenced import — said `@objectstack/plugin-trigger-…`, a name that has + never been published. The exported class names (`ScheduleTriggerPlugin`, + `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all + along; only the package name was wrong, so this is a rename pinned by each + package's own `name` field. + - **`@objectstack/plugin-security`** told readers to `install + @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No + such package exists. The organization wall ships as the enterprise + `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the + `org-scoping` service this plugin probes — the name `objectstack serve` and + `objectstack doctor` both print. Asking for the wall without it is a refusal to + boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The + tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the + cloud control-plane runtime from the separate `cloud` repository and not where + the wall comes from either. + - **`@objectstack/service-package`** described packages being "delivered to + runtime kernels that load them through `@objectstack/service-marketplace`". That + package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future + work. The loading half that does exist here is + `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`. + - **`@objectstack/embedder-openai`** had a fenced example importing + `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape, + because a reader pastes it. No knowledge adapter in this repository consumes an + `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder + option, and the adapters the contract is written for are not here. The example + is now the `embed()` surface that does exist, with the gap stated rather than + papered over with a substitute package name. + - **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against + `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has + ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite + through Knex, choosing the client from its optional peers. + - **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code + generators to write `import { User } from '@objectstack/protocol'`. The package + is `@objectstack/spec`, which the same sentence names as the path being + replaced. + + Four `@objectstack/` names that are **not** in this repo are deliberately left as + they are, because prose may name a package this repo does not build and a runnable + import may not: `@objectstack/security-enterprise` (the enterprise edition, whose + install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant` + (the cloud runtime), `@objectstack/framework` (the umbrella install name), and the + two names `service-datasource`'s README recalls as its own past. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index 4739dccf47..322c07e48b 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.1.0", + "version": "17.2.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 e30a88533a..4f8d777bed 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,139 @@ # @objectstack/plugin-trigger-schedule +## 17.2.0 + +### Minor Changes + +- 73d9795: Time-relative sweeps are now idempotent per matched window (#10220). Previously the sweep + held no cross-tick memory, so every re-scan of the same window re-dispatched the same + records — a 5s-interval flow minted 15 duplicate reminders in ~70s, and even under a daily + cron a kernel rebuild re-dispatched the day's window. + + - `@objectstack/service-automation` — new platform object `sys_flow_dispatch`: a persisted + dispatch-claim ledger (ADR-0057 telemetry retention, 30 days), registered alongside + `sys_automation_run` and exposed as `AutomationEngine.claim(key): Promise` on + the automation service surface (check-and-record; a concurrent duplicate insert re-reads + and reports the key as already claimed). When no ObjectQL engine / registration is + available the engine degrades to in-process dedup and logs the weakened guarantee once; + when the ledger errors, the claim falls back to the in-process check for that key so a + store outage never blocks a dispatch (availability over strict-once). + - `@objectstack/trigger-schedule` — the time-relative sweep computes a dispatch key from + the MATCHED WINDOW's identity and claims it before launching: offset mode keys on + `(flowName, recordId, windowDay, offset)` — so a dateField edit that moves the window + legitimately re-fires — and range mode keys on `(flowName, recordId, sweepDay, + rangeSpec)`, preserving the documented `withinDays` semantic ("fires every day the + record stays in range") while never firing twice in one day. The trigger resolves the + claim surface structurally from the automation service; without one it dedups + in-process and warns once. + - `@objectstack/spec` — `sys_flow_dispatch` added to `PLATFORM_OBJECTS_BY_PACKAGE` under + `service-automation` (registry conformance). + +### Patch Changes + +- 6ceaa4b: docs: name packages that exist in seven published documents, and gate the class (#10893) + + A published README ships inside the npm tarball, so an install instruction in one + reaches every reader of the package. Nine `@objectstack/` names across seven + published documents named a package that is in **no directory of this repo**, and + five of those sat on `import` lines inside runnable fences. + + `check:published-readme-exports` could not see any of it, by construction. It + resolves a documented import against the package's built type surface through the + workspace member map, so a specifier that is not a member has no type entry to + compare against and the gate reads no further — strict about a member that exists, + silent about one that does not. The gate now makes the member-existence claim + first: an `@objectstack/`-scoped specifier that names no workspace member is a + finding, and the run header prints the scoped population as `N/N` so a recogniser + that stops matching shows up as a denominator that fell. + + What each dead claim now says, and why: + + - **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`** + each misnamed **themselves**. Both READMEs — including their `# ` titles and + every fenced import — said `@objectstack/plugin-trigger-…`, a name that has + never been published. The exported class names (`ScheduleTriggerPlugin`, + `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all + along; only the package name was wrong, so this is a rename pinned by each + package's own `name` field. + - **`@objectstack/plugin-security`** told readers to `install + @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No + such package exists. The organization wall ships as the enterprise + `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the + `org-scoping` service this plugin probes — the name `objectstack serve` and + `objectstack doctor` both print. Asking for the wall without it is a refusal to + boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The + tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the + cloud control-plane runtime from the separate `cloud` repository and not where + the wall comes from either. + - **`@objectstack/service-package`** described packages being "delivered to + runtime kernels that load them through `@objectstack/service-marketplace`". That + package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future + work. The loading half that does exist here is + `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`. + - **`@objectstack/embedder-openai`** had a fenced example importing + `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape, + because a reader pastes it. No knowledge adapter in this repository consumes an + `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder + option, and the adapters the contract is written for are not here. The example + is now the `embed()` surface that does exist, with the gap stated rather than + papered over with a substitute package name. + - **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against + `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has + ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite + through Knex, choosing the client from its optional peers. + - **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code + generators to write `import { User } from '@objectstack/protocol'`. The package + is `@objectstack/spec`, which the same sentence names as the path being + replaced. + + Four `@objectstack/` names that are **not** in this repo are deliberately left as + they are, because prose may name a package this repo does not build and a runnable + import may not: `@objectstack/security-enterprise` (the enterprise edition, whose + install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant` + (the cloud runtime), `@objectstack/framework` (the umbrella install name), and the + two names `service-datasource`'s README recalls as its own past. +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + - @objectstack/core@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index d2058cd3bd..3909564c30 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.1.0", + "version": "17.2.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 4dd2e1436d..d53e0ea918 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,46 @@ # @objectstack/types +## 17.2.0 + +### Patch Changes + +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d2d112] +- Updated dependencies [5fa0d72] +- Updated dependencies [02b3b07] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [2306a76] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [5acb58d] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [a037f7c] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [6ceaa4b] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [923c424] +- Updated dependencies [1ec36b7] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/spec@17.2.0 + ## 17.1.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index b3164bb367..f5d6168cf5 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.1.0", + "version": "17.2.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 f34c319600..b2b3b680c5 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,123 @@ # @objectstack/verify +## 17.2.0 + +### Patch Changes + +- Updated dependencies [8f04d9a] +- Updated dependencies [4d7c564] +- Updated dependencies [6936d07] +- Updated dependencies [59eb04d] +- Updated dependencies [9f05b7d] +- Updated dependencies [7d483e1] +- Updated dependencies [530c1df] +- Updated dependencies [163a162] +- Updated dependencies [128684d] +- Updated dependencies [5337ef1] +- Updated dependencies [7d2d112] +- Updated dependencies [03bdd14] +- Updated dependencies [5fa0d72] +- Updated dependencies [7bf3fb7] +- Updated dependencies [02b3b07] +- Updated dependencies [2570ab0] +- Updated dependencies [5886ee6] +- Updated dependencies [bbe643c] +- Updated dependencies [e634ecf] +- Updated dependencies [95437e7] +- Updated dependencies [b20c8d2] +- Updated dependencies [f76fe42] +- Updated dependencies [4257e4e] +- Updated dependencies [3e26359] +- Updated dependencies [6ce58a7] +- Updated dependencies [d806081] +- Updated dependencies [d23e3a0] +- Updated dependencies [9a1ed7a] +- Updated dependencies [f3a8134] +- Updated dependencies [b03a880] +- Updated dependencies [914c413] +- Updated dependencies [55809a0] +- Updated dependencies [5b0af2b] +- Updated dependencies [5b39785] +- Updated dependencies [47cd3ec] +- Updated dependencies [52db1d1] +- Updated dependencies [5649efb] +- Updated dependencies [9d7d2de] +- Updated dependencies [795ea05] +- Updated dependencies [2306a76] +- Updated dependencies [26f3588] +- Updated dependencies [9e04c3e] +- Updated dependencies [67630c4] +- Updated dependencies [a40dcc1] +- Updated dependencies [def0d3e] +- Updated dependencies [8d0bb79] +- Updated dependencies [57e4571] +- Updated dependencies [112a8c6] +- Updated dependencies [13a3dca] +- Updated dependencies [5acb58d] +- Updated dependencies [a16ff50] +- Updated dependencies [e222a53] +- Updated dependencies [acb4dbc] +- Updated dependencies [2e3cf95] +- Updated dependencies [4c93387] +- Updated dependencies [d728325] +- Updated dependencies [504c8d5] +- Updated dependencies [a037f7c] +- Updated dependencies [c49007a] +- Updated dependencies [047ac86] +- Updated dependencies [3ee8ddf] +- Updated dependencies [16cef97] +- Updated dependencies [a79bd35] +- Updated dependencies [490879a] +- Updated dependencies [6ceaa4b] +- Updated dependencies [145ba75] +- Updated dependencies [15ea214] +- Updated dependencies [de19489] +- Updated dependencies [c684d00] +- Updated dependencies [d29e271] +- Updated dependencies [4389fe9] +- Updated dependencies [13a6cb4] +- Updated dependencies [9f483d9] +- Updated dependencies [923c424] +- Updated dependencies [b419135] +- Updated dependencies [88e32a8] +- Updated dependencies [0ab81d1] +- Updated dependencies [a24b7fa] +- Updated dependencies [1ec36b7] +- Updated dependencies [93304c2] +- Updated dependencies [bc400af] +- Updated dependencies [5f2e54c] +- Updated dependencies [189373b] +- Updated dependencies [af1636c] +- Updated dependencies [86a8ec9] +- Updated dependencies [d9353b9] +- Updated dependencies [35ad101] +- Updated dependencies [ceb33a9] +- Updated dependencies [dccbcec] +- Updated dependencies [6439f8b] +- Updated dependencies [73d9795] +- Updated dependencies [8012960] +- Updated dependencies [266654d] +- Updated dependencies [45204a5] +- Updated dependencies [9b0172d] +- Updated dependencies [24ba050] +- Updated dependencies [f399618] +- Updated dependencies [75e9301] + - @objectstack/platform-objects@17.2.0 + - @objectstack/plugin-auth@17.2.0 + - @objectstack/spec@17.2.0 + - @objectstack/objectql@17.2.0 + - @objectstack/runtime@17.2.0 + - @objectstack/plugin-security@17.2.0 + - @objectstack/service-analytics@17.2.0 + - @objectstack/service-automation@17.2.0 + - @objectstack/rest@17.2.0 + - @objectstack/service-datasource@17.2.0 + - @objectstack/plugin-hono-server@17.2.0 + - @objectstack/core@17.2.0 + - @objectstack/plugin-sharing@17.2.0 + - @objectstack/service-settings@17.2.0 + - @objectstack/types@17.2.0 + ## 17.1.0 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index ebec2774c6..92adc06cde 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.1.0", + "version": "17.2.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",