diff --git a/.changeset/12271-published-entry-no-auto-transpile.md b/.changeset/12271-published-entry-no-auto-transpile.md deleted file mode 100644 index 2834af4375..0000000000 --- a/.changeset/12271-published-entry-no-auto-transpile.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`bin/run.js` — the entry `os` / `objectstack` names — resolves its commands from `dist/` whatever an ambient `NODE_ENV` says, so an exported `NODE_ENV=development` no longer kills the CLI in a project whose tsconfig maps a package to TypeScript source (#12271). - -`@oclif/core` skips its TypeScript path lookup only when `isProd()` — a negated `['development', 'test'].includes(NODE_ENV)`. Under either value it resolved the CLI's **own** command modules from `src/` and registered tsx on the way, and tsx honours the tsconfig of the **current working directory**. An application that maps a CommonJS workspace package to its TypeScript source for *type* resolution — `"@objectstack/formula": ["../../packages/formula/src/index.ts"]` — therefore steered this CLI's *runtime* module graph into `.ts` files, after which Node's CommonJS resolver walked their extensionless siblings and found nothing: - -``` -[MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts: -Cannot find module './registry' -``` - -Measured at two example apps with `NODE_ENV` as the only variable: `os compile`, `os dev --compile --fresh`, `os serve --dev` and `os start` each exited 1 on that signature under `development`, and each compiled or booted cleanly under `production`. The app with no `paths` block was the only one unaffected. - -- **The fix is one declaration**: `settings.enableAutoTranspile = false`, checked by oclif ahead of `isProd()`. `bin/run.js` is the built entry and `bin/run-dev.js` is the source entry — a division `check:cli-test-child-env` already enforced on every test that spawns the CLI; the entry simply never asserted it about itself. -- ⛔ **Not a child-environment scrub.** `os serve --dev` and `os start` are top-level processes with no parent to scrub, and the casualty was the CLI's own command table rather than the user's config, so no per-spawn `NODE_ENV` handling could reach it. -- **`NODE_ENV=development objectstack start` works again** — the debugging mode `os start` has advertised in a comment all along, and did not deliver. -- ⚠️ **What it costs, measured**: the only thing oclif keeps its TypeScript lookup alive for in production is a **linked** plugin, so a `plugins link`ed TypeScript plugin would no longer be auto-transpiled through the published entry. That path is not reachable today — `@oclif/plugin-plugins` sits in `devDependencies` and oclif's core-plugin loader only matches names under `dependencies`, so `os plugins` is not a registered command (`os --help` lists 34 topics and none is `plugins`), which is what `content/docs/plugins/index.mdx` already documents. On an unbuilt checkout the entry now answers oclif's `command not found` under `development`/`test` exactly as it already did with `NODE_ENV` unset. diff --git a/.changeset/14656-declared-capability-absence-warn-once.md b/.changeset/14656-declared-capability-absence-warn-once.md deleted file mode 100644 index 5e87614f8b..0000000000 --- a/.changeset/14656-declared-capability-absence-warn-once.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/types": patch -"@objectstack/runtime": patch ---- - -A **declared capability absence** — a 5xx answered because the deployment did not install an optional service — is now reported **once per route per process at `warn`**, naming the missing service, instead of one `error` line per request. Every other 5xx keeps the per-request `error` line #14310 shipped. - -Measured before the change, on a stock showcase boot: `GET /api/v1/ai/*` (the cloud-only AI service's declared `501 NOT_IMPLEMENTED`) printed one `error`-level line per request, and Studio opens it unprompted. A deployment that is working exactly as configured was training the channel built to mean "an operator must look" into noise — which is the failure mode `--log-level`-watching operators learn as "skim the errors". - -- **What counts as an absence** is the envelope the door composed: a producer-declared 5xx (`declaresServerFault` — the repo's existing declared-5xx predicate) whose ADR-0112 `code` is `NOT_IMPLEMENTED` or `SERVICE_UNAVAILABLE`. Nothing is invented to recognise one; the code the producer already declared *is* the declaration. -- **The predicate is applied inside the shared funnel** (`logServerFault`, `@objectstack/types`), not at each door, so `sendError`'s nested-envelope exit and the runtime dispatcher read one answer by construction. A door cannot opt in, opt out, or drift. -- **The dedupe key is (route, process).** A restart reports again, and a second, different route reports on its own — deliberately not a global "first N", which is the shape that hides the second route. A door that supplies no route coordinates is demoted to `warn` but never suppressed: an un-keyed bucket is that same hiding shape. -- **A thrown 5xx keeps its `error` line even when it declared `501`.** The thrown exit hands the funnel the throw and no envelope `code`, so it is not recognised as an absence — fail-loud for the half that carries a stack. - -⛔ **No wire byte moves.** Status, `code`, `message` and body shape are unchanged at both doors; this changes a log level and a count. The response bytes are pinned in `packages/runtime/src/declared-capability-absence-warn-once.test.ts`, and that block runs green on the pre-change tree too, which is what makes it a before/after measurement rather than a claim. - -Operators who were alerting on `[5xx]` at `error` level for an uninstalled optional service will now see one `warn` line per route per process instead. The line says so in its own text: `(declared capability absence — reported once per route per process)`. diff --git a/.changeset/15052-search-fields-docblock-icontains.md b/.changeset/15052-search-fields-docblock-icontains.md deleted file mode 100644 index c68553cba1..0000000000 --- a/.changeset/15052-search-fields-docblock-icontains.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`search-fields.ts`'s module docblock says `$search` expands to an `$or` of `$icontains`, the operator the engine actually emits - -The docblock's ENGINE bullet claimed `@objectstack/objectql`'s -`expandSearchToFilter` expands a `$search` term into an `$or` of **`$contains`** -clauses. It has compiled to `$icontains` since objectstack#7641: -`packages/objectql/src/search-filter.ts:23` carries the ruling verbatim — *"The -case-insensitive operator is `$icontains`, NOT `$contains`. `$contains` is -contractually case-SENSITIVE (#4706 Q2 = A)"* — and both return paths of -`fieldClausesForTerm` (`:109`, `:111`) emit `$icontains`. - -**Why the distinction is worth a clause rather than a word swap.** `$contains` -is contractually case-SENSITIVE, so a reader who trusted the old sentence built -an ingress gate, a test or a driver **stricter** than the platform is — a false -refusal, not a leak. The corrected bullet now says that in one clause, so the -next reader of this module does not have to reconstruct it from two other -packages. - -⛔ No behaviour changes. This is a module docblock; the engine has been right -since #7641 and no accept set, authorable key or published behaviour moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and -this TSDoc is emitted into `dist/data/index.d.ts` and `dist/data/index.d.mts` — -measured on the built artifact, with the old spelling absent from all 216 built -files afterwards and the docblock's own neighbouring sentence present at 2 as -the lit control. `src/data/search-fields.ts` is not a `.zod.ts`, so it is not -shipped as source; the emitted declarations are the whole of its published -reach, and they change. - -The sibling INGRESS sentence two lines below — `@objectstack/metadata-protocol` -`findData` refusing a `$searchFields` override the resolved set does not admit -(#4254) — was measured on the same tip and is unchanged: `findData` still calls -`assertSearchFieldsAreSearchable`, which resolves through this module's own -`resolveSearchFieldResolution` rather than re-implementing the rule. diff --git a/.changeset/15110-retired-element-node-refusal.md b/.changeset/15110-retired-element-node-refusal.md deleted file mode 100644 index 76387aa72b..0000000000 --- a/.changeset/15110-retired-element-node-refusal.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `element:filter` and `element:form` are refused BY NAME at the node, and the typo suggester stops renaming authors into retired types (#15110) - -Two halves of one vocabulary defect, and only one of them is a narrowing. - -**BREAKING** — a bare `element:filter` / `element:form` component node no longer -parses. Both elements were retired whole at element grain (ADR-0049 -enforce-or-remove): no renderer for either ever shipped in objectui, framework -or cloud. Every authorable key became a `retiredKey` tombstone at the time, but -the node itself kept parsing, and each schema's own docblock recorded that as a -limitation rather than an intention: - -> A bare node with empty `properties` parses clean (the open `type` union -> accepts any string, so a node-level refusal is not expressible here) - -It is expressible one level up. Both names join -`RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them with -a located prescription — the same door already built for `user:profile`. - -``` -FROM PageComponentSchema.safeParse({ type: 'element:filter' }) - -> { success: true } // nothing renders it; the console - // drew the unknown-type panel - -TO PageComponentSchema.safeParse({ type: 'element:filter' }) - -> { success: false, - issues: [{ code: 'custom', path: ['type'], - params: { retiredComponentType: 'element:filter' }, - message: '`element:filter` was removed in @objectstack/spec 17 …' }] } -``` - -**The prescription is not new prose.** Each node message is the element-grain -TAIL of that element's own `retiredKey` tombstones with the `property ` -clause dropped, so the node door and the props door carry one text — pinned -byte-for-byte in `component.test.ts`. An author who writes `element:filter` is -told to delete the component and use a view's `userFilters` quick-filter bar or -the list toolbar's filter builder; an author who writes `element:form` is sent -to the object-bound `object-form` block. - -**What does NOT change.** The rows stay in `ComponentPropsMap` — deleting one -would demote a loud retirement to a silent skip on every reader that dispatches -on it — so both rows keep refusing each retired key with its own per-key -prescription, and `isKnownComponentType` still answers `true` for both. The open -string arm is untouched: `object-grid`, `mcp:connect-agent`, `custom.widget` and -every live `element:*` member parse exactly as before. The two D2 conversions -still strip the keys and still leave the node; what changes is that the node -they leave is now refused by name instead of sitting inert, and their prose says -so. - -**The other half is a plain bug fix, no accept set involved.** -`KNOWN_COMPONENT_TYPE_CANDIDATES` — the typo-suggestion pool behind the -`component-type-unknown` authoring rule — was derived from every known type, -retired ones included. Measured through the rule: - -``` -FROM type: 'element:fitler' -> hint: "Rename `element:fitler` → `element:filter`." -TO type: 'element:fitler' -> hint: "Use a declared component type from the standard - vocabulary, or … give it its own namespace …" -``` - -The tool was renaming an author INTO a retired element — a rename the parser -refuses. The pool is now the known set minus whatever the vocabulary retired, -derived from the retirement map rather than restated beside it, so a type -retired tomorrow leaves the pool the day it lands. Live spellings are -unaffected: `global:serch` still proposes `global:search`, `record:detials` -still proposes `record:details`, `element:butotn` still proposes -`element:button`. - -Also corrected: the vocabulary docblock described the `ComponentPropsMap` row -set as a superset of the enum by "exactly" the string-arm registrations plus the -two tombstoned elements — one member short since `user:profile` joined it. - - diff --git a/.changeset/15117-action-engine-delete-id-array.md b/.changeset/15117-action-engine-delete-id-array.md deleted file mode 100644 index 1e504d9e4e..0000000000 --- a/.changeset/15117-action-engine-delete-id-array.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `ActionEngineFacade.delete` declares the id ARRAY the runtime has always accepted, and says which convention is the contract (#15117) - -`delete(object, id: string)` declared one id. The runtime facade -(`buildActionEngineFacade` in `packages/runtime`) has accepted `string | string[]` -all along — normalising the argument and issuing one `ql.delete` per id — and -described that in a comment as a tolerance two handler suites happened to cause. -The declaration was simply behind the behaviour, and the one first-party suite on -the array form could only reach it by hand-rolling a private copy of the -interface (a copy that had already drifted on `find`). - -The slot is now `delete(object: string, idOrIds: string | string[])`, and the -member's doc comment states the contract instead of leaving it to be inferred -from a runtime comment two packages away: - -- **Both spellings are contract.** One row is `delete(object, id)`; a set is - `delete(object, ids)` — a handler holding a list does not have to unroll it - into a loop to stay on the contract. -- **The array form is a convenience over the same per-row path** — not a bulk or - atomic delete. There is no transaction around the set: a failure part-way - leaves the ids before it deleted. An empty array deletes nothing and resolves. - -Nothing is removed and nothing narrows: every existing single-id call still -type-checks, and no runtime behaviour changes — this release makes the published -type describe what was already being served. That makes it non-breaking, not a -patch: widening a published parameter is a purely additive widening of a public -surface, which takes at least `minor` whatever the commit type says. Handler authors who copied the -facade into a local context type to reach the array form can delete the copy and -annotate with `ActionHandlerContext` / `ActionHandler` from `@objectstack/spec/ui`. diff --git a/.changeset/15141-cluster-doc-pointer-site-urls.md b/.changeset/15141-cluster-doc-pointer-site-urls.md deleted file mode 100644 index a930ebd4c0..0000000000 --- a/.changeset/15141-cluster-doc-pointer-site-urls.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`EventMetadata.cluster` and `ServiceMetadata.cluster` cite the live docs page by SITE URL, not a dead filename - -Both `.describe()` strings pointed at `cluster-semantics.mdx`, a page that is no -longer in the tree — `apps/docs/redirects.mjs` has redirected -`/docs/concepts/cluster-semantics` to `/docs/kernel/cluster` since the page was -folded in. The section numbers still resolved, so nothing was broken for a -reader following a link; what was broken is retrieval by filename, which finds -nothing. - -These two strings are the published half. `gen:docs` copies them into -`content/docs/references/kernel/events-core.mdx` and `service-registry.mdx`, and -they also ship as JSON Schema `description` values under `packages/spec/json-schema/` -and as string literals in `packages/spec/dist/`. So the citation had to become -something a SITE reader can follow: - -``` -- See cluster-semantics.mdx §4. (a file that does not exist) -+ See /docs/kernel/cluster §4. (the address the redirect already resolves to) -``` - -⛔ Deliberately NOT the in-repo house style. Source comments elsewhere in the -tree cite `` `content/docs/kernel/cluster.mdx` §N `` — a repo path, correct for a -reader who has the repo checked out. Copying that convention into a `.describe()` -would tell a docs-site reader to open a `content/docs/...` file they do not -have, which is the same class of unfollowable reference pointed the other way. -There is no in-repo precedent to copy either way: these are the only two -`.describe()` strings in `packages/spec/src` that cite a docs page at all. - -The site URL is also redirect-independent — it is the redirect's own target, so -the reference survives the redirect being retired. - -No accept set moves and no authorable key is added or removed: the schemas, -their parse behaviour and their exported types are byte-identical apart from -these two description strings. The two regenerated reference pages carry the -same one-line change on three rows. diff --git a/.changeset/15295-serve-observability-mirror-comment.md b/.changeset/15295-serve-observability-mirror-comment.md deleted file mode 100644 index 7a55384552..0000000000 --- a/.changeset/15295-serve-observability-mirror-comment.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`serve.ts`'s observability knob block points at the cloud mirror in the house style, keeps the sync duty, and names the package that owns the list (#15295) - -The block above `buildServeObservability()` instructed the reader to *"keep the -two in sync"* with `apps/cloud/server/observability.ts` — a path that has not -existed in this repository since `apps/cloud` moved to `objectstack-ai/cloud` -(`git ls-tree origin/main -- apps/` returns exactly `apps/docs`, the positive -control that makes that a reading rather than a broken query). A reader was -being sent to a file they cannot open, with no hint that it lives in another -repository. - -**The duty is live, so it stays.** The cloud file still exists and still reads -these names as `process.env` lookups (measured on `objectstack-ai/cloud` and -recorded on #15295, with that file's own `process.env` hit count as the firing -control) — for every knob in the block except `OS_OTLP_FLUSH_MS`, which was -added on this side after that measurement and is therefore unverified rather -than mirrored. The comment states that boundary rather than a bare count, so a -reader counting six entries under a claim about five cannot be misled about -which of them the reading covers. Deleting the clause would have dropped a real -obligation whose failure mode is quiet: the two exporters drift and the cloud -host stops reading the variables an operator set. - -Three things change, all inside one comment block: - -- the path is re-spelled in this repo's settled style for a cloud-repo - reference — ``(`apps/cloud/server/observability.ts`, cloud repo)``, the form - at `packages/services/service-cluster/src/multi-node-gate-mount.ts:9`; -- the duty is narrowed to what its own words say — **names, not defaults**. - `OS_OBS_SERVICE_NAME` defaults to `objectstack` here and to - `objectstack-cloud` there *deliberately*, because two deployments are two - services; a future reader "tidying" that into one value would merge both - deployments into a single telemetry series. The comment now says so, which is - the point of writing it down rather than leaving it to be rediscovered; -- the canonical home for the variable list is named as - `@objectstack/observability` — the package **both** consumers already import - — instead of two consumers pointing at each other. That mutual pointing is - the decay mechanism itself, and it is still one-sided today: the cloud file - carries no reciprocal sentence, so nobody renaming a name over there is - prompted to come back here. - -⛔ No behaviour changes, and no observability code path was touched. No env var -is added, removed or renamed; no default moves. - -**This ships, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/cli`'s published `files[]` is -`["dist","README.md","CHANGELOG.md"]`, and this package builds with plain `tsc` -(no `removeComments`), so the block is emitted verbatim into the tarball — -measured on the rebuilt artifact: the new clause is present in -`dist/commands/serve.js` (1 occurrence, and the knob-list line as control -resolves to that one file), the old spelling is absent from all of `dist`, and -`dist/commands/serve.d.ts` carries 0 of it because the block sits above a -non-exported helper. So the published JS bytes move while the declaration -surface does not. diff --git a/.changeset/15437-validation-messages-migration-route.md b/.changeset/15437-validation-messages-migration-route.md deleted file mode 100644 index 40e60349cb..0000000000 --- a/.changeset/15437-validation-messages-migration-route.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `translation-validation-messages-removed` migration text names the object-scoped bundle key, not just the authored literal - -`validationMessages` was retired in 17.0.0 (#4667). The ADR-0087 conversion that -migrates it told an author to author the message on the rule -(`object.validations[].message`) and stopped there. Since 17.3.0 (#14381, -#14253) that message has a translation route — -`objects.._validations..message`, resolved on the write -path — and the sibling prescription ten metres away in the same package -(`TRANSLATION_KEY_GUIDANCE.validationMessages`, the text the strict door -returns) already names it. - -⛔ Nothing the old text said was false, and none of it is deleted. The defect is -**silence**: this is the *migration* text, read by exactly the population that -authored the retired key — the authors who wanted their rule messages -translated — and it steered them to a plain authored literal without mentioning -that the bundle key now exists. The literal advice stays; the route is added -after it. - -**Two texts in the file carried the narrow prescription, not one.** The -conversion's `summary` is the one the card named; the docblock above it asserted -that rule messages are *"not translated through a group"*, which would have sat -directly above the corrected summary. Both are completed. The docblock keeps its -17.0.0 sentence — still true of the retired key — and says what 17.3.0 changed, -including why the object-scoped group is not `validationMessages` returning (the -retired one was keyed by rule name at the top level, could not tell two objects' -rules apart, and had no reader). - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `packages/spec/src/conversions/registry.ts` is not a -`.zod.ts`, so it is not shipped as source — but two published paths move, -measured on the built tree rather than reasoned about: - -- `dist` is in `files[]`, and the new sentence is emitted into six built files - (`dist/index.js` / `.mjs`, `dist/shared/index.js` / `.mjs`, - `dist/browser/index.js` / `.mjs`); a negative control string scored 0 on the - same tree. An author running `os migrate meta --from 16` reads the changed - notice out of that runtime string. -- `spec-changes.json` is itself listed in `files[]`, and it carries the summary - twice. It is generated (`gen:spec-changes`), and `check:generated` caught it - stale — the conversion registry feeds two generated artifacts, not one. - -`docs/protocol-upgrade-guide.md` is the third, regenerated with -`gen:upgrade-guide` and verified by `check:upgrade-guide`; all three are -regenerated, never hand-edited. - -⛔ No behaviour changes. The conversion id, its `apply`, its accept set and its -fixture are untouched; no authorable key is added or removed. diff --git a/.changeset/15484-rest-log-declared-level-seam.md b/.changeset/15484-rest-log-declared-level-seam.md deleted file mode 100644 index 784144d4b2..0000000000 --- a/.changeset/15484-rest-log-declared-level-seam.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -`packages/rest`'s fault logging gains a **declared level seam**, `OS_REST_LOG`, with the **shipped default unchanged**. At the default — and an unset or unrecognised value *is* the default — a reported fault still prints the whole `Error`: message, `cause` chain and stack frames, exactly as before. ⛔ No wire byte moves, no published payload gains a key, and no existing log line changes shape. - -What is new is that the loud/quiet choice is now **declared and machine-read** instead of implicit in whether an author happened to pass `error` or `error.message`: - -- **`OS_REST_LOG`** accepts `debug` / `info` / `warn` / `error` / `silent` — deliberately the same vocabulary and the same `'info'` default as `@objectstack/objectql`'s `OS_REGISTRY_LOG`, so the two are one logging contract with two populations rather than a second ad-hoc environment variable. Documented for operators in this package's README. -- **`scripts/check-rest-log-declared.mjs`** enforces it: the seam is located by its environment read (never a hardcoded path), the vocabulary is read from `REST_LOG_LEVELS` rather than copied, the two seams' vocabularies are held equal, a harness declaration must name a level the seam actually recognises — an unrecognised one resolves to the default *silently* — and every inline vitest project must carry its own declaration, because a root-level `env` is inert for project runs. -- **The shipped default is gated, not just documented.** Lowering `REST_LOG_DEFAULT_LEVEL` to `error` or `silent` is a finding, because at those levels this package stops reporting faults it is the only reporter of. - -**Why the default does not move.** Measured on one green `packages/rest` run: 2,095 indented `at ` frame lines, 36.7% of captured output, 100% of them arriving through this one shim. They are not dead weight. When a 5xx is withheld from the client, the log is the only copy of the driver text, and that text lives on `error.cause` — printed only because a whole `Error` object, not a summary, reaches `console.error`. Four assertions across `rest-5xx-message-sanitization.test.ts` and `rest-expected-error-logging.test.ts` pin that by asserting the **identity** of the error that arrives, one of them carrying an explicit do-not-delete warning aimed at exactly this repair. - -Operators: nothing to do. A deployment that wants the REST layer quieter can now say so — `OS_REST_LOG=error` drops the warning half, `silent` drops both — but doing so discards diagnostics that have no second copy anywhere, and the README says so at the seam. diff --git a/.changeset/15556-subflow-parent-strand-on-decide.md b/.changeset/15556-subflow-parent-strand-on-decide.md deleted file mode 100644 index 9fc02c9c73..0000000000 --- a/.changeset/15556-subflow-parent-strand-on-decide.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/plugin-approvals": minor ---- - -An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A). - -**The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers. - -``` -FROM service.decide(requestId, { decision: 'approve' }, ctx) - -> { finalized: true, decision: 'approve', runId: '', resumed: true } - // identical to a healthy composition's answer — no caller can tell - -TO service.decide(requestId, { decision: 'approve' }, ctx) - -> { finalized: true, decision: 'approve', runId: '', resumed: true, - resumeError: "RESUME_FAILED: … its own flow run '' resumed, but the " + - "subflow parent above it — run '' — consumed its suspension " + - "and is now stranded: ", - resumeFailure: { code: 'RESUME_FAILED', runId: '', status: 'stranded', repairable: true } } -``` - -**Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history. - -**What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here. - -**New public surface — the reason for `minor` on both packages, not `patch`.** Getting the parent's strand from the engine to the approvals door without touching `packages/spec` or the wire-visible `AutomationResult` (which a raw REST `POST …/resume` also serves verbatim, so a field there would leak an undeclared key onto every subflow resume, not only an approvals-mediated one) needed a small new internal channel: - -- `@objectstack/service-automation`: `AutomationEngine` gains a new public method, `takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined` — read-once (deletes on read), populated only by `bubbleToParent`'s `'stranded'` exit. `SubflowParentStrand` is a new exported interface (`{ runId, repairable: true, error }`). -- `@objectstack/plugin-approvals`: `ApprovalResumeSurface` (already exported from the package entry) gains a matching optional member, `takeSubflowParentStrand?(childRunId): { runId, repairable, error } | undefined`. - -Both are additive and optional; nothing existing changes shape or behaviour. Neither reaches any wire payload — `AutomationResult`, the REST resume door's response, and every other published contract are byte-for-byte unchanged. diff --git a/.changeset/15970-recall-resume-failure.md b/.changeset/15970-recall-resume-failure.md deleted file mode 100644 index c3e9b48838..0000000000 --- a/.changeset/15970-recall-resume-failure.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -An approval `recall()` whose resume strands the run now tells the caller WHICH failure it was, in fields — `resumeFailure: { code, runId, status, repairable }` beside the prose `resumeError` — instead of one sentence a caller has to parse (#15970; the #16472 family ruling, decision batch #76, option A). - -**The shape.** A flow parks at an `approval` node; the reject branch's downstream node throws. The submitter recalls the request, which resumes the run down the `reject` edge — and that resume strands it. The withdrawal is durable and the call correctly does not throw, but the engine's own discriminator never reached the caller: `recall` resumes DIRECTLY rather than through `resumeRecordedOutcome`, and its `catch` kept `err.message` alone, discarding the `resumeStatus` (`AutomationResult.status: 'stranded'`) the error already carried one line before the result was built. `repairable` had a producer and, on this door, no consumer. - -``` -FROM service.recall(requestId, { actorId }, ctx) - -> { request: { status: 'recalled' }, runId, resumed: false, - resumeError: "resume of run '' failed: " } - // prose only — nothing says the run is still repairable - -TO service.recall(requestId, { actorId }, ctx) - -> { request: { status: 'recalled' }, runId, resumed: false, - resumeError: "resume of run '' failed: ", - resumeFailure: { code: 'RESUME_FAILED', runId: '', - status: 'stranded', repairable: true } } -``` - -**⛔ The no-throw stays, and that is the ruling's point.** The withdrawal and the record-lock release are the product of this call and they have already happened when the resume fails; making `recall` fail would be the wrong fix, not a stricter one. The door's `error` log line is untouched too, at the same level with the same context keys — the ruling left logging alone, and the report is a sibling of that line, not a replacement for it. - -**Two exits report, and the rest deliberately do not.** A report is stamped exactly where the engine's own verdict says `'stranded'`: this door's own resume stranding, and (the sibling half of #15556, whose producer landed one door over) a resume that SUCCEEDED while the subflow parent above it stranded — which answers `resumed: true` with the PARENT's `runId` on `resumeFailure`, exactly as `ApprovalRecallResult.resumed`'s docblock already declared. Every other exit answers as it always did, with no `resumeFailure` at all: a lost run's honest code is `RESUME_TARGET_LOST` and the tolerated duplicate's is `RESUME_IN_PROGRESS`, and this package's ADR-0112 ledger row admits exactly one code, so stamping `RESUME_FAILED` there would make the discriminator lie about which failure it was — the defect this fixes, one field over. Per the member's own docblock, an absent `resumeFailure` means no report was made, never that no run is stranded. - -**Additive only — no migration, and `patch` rather than `minor`.** `ApprovalRecallResult.resumeFailure` was already declared, exported and type-pinned in `@objectstack/spec` ahead of this card (`contracts/approval-service.ts`, `resume-failure-report.pin.test.ts`); this fix is the first producer that fills it. The delivered diff adds no exported symbol to `@objectstack/plugin-approvals` — nothing new is reachable from its published entry — and adds no key to a payload that did not already declare one. Nothing existing changes shape: a consumer that ignores unknown fields sees no difference, and one that reads `resumeFailure` can now branch on `repairable` and call `restoreConsumedSuspension` on the run the report names. diff --git a/.changeset/15989-file-family-column-step.md b/.changeset/15989-file-family-column-step.md deleted file mode 100644 index 22a24c0714..0000000000 --- a/.changeset/15989-file-family-column-step.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/objectql": minor -"@objectstack/platform-objects": minor -"@objectstack/spec": minor -"@objectstack/cli": minor ---- - -feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) - - - -**BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. - -Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. - -## The column step - -`os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: - -1. the migration's own gate (zero blocking rows); -2. **every** abort pre-check, across **every** planned column, before a single statement runs; -3. no refusals — a column the driver could not plan stops the columns it could. - -**PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. - -Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. - -### ⛔ The abort clause is NOT the one the ADR sketched - -The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. - -Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. - -## The kernel→driver supply seam - -`SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. - -⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. - -⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. - -## New published surface - -- `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. -- `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. -- `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. -- `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. - -## What does NOT change - -A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. - -`multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. diff --git a/.changeset/16175-schema-tree-freshness-stamp.md b/.changeset/16175-schema-tree-freshness-stamp.md deleted file mode 100644 index 127e6194b3..0000000000 --- a/.changeset/16175-schema-tree-freshness-stamp.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(devx): the json-schema tree's freshness rule can be answered — a generation stamp acquits a tree whose sources were re-checked-out unchanged (#16175) - -`scripts/check-regen-pending.mjs` exports three freshness predicates over the -same `newestMtime(artifact) < newestMtime(src)` comparison, and all three share -one blind spot: `git merge`, `git checkout` and `git worktree add` re-check-out a -source file with **identical bytes** and bump its mtime, the build that follows -correctly does not run (turbo's cache hashes content), and the rule then refuses -an artifact that is exactly current. - -Two of them were answered already — `distIsStale` by `dist/.build-input-hash-dts` -(#14985/#16176) and `bundlesAreStale` by `dist/.build-input-hash` (#16240). -`schemaTreeIsStale` was the third, and the one with **no evidence of any kind to -read**: nothing recorded which sources `packages/spec/json-schema/` came from. -Measured on a checkout whose `git status` was empty, after a bare -`touch packages/spec/src/data/query.zod.ts`: - -``` -pnpm --filter @objectstack/spec check:docs exit 1 - packages/spec/json-schema is older than packages/spec/src. -``` - -The only remedy on offer was a full `gen:schema` — minutes under a shared verify -lock — for a tree that needed nothing. The same command now exits 0 with no -rebuild, and a genuine source edit still refuses. - -**The evidence is new, because neither `dist/` stamp could stand in.** Both are -written at the END of the build, whereas `gen:schema` is its FIRST step and is -also run standalone and again by `check:authorable-surface` — so a `dist/` stamp -is evidence about `dist/`, and in the standalone case there would be none at all. -`build-schemas.ts` now writes `json-schema/.build-input-hash-schema` as the last -thing it does: one write point, after the unconditional whole-tree regeneration -that precedes its `--check` / `--update-base` fork, so all three entry points are -covered, and after every ratchet that can exit 1, so a refused run vouches for -nothing. - -**⛔ The digest may only ACQUIT, never accuse.** A missing, unreadable or -non-64-hex stamp is `unstamped` — no evidence — and leaves the mtime refusal -exactly where it stood (#4690). Nothing that passes today can start failing, and -the rule keeps its only conviction instrument: mtimes still see the hand-edited -tree and the toolchain change a content digest is blind to. - -**Why this ships, and why it is a changeset rather than `skip-changeset`.** -`json-schema` is in `@objectstack/spec`'s published `files[]`, so the new stamp -travels in the tarball — measured with `npm pack --dry-run`: -`json-schema/.build-input-hash-schema` is present alongside the two existing -`dist/` stamps. One 65-byte file is added to the published package. No export, no -schema key, no runtime behaviour and no authorable surface moves. - -**One other published-adjacent change**, for the same soundness reason: the build -digest (`scripts/build-input-hash.mjs`) now also hashes `/scripts/**` for -packages that have it. `packages/spec`'s generators live there and were in none of -the previous input sets, so an edited generator kept a digest that had not moved — -and a stamp written by the OLD generator would then acquit a tree the new one -emits differently. Widening a digest can only ever WITHHOLD an acquittal, never -grant one, so the two `dist/` stamps become strictly more honest as well; the -first build after this lands re-stamps all three. diff --git a/.changeset/16211-ai-slot-501-not-404.md b/.changeset/16211-ai-slot-501-not-404.md deleted file mode 100644 index 42ffcf8ae1..0000000000 --- a/.changeset/16211-ai-slot-501-not-404.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/client': patch ---- - -`client.ai`'s docblock says the AI slot answers 501, names the 401-first and `GET /ai/agents` arms, and stops promising a 404 - -The `ai` namespace docblock described the pre-`capabilityUnavailable` -behaviour: that this repo's dispatcher *"404s `AI service is not configured` -when the service is absent (the open-source default)"*. The dispatcher has -answered **501** since the shared exit landed. `/ai/*` is registered -**unconditionally** (`createAiDomain`, plus the host wildcard across four -methods in every branch of the scoping conditional), so a request reaches a -handler with nothing behind it — which is 501 Not Implemented, not 404. -`packages/runtime/src/domains/unavailable.ts` exists to draw exactly that line: -404 means *the route is not there*, and for `/ai/*` that is false. - -**Why the replacement is narrower than "`/ai/*` answers 501".** That sentence -is not true either, and a caller branching on status needs both exceptions. -Verified against the unserveable-slot branch in -`packages/runtime/src/domains/ai.ts`, in its own evaluation order: - -``` -FROM any /ai/* with no AI service -> 404 `AI service is not configured` - -TO anonymous caller -> 401 (ANONYMOUS_DENY_STATUS; the 501 and - the courtesy below are capability - disclosures, owed to nobody who has - not authenticated) - GET /ai/agents -> 200 { agents: [] } under the envelope's - `data` — a console polls it on every - navigation to decide whether to show - AI affordances - every other /ai/* route -> 501 serviceUnavailableMessage('ai') -``` - -All three arms are already test-pinned in -`domains/ai-anonymous-deny-ordering.test.ts` — this changeset moves no -behaviour, only the sentence describing it. - -**The `GET /ai/agents` courtesy was mentioned nowhere in this docblock**, which -is the one an SDK reader actually opens, so it is added rather than merely -corrected. Also stated now: the 501 body is not a local string — it comes from -the shared `serviceUnavailableMessage`, the same sentence -`discovery.services.ai` reports for the slot, so the two cannot drift into -naming different remedies. - -⛔ No behaviour changes. This is a docblock; no export, authorable key, accept -set or response byte moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/client`'s published `files[]` ships `dist`, -and this TSDoc is emitted into all four built artifacts — `dist/index.d.ts`, -`dist/index.d.mts`, `dist/index.js` and `dist/index.mjs` — measured on the -built tree, with the stale `AI service is not configured` sentence absent from -every built file afterwards and the docblock's own neighbouring sentence -present as the lit control. The declarations are what a consumer's editor shows -on hover and what an upgrading agent greps, and they change. - -The two sibling corrections in the same change do **not** publish and are not -named here: `packages/runtime/src/route-ledger.ts` is a CI-audit ledger that is -not exported from the runtime entry (`ROUTE_LEDGER` is absent from -`packages/runtime/dist` entirely), and the `domains/ai.ts` implementation -comment is not emitted — three pre-existing comments from that same file were -probed as controls and none appears in the built output. diff --git a/.changeset/16236-formula-return-type-measure-column.md b/.changeset/16236-formula-return-type-measure-column.md deleted file mode 100644 index 62167c4e6e..0000000000 --- a/.changeset/16236-formula-return-type-measure-column.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics): a `min`/`max` over a `formula` field is typed from the formula's declared `returnType`, not described as `number` (#16236) - -> ⚠️ **Superseded within the same release window — ⛔ do not act on this entry.** -> Everything below was accurate when it was written and is kept as the record of what -> #16236 measured and built. It never reached a published version: **#17560** (director -> ruling, decision batch #127, 2026-09-13) refuses `min` / `max` over a `formula` field -> outright, on the compatibility table's own storage ground — a formula is VIRTUAL in SQL -> storage, no column is emitted, so no aggregate can be lowered to it whatever -> `returnType` says. At the version that compiles this entry such a measure answers -> `DATASET_INVALID` / **400** at compile time instead of carrying any `fields[].type`, and -> the `returnType?: string` member described at the foot of this entry is **not** on -> `AnalyticsServiceConfig.sourceFieldMeta` — it was added and removed inside one release -> window, so no published version ever carried it. ⇒ Read #17560's entry instead; the -> FROM → TO below never became a shipped behaviour. - -**Behaviour change — read this if any dataset measure aggregates a `formula` -field.** `AnalyticsResult.fields[].type` for such a measure column was always -`number`, whatever the formula computes. It is now translated from the field's -declared `FieldSchema.returnType`: - -``` -FROM {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], - "fields":[{"name":"first_label","type":"number"}, - {"name":"latest_due","type":"number"}]} - -TO {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], - "fields":[{"name":"first_label","type":"string"}, - {"name":"latest_due","type":"time"}]} -``` - -Both values were strings; both descriptors said `number`, so a renderer that -branches on the declared type never reached its textual or temporal branch. - -**The mapping is a TRANSLATION, not a pass-through.** `returnType` speaks the -authoring vocabulary (`number` / `text` / `boolean` / `date`); -`fields[].type` speaks `DimensionType` (`string` / `number` / `boolean` / -`time` / `geo`). Two of the four words do not exist on the wire at all: - -| declared `returnType` | `fields[].type` | -|:---|:---| -| `text` | `string` | -| `date` | `time` | -| `number` | unchanged — the producer's `number` is already correct | -| `boolean` | unchanged — three readings disagree on what `min`/`max` over a boolean returns | - -**A formula with no `returnType` is unchanged.** The key is optional — "absent -when the type can't be proven (an ambiguous/`dyn` expression)" — and an -unproven formula's measure column keeps the `number` it had. The absence is not -read as an answer. That tier is written down as a row in `measureResultType`'s -own table rather than left as an implied code path, and so is the treatment of -a word outside the declared four: left alone, never guessed at. - -**For hosts wiring `AnalyticsService` directly.** `AnalyticsServiceConfig`'s -`sourceFieldMeta` hook gains an optional fourth member on its return — -`returnType?: string` beside `type` / `defaultCurrency` / `max`. Additive: a -host that returns the three-member shape still satisfies the contract and gets -exactly today's behaviour for every column. `AnalyticsServicePlugin` relays the -key automatically, so a host on the plugin needs no change at all. - -⚠️ **Superseded — see the banner at the top.** #17560 removed that member again in -the same release window, so the shape a host writes against is the three-member one -this paragraph calls today's. Nothing to do either way: a host that returns the -fourth key is ignored, not refused. diff --git a/.changeset/16270-org-record-tab-strip-provenance.md b/.changeset/16270-org-record-tab-strip-provenance.md deleted file mode 100644 index fb3158e8cb..0000000000 --- a/.changeset/16270-org-record-tab-strip-provenance.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Name where the organization record page's Members / Invitations / Teams tab strip is declared, at the three places that assert it (#16270) - -#16270 measured that no object under `packages/platform-objects/src/identity/` declares -the `Field.relatedList` prominence key, and inferred from that a two-way disjunction: -either the metadata is short three `relatedList: 'primary'` declarations, or the three -documents that describe the page as opening on tab-0 **Members** have gone stale. - -**Neither. The premise is false.** The tab strip is declared metadata — -`SysOrganizationDetailPage` in `packages/platform-objects/src/pages/sys-organization.page.ts`, -a `kind: 'slotted'` record page for `sys_organization`, `isDefault: true`, handed to the -runtime by plugin-auth's `pages: [SysOrganizationDetailPage, SysUserDetailPage]`. Its -`slots.tabs` override carries exactly three `record:related_list` tabs — Members, -Invitations, Teams, in that order — and objectui's synthesizer pushes that authored node -and never calls `buildDefaultTabs`, so the strip replaces the synthesized -Details + stacked `Related` one outright and Members really is at index 0. That file was -already in the tree at the commit the card measured. - -`relatedList: 'primary'` is a different mechanism (prominence on a child's lookup field, -promoting one derived list to its own tab). The card looked for that key, correctly found -none, and read the zero as "declared by no metadata". While the `tabs` slot is present, -adding the key would not move this page at all. - -**What changes here is prose only — no metadata, no behaviour.** The two source comments -that assert the tab order and the QA checklist item that grades it now name the page that -declares it, so the next reader does not repeat the measurement: - -- `packages/platform-objects/src/identity/sys-member.object.ts` — the `invite_user` - mirror's rationale -- `packages/platform-objects/src/identity/invite-entry-toolbar.test.ts` — the file header - that states the whole pin's premise -- `docs/qa/platform-checklist/areas/identity-auth.json` — - `identity-auth.org-membership-team-management`, a new `source` entry plus the revision - and history bump its ledger requires. Steps, acceptance clauses, oracles and negatives - are unchanged: a grader grades exactly what it graded before, and now knows that a - Details + stacked `Related` strip means this page failed to load rather than that the - clause was wrong. - -This package ships its `src` comments in `dist` (measured: the new comment text appears -4 times under `packages/platform-objects/dist`, with an exported symbol as the positive -control and the test-file header absent at 0), which is why a comment-only diff here -takes a changeset rather than the publishes-nothing exemption. diff --git a/.changeset/16274-initial-completion-history-guard.md b/.changeset/16274-initial-completion-history-guard.md deleted file mode 100644 index 874bf9769a..0000000000 --- a/.changeset/16274-initial-completion-history-guard.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A run whose nodes all succeeded is no longer answered `failed` — or, under `errorHandling.strategy: 'retry'`, RE-EXECUTED — because its terminal run-history write threw (#16274) - -`AutomationEngine.execute()` and `executeWithoutRetry()` each called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. This is the initial-execution half of the pattern fixed on the resume path in 17.4.0; that fix deliberately scoped these two sites out. - -**The consequence was measured, and it is a double run, not just a mislabelled one.** `execute()`'s node-failure arm ends at the retry strategy branch, which hands the false `failed` result to the retry loop; the loop reads `result.success` and therefore re-enters `executeWithoutRetry()` — the whole flow, every node, again. Driven with `maxRetries: 2`: a flow whose node always succeeded ran it **three** times and wrote three `failed` rows, unattended, inside one `execute()` call, with the node's side effects repeated each time. Controls on the same instrument: the identical flow on healthy sinks runs the node once, and a genuine node failure runs it three times (retry working correctly). - -**What can throw there is a host surface, not in-repo code** — which is why it could not be reproduced from inside the package and why the package owed the fix: - -- the run-summary line `logger.info(line, meta)`, on by default (`runSummaryLog: 'info'`) and calling a **host-injected** `Logger`. This one needs no store at all. -- `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise — the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection. Both stores shipped in this package are `async` methods and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. (A store returning a non-thenable escapes identically: `write.catch` is then itself a synchronous `TypeError`.) - -On that second variant the old code did not even answer `failed`: the node-failure arm's own `recordLog({ status: 'failed' })` threw again out of the same store and escaped `execute()` entirely — a rejected promise where `AutomationResult` is declared. - -What changes: - -- **Each completion-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller is told the truth — `success: true`, no `status`, the flow's `successMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first — the node runs exactly once, and one `completed` row is recorded rather than `1 + maxRetries` `failed` ones. -- **The swallowed failure is reported once per run at `error`**, with the consequence and the fix in the first line: the run completed, its terminal history row never landed, nothing retries it, and the run must not be re-run. The thrown text rides the structured slot. - -⛔ No `catch` arm's meaning is widened: a genuine node failure still reaches the node-failure arm, is still recorded `failed`, still carries the node's own text, and is still retried the full `1 + maxRetries` times. diff --git a/.changeset/16310-orphan-locale-key-gates.md b/.changeset/16310-orphan-locale-key-gates.md deleted file mode 100644 index 106ea55eea..0000000000 --- a/.changeset/16310-orphan-locale-key-gates.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: an orphaned locale key now FAILS the run — `translation-target-unknown` is an `error` (#16310) - -`validate-translation-references` reported every orphan translation key precisely -— the id named, the locale named, the remedy printed — and failed nothing. -`os lint` exits 0 on warnings, the rule hard-coded `severity: 'warning'`, and no -per-rule severity is configurable by a consuming app. So a PR that deletes a -navigation entry, a form section or a view and leaves its locale keys behind was -green on every pipeline on the platform, and the dead keys are actively -misleading afterwards: grepping the id returns a confident-looking hit in every -locale, which reads as "this exists and is translated". - -The forward half of this parity — `i18n/missing-*`, an authored surface with no -translation — already fails, and apps already gate on it. The orphan half now -fails too, so the two halves of one parity have the same enforceability instead -of opposite ones. - -**BREAKING** — a stack carrying an orphan locale key stops passing `os lint`, -`os validate` and `os build`. Measured on one stack with 8 orphan keys planted, -`objectstack lint --json`: - -| `@objectstack/lint` | findings | errors | warnings | `passed` | exit | -| :-- | --: | --: | --: | :-- | --: | -| before this release | 20 | 0 | 18 | `true` | 0 | -| after this release | 20 | 8 | 10 | `false` | 1 | - -The findings themselves are unchanged — same count, same paths, same message and -hint text. Only the severity moves, and with it the exit code. - -**What an author does about it.** In a clean stack, nothing: a tree with no -orphan key reports exactly what it reported before, at the same severities, with -the same exit code (measured — the report is identical field for field apart -from its wall-clock `duration`). In a stack the rule already names findings on, -delete each locale key it names. The key resolves to nothing — the object, -field, view, section, tab, action, param, app, nav item, dashboard, widget or -flow screen it was written for is not in the stack — so removing it changes no -rendered string in any locale. Where the target was renamed rather than removed, -key the translation to the new name instead; the finding prints the declared -names to choose from. - -**This is ONE rule, not "warnings are errors now".** Measured on a planted tree -carrying findings from 13 distinct rules: exactly 1 changed severity, 12 did not, -and the finding set is identical modulo that one severity. -`translation-option-key-unknown` — raised by the same function — stays `warning` -on purpose: a mis-keyed option translation names something real and its remedy is -a rename, not a deletion. `validateTranslatableSections`, the sibling asking "is -there a key at all?", is untouched. - -**Unchanged: the runtime publish gate.** `validateTranslationReferences` reaches -the runtime door on a `flow` write, but the per-write snapshot carries only -`objects` / `permissions` / `books` / `datasets` — `RuntimeStackContext` has no -`translations` member for a host to fill — so the rule sees no bundle and returns -nothing there. Measured: a flow write through `runRuntimeAuthoringRules` yields -0 errors and 0 advisories from this rule. No publish that used to succeed is -refused. - -`TranslationRefSeverity` widens from `'warning'` to `'warning' | 'error'` -accordingly. - - diff --git a/.changeset/16384-auth-base-path-single-definition.md b/.changeset/16384-auth-base-path-single-definition.md deleted file mode 100644 index 201fac462e..0000000000 --- a/.changeset/16384-auth-base-path-single-definition.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -fix(plugin-auth): give the auth `basePath` default a single written definition (#16384) - -`'/api/v1/auth'`, the shipped default for `AuthPlugin`'s `basePath` option, was -written independently at four sites: the `AuthPlugin` constructor, two later -re-derivations inside `AuthPlugin` (`registerAuthRoutes`, the OIDC discovery -`.well-known` alias), and `AuthManager.configuredBasePath()`'s own fallback. -Nothing was broken by the duplication — `AuthPlugin` always supplies `basePath` -to `AuthManager`, so the manager's copy was dead on the live path and -unfalsifiable by construction: no test could have caught one copy drifting from -the other three. - -The default now lives in exactly one place, `DEFAULT_AUTH_BASE_PATH` (exported -from `@objectstack/plugin-auth`, declared beside `readMcpServerEnabledEnv` in -`auth-manager.ts`); all four sites import it instead of retyping the literal. -Every site evaluates byte-identically to before — this is a consolidation of -where the value is *written*, not a change to what any site *evaluates to*, and -in particular does **not** touch `AuthManager`'s `configuredBasePath` → -`rootedBasePath` → `getBasePath` normalisation chain (#16399) or the published -OAuth `iss` / RFC 8707 `aud` identifiers those getters produce. - -This is additive and non-breaking — no existing call site's behaviour changes — -but it does add one new named export (`DEFAULT_AUTH_BASE_PATH`) to the -package's public surface, which is what makes this `minor` rather than `patch`. diff --git a/.changeset/16678-admin-set-user-manager.md b/.changeset/16678-admin-set-user-manager.md deleted file mode 100644 index 6f903e4dcd..0000000000 --- a/.changeset/16678-admin-set-user-manager.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/lint': patch ---- - -`sys_user.manager_id` gains an admin write surface: `POST /api/v1/auth/admin/set-user-manager` - -`{ type: 'manager' }` is the canonical first rung of a tiered approval ladder, -and it resolves `sys_user.manager_id` — a column **no product surface could -write**. Measured: the generic data path refuses it (the ADR-0092 D2 -managed-update whitelist for `sys_user` is `{name, image, locale}`), the admin -bulk import does not carry it (`admin-import-users.ts` matches `manager_id` 0 -times, against a control of `phone_number` 8), and the column is `readonly` on -the user form. So on any install without a directory sync the rung expanded to -nobody, the request opened on a slate no one could act on, and under the -default `lockRecord: true` the record stayed locked. - -**The endpoint.** A platform admin posts `{ userId, managerId }`; `managerId: -null` clears the link. It is an ObjectStack mount on the raw app ahead of the -better-auth catch-all — the same family as `POST /api/v1/auth/admin/unlock-user` -— platform-admin gated (ADR-0068) and ledgered in `auth-route-ledger.ts`. - -**It is not a new editable profile column, and that is the design.** The -handler runs under a **system context**, so it reaches the column by context -rather than by a whitelist entry — the same way `admin-import-users` already -reaches `phone_number` and `role`. `SYS_USER_PROFILE_EDIT_FIELDS` is -untouched, `MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user` stays `{locale}`, and -`sys_user.manager_id` keeps `readonly: true`, so ADR-0092 D4 still holds by -construction. Since ADR-0092 D5's amendment made Tier-1 membership imply -self-editability, admitting the column to Tier 1 would have handed every member -their own first-rung approver and a widening of their own `own_and_reports` -read scope; it is not admitted. - -**Five refusals, every one enforced at the write** — the only manager-chain -walkers in the open tree are single-hop, so nothing downstream catches a bad -link: self-assignment; a link that closes a cycle (the walk is itself -cycle-safe, so a pre-existing loop is reported rather than hung on); a chain -past the depth cap that ADR-0057 D3's bounded rollups require; a manager -provably outside every organization the user belongs to (beside, not instead -of, the existing routing-time screen); and any identity whose `sys_user.source` -is `idp_provisioned`, where the directory stays the one authoring surface. - -**`@objectstack/lint`** keeps the `approval-approvers-may-resolve-empty` -advisory and its `stackWiresManagerChain` silencer — the dead end it reports -survives the write surface, because a static check still cannot read the -column; only its *cause* became recoverable. What changed is the remedy text, -which named a column with no route and now names the endpoint, its body, how to -clear the link, and what it refuses. The Approvals guide carries the same -rewrite in prose. - -**Why `patch` and not `minor`.** No new exported symbol is reachable from -either published entry: `admin-set-user-manager.ts` is deliberately not -re-exported from `plugin-auth/src/index.ts` and is not named in the package's -`exports` map, so none of `runSetUserManager`, `MAX_MANAGER_CHAIN_DEPTH`, -`SetUserManagerDeps`, `SetUserManagerEngine`, `SetUserManagerResult` or -`SetUserManagerRefusalReason` appears in the built `dist/index.d.ts`. No -already-published payload gains a key — the endpoint's response is a new -payload, not a new field on an old one. A new **route** is wire, and wire -compatibility is not the grading floor. diff --git a/.changeset/16746-connect-agent-account-nav.md b/.changeset/16746-connect-agent-account-nav.md deleted file mode 100644 index f0808a8d62..0000000000 --- a/.changeset/16746-connect-agent-account-nav.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -Connect an Agent is reachable from the Account app, so a non-admin can mint their own key - -`POST /api/v1/keys` mints a `sys_api_key` bound to the **caller**, and the -Connect-an-Agent page says the key "acts as you". But the page's only navigation -entry sat in the Setup app, which declares `requiredPermissions: -['setup.access']` — so every non-admin following the shipped two-step guide, and -every reader of the runtime's own error text (`packages/mcp/src/plugin.ts`: -*"mint an API key (Setup → Connect an Agent, or POST /api/v1/keys)"*, and -`README.md`), stopped at step 1 while the endpoint behind the button had accepted -them all along. Measured before: a principal with no system permissions gets -`403 PERMISSION_DENIED` on `GET /api/v1/meta/apps/setup` and `nav_connect_agent` -is absent from the wire. - -`CONNECT_AGENT_UI_BUNDLE` now carries a **second** `navigationContributions` -entry, targeting the `account` app's `grp_account_developer` group beside the -`nav_account_api_keys` entry already shipping there. Measured after, over the -real composition (real `SETUP_APP` / `ACCOUNT_APP` / `SETUP_NAV_CONTRIBUTIONS`, -the real fold and the real RBAC-by-route filter): the same permissionless -principal gets `200` on `GET /api/v1/meta/apps/account` with -`grp_account_developer` carrying `['nav_account_api_keys', -'nav_account_oauth_apps', 'nav_connect_agent']`, while `apps/setup` still -answers `403 PERMISSION_DENIED` with `connect_agent` absent from that body. - -**Nothing else moves.** No backend change, no authorization change, no change to -which permissions exist, and the published "acts as you" promise is unchanged — -it simply becomes keepable for the users it was written for. The Setup entry -stays exactly as it was, so admins keep the page where the guide points, and no -gate is added or removed anywhere: a navigation contribution registers exactly -when the page registers, so an opted-out deployment -(`OS_MCP_SERVER_ENABLED=false`) still gets no page and neither entry. - -⛔ Ungating Setup was **not** the fix, and was measured rather than assumed: the -app-level `setup.access` gate fires before the group gate, so dropping the group -gate alone changes nothing, and dropping both serves 14+ unrelated Setup -surfaces (Users, Organization, Business Units, Branding, Feature Flags, …) to -every signed-in user. ⛔ Nor was a `requiresService: 'mcp'` gate on an -`account.app.ts` entry: the `mcp` service registers unconditionally in `init()` -while this bundle registers behind `isMcpServerEnabled()`, so such an entry -would outlive its page and 404 for every signed-in user on an opted-out -deployment. - -Both entries deliberately share the item id `nav_connect_agent` — one -destination, one identity. That is scoped, not a collision: `SchemaRegistry` -keys contributions by target app and `applyNavContributions(app)` consults only -that app's bucket, so a nav item id is unique within one app's navigation tree, -and the translation bundles are keyed `apps..navigation.`. diff --git a/.changeset/16786-scoped-updatebyid-answer.md b/.changeset/16786-scoped-updatebyid-answer.md deleted file mode 100644 index d138614bf8..0000000000 --- a/.changeset/16786-scoped-updatebyid-answer.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `IScopedObjectRepository.updateById` declares its answer — the record or `null`, not `any` (#16786) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, and PR #17255 for this card's `objectql` half, which was re-graded from `patch` to `minor` mid-round for exactly this reason). - -`updateById(id, data)` declared `Promise` — the last wide member of a contract whose siblings answer what they mean. It now declares `Promise | null>`: the written record, or `null` when the id matched nothing. - -The declaration is what every layer under it already says, measured rather than inherited: - -- the engine door it forwards to, `IDataEngine.update`, declares `Promise | number | null>`; -- that door's by-id exit calls `IDataDriver.update(object, id, data)`, which declares exactly `Promise | null>`; -- `packages/objectql`'s `ObjectRepository.updateById` declared `Promise` to MATCH this member rather than independently of it, and PR #17255 said so in its own docblock when it deliberately left this half open. - -The `number` limb `update` carries — the affected-row COUNT a predicate write resolves — is **not** declared here, and that is a measurement too: the implementation binds both the payload id and a pure-id `where` and never declares `multi`, so the shared update dispatch answers `by-id` for every call this signature admits. A falsy id (`0`, `''`) is a REFUSAL, not a `null`: it identifies no row, so the dispatch rejects and the call throws. - -Ruling A on #16231 settled the rule — #15823's `find()` narrowing extends to the sibling doors — and enumerated `scoped-context.ts:148` / `:164`, not this member. It is narrowed because the measurement says the declaration was wider than every implementation and wider than the door it forwards to, ⛔ not because a ruling named it. - -A hook or service that assigned the result into a record slot, or read a field off it, through an `IScopedObjectRepository`-typed door now separates the `null` arm first. No runtime behaviour changes. The in-repo census through the interface-typed door is the contract's own suites, which already answer the narrow shape. - - diff --git a/.changeset/16804-dev-https-cert-key.md b/.changeset/16804-dev-https-cert-key.md deleted file mode 100644 index 611a265333..0000000000 --- a/.changeset/16804-dev-https-cert-key.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/plugin-hono-server': minor ---- - -feat(cli): `objectstack dev --cert --key ` terminates TLS in the dev process, and the canonical origin follows the listener (#16804) - -An interactive MCP client refuses to start an OAuth sign-in against a non-TLS -URL, so the self-serve identity path the product advertises — "interactive -clients just open a browser login" — could not be exercised against a local dev -server at all. The only way round it was a hand-built https reverse proxy plus -`OS_AUTH_URL`, a page of setup that every developer, demo and video recording -repeated off-camera. - -**Bring your own certificate.** Nothing here generates one, and nothing here — -not the code, not `--help`, not any doc page — says anything about installing a -certificate into a system trust store. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何 -「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. The trust store is the -developer's own business; this feature's whole job is to *use* the certificate -they already have. - -```bash -objectstack dev --cert ./localhost.pem --key ./localhost-key.pem -``` - -Both flags are required together — half a pair is refused by name — and an -unreadable file is refused rather than degraded to a plain-http listener. - -**What follows the listener.** With both flags given, everything this boot -advertises is `https://localhost:`: the two `/.well-known/*` discovery -documents, the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the -`🤖 MCP server` connect hint, and the runtime state file the `os dev` parent and -external supervisors dial. Only the built-in default at the end of the base-URL -chain moves — `OS_AUTH_URL`, `BETTER_AUTH_URL` and `OS_BASE_URL` keep winning, -an `http://` value included, because they name where a deployment is *reached* -rather than what this process *bound*. - -**Without the flags nothing changes**, byte for byte — pinned by ablation legs -rather than asserted. - -`@objectstack/plugin-hono-server` gains the option this is built on: -`HonoPluginOptions.tls` (`{ cert, key }` PEM bytes) makes the adapter bind a TLS -listener with the same fetch handler, the same route table and the same graceful -drain. Absent, the listener is plain http exactly as before. diff --git a/.changeset/16870-scope-beside-superuser-bit-refused.md b/.changeset/16870-scope-beside-superuser-bit-refused.md deleted file mode 100644 index 63bcfa97d2..0000000000 --- a/.changeset/16870-scope-beside-superuser-bit-refused.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): an object permission that declares a depth axis beside the super-user bit which short-circuits it is now REFUSED, instead of being stored and counted as coverage (#16870) - -**BREAKING** — `ObjectPermissionSchema` no longer accepts a `readScope` beside -`viewAllRecords: true`. Two sibling shapes are refused with it, read off the -same resolver lines rather than guessed at. - -The pair was accepted with **zero diagnostics**, materialised into -`sys_permission_set.object_permissions`, and counted by a capability census -reading the deployed shape as coverage — while the read stayed org-wide. -`PermissionEvaluator.getEffectiveScope` answers `org` on the super-user bit -**before** it consults the depth key, and `getDeclaredScope` (the ADR-0090 D10 -delegated-path input) carries the identical short-circuit ahead of the identical -read, so the declared narrowing was dropped from the delegation fold as well. - -⇒ the author declared a narrowing, the platform stored it, an audit of the -deployed shape reported the capability as exercised, and the read was still -org-wide. That is ADR-0049 `declared ≠ enforced` at the capability container -itself, and the accept set is the only door that stops the declaration from -being STORED: a diagnostic raised later fires after the shape is already there. - -``` -FROM ObjectPermissionSchema.parse({ allowRead: true, viewAllRecords: true, - readScope: 'own_and_reports' }) - -> { …, viewAllRecords: true, readScope: 'own_and_reports' } // stored, unread - -TO -> ZodError, located at ['readScope']: - "readScope: 'own_and_reports' is declared beside viewAllRecords: true, - which already grants org-wide read. … Delete readScope if the org-wide - read is intended, or set viewAllRecords: false if the narrowing is." -``` - -**Which pairs move, and the one that deliberately does not.** The refusal is the -two short-circuits, transcribed: - -| declaration | resolver | verdict | -|:--|:--|:--| -| `readScope` + `viewAllRecords: true` | `opClass === 'read' && (viewAllRecords \|\| modifyAllRecords)` | **refused** | -| `readScope` + `modifyAllRecords: true` | same disjunct | **refused** | -| `writeScope` + `modifyAllRecords: true` | `opClass === 'write' && modifyAllRecords` | **refused** | -| `writeScope` + `viewAllRecords: true` | the write short-circuit does not name `viewAllRecords` | **accepted — honoured, and refusing it would delete a real grant** | - -⛔ **What `viewAllRecords: true` GRANTS is untouched.** This changes which -declarations are accepted, never what an accepted one does — a permission- -semantics change is not in this change's remit. `viewAllRecords: true` alone, -`viewAllRecords: false` beside a `readScope` (the ordinary, honoured shape), and -a bare `readScope` all parse exactly as before; each is pinned as a -cost-direction guard in `permission.test.ts`, and an ablation that widens the -refusal one shape too far turns the `writeScope`-beside-`viewAllRecords` pin red. - -**The wire surface stays tolerant.** The refinement rides on the AUTHORING -wrapper only; `EffectiveObjectPermissionSchema` extends the unrefined base, so a -server still running an older toolchain can return a stored pair in an -effective-permission response without crashing a client (#4001's authorable/wire -split). `AccessMatrixEntry` likewise keeps describing the pair: it is a derived -SNAPSHOT shape whose committed `access-matrix.json` may predate this refusal, and -its tolerance is now stated with that reason in `explain.test.ts` rather than -reading as evidence that the platform accepts the declaration. - -**Scope is one object-permission entry**, which is exactly the resolver's input — -`resolveObjectPermission` returns a single entry (explicit, else the `'*'` -wildcard) and never merges two. A super-user bit in one permission set widening -past another set's `readScope` is ADR-0090's documented additive "widest wins" -semantics, not a contradictory declaration, and is not judged here. - -**Nothing in the fleet moves.** Measured across shipped defaults, both seeded -examples, two built access matrices, the built artifact fixture and every tracked -`.ts` / `.json`: **0** object permissions carry any refused pair, with lit -controls on every probe (130 nodes declaring `viewAllRecords`, 53 of them `true`, -18 declaring `readScope`; 133 brace-local `viewAllRecords: true` literals). - - diff --git a/.changeset/16872-generated-i18n-provenance-population.md b/.changeset/16872-generated-i18n-provenance-population.md deleted file mode 100644 index 13362308de..0000000000 --- a/.changeset/16872-generated-i18n-provenance-population.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/cli': patch ---- - -fix(platform-objects,cli): the generated i18n staleness predicate judges every section a run generated, not two fixed names - -`os i18n extract --no-objects-only --fill=default --source-hashes` emits -`apps` / `dashboards` / `pages` leaves and fills them from the source locale — -leaves carrying exactly the property the GENERATED staleness predicate exists to -judge — but the population that predicate walked was the fixed -`GENERATED_SECTIONS` list (`['objects', 'metadataForms']`). So no provenance -record was written for such a leaf, none was read back, and a `--fill=default` -copy left behind by a revised source kept being served as a superseded draft -with every i18n gate green. The hand-authored predicate does reach those paths, -but it judges against `LOCALE.source-hashes.ts`, which by construction carries -no entry for a leaf a generator produced. Neither mechanism covered them. - -The population now follows the RUN, at both ends: - -- **write** — `collectFilledFromHashes` takes a new **optional** fourth - parameter, `sections?: readonly string[]`, defaulting to `GENERATED_SECTIONS`. - `collectGeneratedLeaves` takes the same optional second parameter. Every - existing call site compiles and behaves exactly as before; `os i18n extract` - passes the sections it actually built. -- **read** — `findStaleFills` walks the sections the recorded table itself - names. One run wrote that table, so the table is the record of what that run - emitted, and the two ends cannot disagree about it. For every table committed - today this resolves to `['objects', 'metadataForms']`, so no served byte moves. - -Adding `'apps'` to `GENERATED_SECTIONS` was the other available shape and is -deliberately not taken: it would make `collectSourceLeaves` and -`collectGeneratedLeaves` walk one section — two predicates permanently on one -path — and it would assert `apps` is always generated, which is false for every -bundle set that ships. Both constants are unchanged and pinned unchanged. - -Widening the generated population is safe in a way widening the hand-authored -one would not be, because the rule is self-discriminating per leaf: a record is -written only when `value === currentSource` or `previous[path] === hash(value)`, -so a leaf someone actually translated satisfies neither and stays -legacy-trusted however wide the walk. The section list was the only part of the -mechanism that could not tell a fill from a translation. - -No committed bundle or companion byte moves in this repository. All nine -`--source-hashes` configs run the default `--objects-only`, whose commit layer -already narrows the run's table to the sections it emits a bundle for. The 387 -hand-recorded digests across `zh-CN` / `ja-JP` / `es-ES` are neither read, -written, shadowed nor lost — `apps` stays in `HAND_AUTHORED_SECTIONS`, -`collectSourceHashes` still walks it, and the extractor still never writes that -file. Its header now states which table a maintainer keeps for a path that can -appear in both, and why the overlap cannot serve wrong text. diff --git a/.changeset/16875-nav-recordid-viewname-tolerated.md b/.changeset/16875-nav-recordid-viewname-tolerated.md deleted file mode 100644 index a07cafb6b6..0000000000 --- a/.changeset/16875-nav-recordid-viewname-tolerated.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ObjectNavItem.recordId`'s docblock said it was "Mutually exclusive with `viewName`" — the guard tolerates that exact pair, deliberately - -The docblock read *"Mutually exclusive with `viewName` (viewName is ignored if -both are set)"*. The parenthetical was the tell: *"ignored if both are set"* -describes a **precedence**, not a refusal, so the sentence's own second clause -contradicted its first — and the code agrees with the second clause. -`recordId` + `viewName` parses clean through `NavigationItemSchema`; it is the -one legacy combination `objectNavTargetExclusivity` lets through, and that -guard's own docblock says so in as many words. - -**The harm direction is silent in both directions.** An author (or an agent) -who read "mutually exclusive" would avoid a combination the platform accepts, -or file a bug when it parses. Two docblocks in one file described one rule and -disagreed; the guard's was right. - -⛔ **No behaviour changes, and the asymmetry is not "unified".** The tolerance -is a recorded decision, and `app-nav-target-exclusivity-export.test.ts` already -pins `recordId` + `viewName` as accepted precisely so that making the target -fields pairwise exclusive goes red. This changeset corrects the **prose** only: -no schema, no guard, no accept set, no authorable key, no export moves. The -`.describe()` strings — the ones that reach `content/docs/references/` — are -untouched. - -The corrected docblock now says the pair is tolerated rather than refused, -names the guard that tolerates it, and points at the test that pins it. The -same test file gains a fifth leg asserting the docblock against the accept set -it describes, so the next copy of this sentence goes red instead of shipping: -prose is the only place the tolerated pair is documented, so nothing else was -watching it. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` carries both -`dist` and `src/**/*.zod.ts`, and `src/ui/app.zod.ts` matches that glob — the -edited file is shipped as source verbatim. Measured on the built artifact as -well: the new sentence is present in **18** built files under `dist/` and the -old spelling in **0**, with two untouched sentences from the same region -(`navigate straight to the detail page`, and the `filters` docblock's own TRUE -exclusivity claim over `recordId` / `viewName`) present in **18** each as the -lit controls, so the zero is a reading and not a mistyped anchor. The -declaration files do not carry it — this is a field-level docblock inside a Zod -shape — which is why the reach is stated as the bundles and the shipped source -rather than as `.d.ts`. diff --git a/.changeset/16884-boot-refusal-comments-registered.md b/.changeset/16884-boot-refusal-comments-registered.md deleted file mode 100644 index 46e3d5e8a3..0000000000 --- a/.changeset/16884-boot-refusal-comments-registered.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/core': patch -'@objectstack/driver-sql': patch ---- - -Three source comments now state the registered position for the `door: 'none'` boot-refusal codes instead of the pre-#16404 one - -`SERVICE_NOT_REGISTERED`, `PLUGIN_CONTRACT_VIOLATION` and — as the worked -example the `driver-sql` comment cites — `MONGODB_MULTI_TENANT_UNSUPPORTED` are -all registered in `ERROR_CODE_LEDGER`. #16649 registered fourteen `door: 'none'` -codes under the #16404 door-or-no-door ruling, and re-registered the MongoDB one -that #8035 had removed. Three TSDoc comments still asserted the position that -preceded that ruling — that these codes are deliberately not wire vocabulary, -and that registering one is "not something to start doing at a door" — and each -was false the moment #16649 landed. They also pointed at -`dispatcher-error-vocabulary.ts`'s `boot-refusal` verdict, which the same PR -ratcheted from fourteen rows to zero, so the pointer dangled. - -These docblocks ship inside each package's `dist/*.d.ts`, which is why this is a -published change rather than an internal one: the sentence is what an agent or -an IDE reader sees at the point it decides whether the code needs registering. - -⛔ No behaviour changes. Every reachability sentence is kept verbatim — none of -these codes reaches an HTTP door on this tree — no code is added, removed or -re-registered, and no gate moves. With every comment character removed by -`scripts/js-comment-mask.mjs`, all three files' executable token streams are -byte-identical to the commit this branched from. diff --git a/.changeset/16927-agent-tools-retirement-citation.md b/.changeset/16927-agent-tools-retirement-citation.md deleted file mode 100644 index 820b091504..0000000000 --- a/.changeset/16927-agent-tools-retirement-citation.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `agent.tools` rejection now says why ADR-0064 binds, so its `Proposed` status does not read as "not yet in force" - -An author who writes the retired `agent.tools` key gets the tombstone's -prescription, which rests the rule on **ADR-0064** (*"an agent's tool set is the -union of its surface-compatible skills' tools"*). Following that citation lands -on a record whose own header reads `**Status**: Proposed (2026-06-22)` and -carries a `🔶 Cloud-owned — superseded in part by cloud ADR-0025` callout. From -the record itself an author cannot tell that the rule still binds them — the -weaker reading is the one the metadata invites. - -ADR-0064 stays the cited authority, because it is the record that states the -invariant the key violated; **ADR-0109** (`Accepted — implemented (Phase 1)`) -names `agent.tools` nowhere and only *builds on* that invariant, so retargeting -the citation would send the author to a record that does not contain the rule -they broke. The message instead gains one clarifying clause: the `Proposed` / -cloud-owned status scopes the **runtime** half (tool resolution, which lives in -cloud `service-ai`), while the **authoring** half is in force in this repo and -ADR-0109 is the in-repo record carrying it. - -Prose only — the rejection, the retirement and the accept set are unchanged. diff --git a/.changeset/16974-inbox-message-actor-id.md b/.changeset/16974-inbox-message-actor-id.md deleted file mode 100644 index 96048bdb3d..0000000000 --- a/.changeset/16974-inbox-message-actor-id.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/service-messaging": minor ---- - -`sys_inbox_message` rows now carry **`actor_id`** — who caused the notification — and the actor travels there end to end from the `emit()` that raised the event. - -Until now an inbox row could not answer "did I cause this?". The actor stopped one layer upstream on `sys_notification.actor_id`, and the shipped default permission sets grant a member no read on `sys_notification`, so the value was behind an FK hop into an object the reader cannot open. Consumers implementing the standard "do not notify me of my own action" rule had nothing to compare, and the visible failure was the notification that says *you* just did the thing you just did. - -The path, one leg per seam, no new read anywhere: - -- **`Notification.actorId?: string`** (`channel.ts`) — the per-recipient unit every channel implementation consumes gains an optional member, with the same semantics as `sys_notification.actor_id`. -- **`emit()`** projects `EmitInput.actorId` onto that unit on the P0 inline path, and **`enqueueDeliveries`** snapshots it into the delivery row's payload on the P1 outbox path — beside the rendered title/body, under the rule the enqueue path already states in its own comment: an event edited after enqueue cannot rewrite an in-flight send. `DeliveryPayload.actorId?: string` is declared rather than left to that type's index signature. -- **The dispatcher** reads it back off that snapshot in `processRow`. It deliberately does **not** re-read `sys_notification`, which would cost one read per delivery and break the snapshot rule. -- **The inbox channel** writes `actor_id: n.actorId ?? null`, and `sys_inbox_message` declares `actor_id` as a `sys_user` lookup. - -**A digest row keeps `actor_id` null by construction.** A collapsed group has no single actor, so asserting "you caused this" over a message that also carries other people's events would be wrong; `processDigestGroup` sets no actor and the object's own description says so. - -**Existing rows read `actor_id` null**, which a consumer's `row.actor_id === currentUserId` evaluates as "not mine" — the pre-change behaviour for rows written before this release. Nothing is backfilled: the value was never captured on those rows, so any backfill would be invented. diff --git a/.changeset/17053-list-view-sort-string-clause-retired.md b/.changeset/17053-list-view-sort-string-clause-retired.md deleted file mode 100644 index e0f886baea..0000000000 --- a/.changeset/17053-list-view-sort-string-clause-retired.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `ListViewSchema.sort` retires the bare string clause — the PRODUCER half of the sort seam, so the contract stops minting documents its own consumer refuses (#17053; objectui#8221, decision batch #77 option B) - - - -**BREAKING** accept-set narrowing at `view.sort` — the list-view doors -(`ListViewSchema`, and the `ObjectListViewSchema` copy behind `object.list` / -`object.listViews.*`) — shipped as `minor` under this repo's launch-window -convention for breaking changes, the same grade its sibling -`object-block-sort-item-array` took for the two `ComponentPropsMap` doors. The -mechanical prescription is registered under protocol major 18 as -`list-view-sort-string-clause-to-array`. - -**Why this is graded on the seam, not on the string.** objectui ruled one sort -orthography platform-wide — the array (objectui#8221, decision batch #77, -2026-09-07, option B) — and objectui PR #8758 executes it: `convertSortToQueryParams` -refuses a runtime string and its diagnostic names the array form. `ListViewSchema` -is the producer of exactly those documents: `object.list.sort` is what -`deriveRelatedLists` reads. So until this release a view authored with -`sort: 'created_at desc'` **validated here, cleanly, and then failed downstream** — -the contract minting a shape its consumer rejects, with the author told off by -the wrong layer. Re-measured on this tree before the change, with `bogusProp` -refused by name on the same call as the firing control: `'name desc'`, `'-name'` -and the array form all returned `success: true`, and only a bare number was -refused (`sort/invalid_union`). - -`sort` survives as a key, one union arm lighter, so this is a VALUE narrowing with -no `retiredKey()` tombstone to hang a prescription on. The surviving array member's -own `error` map carries it, keyed on `issue.input` being a string — the same shape -`view.type`'s retired `'page'` value and `view.exportOptions`' retired `'pdf'` value -already use in this schema. Every other invalid value (a number, an object, a -string reaching a *descendant* such as a misspelled `order`) keeps zod's default -report, so nobody is told a clause they never wrote "was removed". - -**Migration** (`list-view-sort-string-clause-to-array`, a D2 conversion, not a -semantic TODO — the rewrite is lossless and wholly mechanical): -`sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`; -a bare field name meant ascending, so `sort: 'created_at'` becomes -`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required on the entry -and is written out rather than omitted; a comma-separated clause becomes one array -entry per key, in the same order. `os migrate meta --from 17` lists these edits for -author sources, and stored rows replay them through `applyConversionsToStoredItem`. - -**The narrowing was not free, and the population was measured rather than assumed.** -A tree-wide census over both the TS and JSON spellings of a string-valued `sort`, -read as STRUCTURES rather than counted as tokens, found the clause authored on -three live in-tree sites, all converted here: the shipped showcase list view -`examples/app-showcase/src/ui/views/task.view.ts` (`'estimate_hours desc'`, carried -since objectui#2601 as a deliberate live coverage fixture for the string form), the -frozen `packages/lint` snapshot of that same shipped shape, and the published -`skills/objectstack-ui` list-view rule. The census fired: it *found* documents, and -`tsc` independently reds on the first two the moment the arm is removed. Sites -deliberately NOT converted, having been read rather than grepped: ObjectQL -`query.sort` and the wire `normalizeSortNodes` (different doors, different -dialects), `packages/spec`'s `book`/`doc` field-mapping records whose `sort: 'order'` -is an unrelated key of the same name, and the `packages/lint` rule fixtures, which -feed the PRE-parse walker and never reach this schema. - -**Not moved by this release.** `RecordRelatedListProps.sort` keeps its declared -string arm. That string is the `'field'` / `'-field'` dialect normalised by -objectui's own `RelatedList.normalizeSortSpec`; it never reaches -`convertSortToQueryParams`, and retiring it was not ruled. For the same reason the -conversion above declines any clause that does not parse as ` [asc|desc]`: -guessing a direction for `'-name'` would invent an ordering the author never wrote, -so on a list view it meets the door's prescription instead. diff --git a/.changeset/17054-calendar-config-all-day-field.md b/.changeset/17054-calendar-config-all-day-field.md deleted file mode 100644 index 7c968a670a..0000000000 --- a/.changeset/17054-calendar-config-all-day-field.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`CalendarConfigSchema` now declares **`allDayField`** — the fifth field binding on a calendar config, and the one key the rest of this package already published as a member while the schema refused it by name. - -**The trap this closes.** The `object-calendar` door refuses a flat `allDayField` and prescribes, verbatim: *"Write this as a key of the `calendar` config object instead — `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`."* That block's `calendar` prop `.describe()` publishes the same five-key shape, and it ships to `content/docs/references/ui/component.mdx`. An author who followed the prescription on a stored view was refused a **second** time, by a different schema with a different message — `Unrecognized key(s) on this calendar configuration: allDayField` — and neither message said the key was not a member at all, so the natural next move was to assume a typo and try more spellings. - -**Why the schema was the wrong half, measured rather than assumed.** The key is honoured, not inert. At the objectui pin this repo builds against, `ListView`'s `collectViewFields` reads `calendar.allDayField` into the fetch projection and its calendar branch forwards the authored block onto the `object-calendar` node, where `getCalendarConfig` resolves it; objectui then made it load-bearing in the render itself. Trimming the prescription instead would have left a shipped capability with no protocol carrier — and the mirror that carries it today keeps `.passthrough()` explicitly so the key is not stripped, which means a later hardening there would silently drop it. - -**What is authorable, and what still is not.** - -```ts -// accepted -calendar: { startDateField: 'start_date', endDateField: 'end_date', - titleField: 'subject', colorField: 'status', allDayField: 'is_all_day' } - -// still refused — one key per concept, not a second authorable spelling -{ type: 'object-calendar', allDayField: 'is_all_day' } -``` - -`allDayField` **names a boolean field, not a value**: a record whose flag is true draws as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit it and the renderer's existing inference is untouched — an event with no end date draws as all-day — so every calendar that never authored the key renders exactly as before. - -**The opening is one key wide.** `defaultView` stays refused on this config: it is the renderer's initial view mode, a UI preference rather than a field binding, and it already has its own declared home as an `object-calendar` component prop. Unknown keys are refused in the same shape as before, and `startDateField` is still required. - -Purely additive: nothing that parsed before is refused now, and no key is renamed or removed. diff --git a/.changeset/17081-dev-admin-banner-says-what-it-sees.md b/.changeset/17081-dev-admin-banner-says-what-it-sees.md deleted file mode 100644 index 263d025f93..0000000000 --- a/.changeset/17081-dev-admin-banner-says-what-it-sees.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): the boot banner's `🔑 Dev admin` says what that account will and will not see (#17081) - -`--seed-admin` (on by default in `os dev`) prints one credential, and it is the -**only** one a first-run operator is given. It is also, by construction, the -account with every *platform* capability and no *app-declared* one: its standing -is `admin_full_access`, whose `systemPermissions` are `setup.access`, -`studio.access`, `manage_users`, `manage_metadata`, `manage_platform_settings` -and `manage_sharing` — all platform built-ins — plus the `'*'` -view-all/modify-all record bits. - -So in any app that gates its apps, tabs or nav entries on -`requiredPermissions` — the filter `/me/apps` and `/meta/app` apply, and a -first-class platform feature the docs teach — the credential the terminal hands -over is the account that resolves to an **empty navigation**. A downstream -maintainer ran `pnpm dev`, signed in with it, and read the empty shell as a -broken product. The app was correct. The banner had asserted a login and said -nothing about its audience, and it outranks whatever the app's own README says, -because it sits directly under the command that was just run. - -FROM → TO, on a boot that seeds: - -``` - 🔑 Dev admin: admin@objectos.ai / admin123 - seeded on empty DB · dev only — do not use in production -+ platform admin — Setup, Studio and every record, but NO app-declared capability, so -+ an app that gates navigation on requiredPermissions may show it an empty menu; grant -+ it a permission set under Setup → Users, or sign in as an account your app seeds -``` - -**Nothing about the seed changes.** What the first run creates — the account, -its address, its password, its promotion to platform admin — is a product-shape -decision and is untouched; only the banner's words move. The three lines print -only inside the branch that already prints the credential, so a boot that seeds -nothing is byte-identical to before. - -Dim continuation lines rather than a warning, deliberately: ADR-0115's -`OS_ALLOW_DEV_PLUGIN` amendment excluded the dev-admin seed from that hazard set -because "a warning about a non-event spends the attention the real ones need". -That exclusion is kept — this qualifies an event that just happened, on the line -that already announces it, and adds no new line where there was none. - -The route the sentence names is asserted against the declarations that make it -reachable, not re-spelled: `SETUP_APP.requiredPermissions` is a subset of what -this account holds, the `Users` entry is ungated, and the `sys_user` detail page -carries the "Grant permission set" related list. A rename on any of those reds -the pin instead of leaving the banner pointing at nothing. diff --git a/.changeset/17093-scaffold-scim-retirement-note.md b/.changeset/17093-scaffold-scim-retirement-note.md deleted file mode 100644 index e5a729e2e2..0000000000 --- a/.changeset/17093-scaffold-scim-retirement-note.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/cli': patch -'create-objectstack': patch ---- - -The scaffolded `pnpm-workspace.yaml` records the retired `@better-auth/scim>better-call` peer rule instead of advertising it as live - -`objectstack init` wrote a paragraph into every project it scaffolds explaining -an `@better-auth/scim>better-call` suppression that is not in the map it -annotates — the entry retired with objectstack#3653, and `init.test.ts` pins its -absence. All three of its claims were false on today's tree as well: -`@better-auth/scim` is not "held at a release candidate deliberately" (it is -pinned at exact stable `1.7.3`), and stable `@better-auth/scim@1.7.3` declares -`peerDependencies["better-call"]` as the exact string `1.4.0` — the single copy -`better-auth@1.7.3` itself depends on — so the `1.3.7` skew the paragraph -described does not exist. - -It now records the retirement, in the shape `create-objectstack`'s bundled -`blank` template already used, and dates the measurement the way the -neighbouring `better-sqlite3` paragraph in the same block does. Both scaffold -paths previously named `1.7.1` as the current pin; both now name the measured -`1.7.3`, so the two paths tell a user the same thing. - -Comments only — no declaration moves. The rendered `allowedVersions` map is -byte-identical before and after, so no resolution, lockfile or suppression -changes. diff --git a/.changeset/17114-fold-admission-tenancy-classification.md b/.changeset/17114-fold-admission-tenancy-classification.md deleted file mode 100644 index ac07859dfc..0000000000 --- a/.changeset/17114-fold-admission-tenancy-classification.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/mcp': patch ---- - -refactor(runtime,mcp): the last two admission doors classify the `tenancy` rejection through the shared `classifyAdmissionTenancyPosture` (#17114) - -`@objectstack/core`'s `classifyAdmissionTenancyPosture` is the one place the -#13906 decision 1 option A classification lives: a branded "never registered" -rejection is the supported no-tenancy composition and answers a quiet -`undefined`, while every other rejection becomes -`AuthzStoreUnavailableError('tenancy', err)` — ADR-0112 `SERVICE_UNAVAILABLE` / -503 — because the posture is an authorization INPUT and admission was never -decided. - -Two admission doors were still hand-writing that classification, out of the -declared scope of the fold that extracted it: - -- `@objectstack/runtime`'s `resolveExecutionContext` — the REST/dispatcher - entry-point identity resolver; -- `@objectstack/mcp`'s `resolveStdioTenancyPosture` — the stdio door's **async - kernel** leg. - -Both now call the shared function. ⛔ **No behaviour changes at either door.** -Tenancy posture decides which rows a caller may see, so a divergence between -copies would be two answers to "whose data is this", and the copies are the -stale ones by construction — the shared version is the one that will be -maintained. - -**The resolution stayed at each seam, deliberately.** The extractable part is -the classification, not the resolution: each door keeps its own accessor guard -and hands its own former accessor expression in as the thunk, so the helper -never learns *how* a seam reaches the service. A helper that owned the wiring -too would be wrong for one seam or grow a flag per seam. - -**One neighbouring leg is deliberately NOT folded.** The stdio door's **sync** -fallback is taken only on a `KernelBase`-shaped host with no `getServiceAsync`, -whose accessor reports its one possible fault — nothing registered under that -name — **unbranded**. Routing it through the shared classification would mint a -503 outage out of a supported composition, so its bare `catch` remains that -seam's recorded decision. A test arm now fails if that leg is ever folded. - -Shipped rather than `skip-changeset`: both packages publish `files[]: ["dist"]`, -and the built `dist` of each carries the new call (2 files each, measured after -a real build, with a symbol known-absent scoring 0 and -`isServiceNotRegisteredError` scoring 4 in `runtime/dist` as the lit control). -`@objectstack/mcp`'s `dist` no longer mentions `isServiceNotRegisteredError` at -all. diff --git a/.changeset/17124-daterange-array-arm-arity.md b/.changeset/17124-daterange-array-arm-arity.md deleted file mode 100644 index 2732009bb3..0000000000 --- a/.changeset/17124-daterange-array-arm-arity.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(analytics): a `dateRange` array that is not a two-bound window is refused, once, instead of meaning three different things (#17124) - -`AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no -length constraint, so `dateRange: ['2026-01-01']` is schema-valid and reaches the -analytics faces through `POST /analytics/dataset/query`, which types its selection -from `AnalyticsQuery` and never Zod-parses it. The four faces in this package that -read the arm answered it three different ways — measured over one authored -document and four rows: - -| face | `['2026-01-01']` meant | -|---|---| -| `ObjectQLStrategy.dateRangeBounds` | the point window `created_at >= '2026-01-01' AND <= '2026-01-01'` | -| `NativeSQLStrategy` | no time clause at all — the whole dataset | -| the draft-preview evaluator | an upper bound of the string `"undefined"`, which every ISO date sorts below — everything from that day onward | -| `DatasetExecutor`'s `compareTo` pass | the point window, shifted — compared against a primary pass that may have read all of history | - -For a dashboard that is one day's number, the whole dataset's, and everything -from that day onward, from the same document, decided by which backend answered. -`[]` and `[a, b, c]` split the same three ways, and `[null, null]` reached -`parseUTC(null)` as a bare `TypeError` — a 500 for a malformed request. - -One rule is now the single reading of the arm and all four faces call it; the -three divergent fallbacks are deleted. An array that is not exactly two string -bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 -envelope — the answer the contract already gives for a `dateRange` that does not -denote a window. A two-element window is untouched on every face, bound for -bound, including the inclusive upper reading a caller's bounds keep (#16179) and -the half-open bare-day widening on the SQL side (#3777). - -### Write both bounds - -| wrote | write instead | -|---|---| -| `dateRange: ['2026-01-01']` | `dateRange: ['2026-01-01', '2026-01-01']` | - -That spelling already selects exactly that one day on every face, and it is the -same instruction #16322 shipped for the single-day string dialect. - -⭐ Shipped as `patch`, not as a breaking narrowing, because nothing DECLARED -moves. The spec's own refusal wording already states that *"an explicit window is -the two-element array [start, end] of ISO dates or {date-macro} tokens"*, and -#16322's shipped migration table already told authors to write a single day as -`['2026-01-20', '2026-01-20']`. A one-element array was therefore never a valid -document; it was an invalid one that four faces answered arbitrarily, and a -behaviour that was never one behaviour is not a behaviour this removes. The Zod -type admitting the shape is weaker than the contract the same file states — -tightening it is a separate, spec-owned question. diff --git a/.changeset/17147-granted-permissions-registered-not-enforced.md b/.changeset/17147-granted-permissions-registered-not-enforced.md deleted file mode 100644 index 3eca2a115c..0000000000 --- a/.changeset/17147-granted-permissions-registered-not-enforced.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/core': patch ---- - -Say what the install-time granted permission set actually does: it is REGISTERED at load and refuses nothing. - -Four shipped sentences claimed the structured `manifest.permissions` / `granted_permissions` set was enforced. Measured on `9bd4344e4`: `SecurePluginContext` — the only reader of `PluginPermissionEnforcer`'s service and hook gates — has zero production construction sites, and `enforceFileRead` / `enforceFileWrite` / `enforceNetworkRequest` are called by nothing at all, `SecurePluginContext` included. So #13457's binding registers a consented set that nothing queries, and the `fs` and `network` classes have no enforcement surface even in principle. - -Corrected, each to the same truthful split ("registered at load · queried by nothing · refuses no operation"): the `registerGrantedPermissions` docblock, the `PluginPermissions` schema docblock, the `manifest.loading` tombstone prescription, and the ADR-0087 D3 entry that ships that prescription into `docs/protocol-upgrade-guide.md`. The hand-written plugin development guide gains the same note beside its permission table. - -`plugin-runtime-tier-truthful-text.test.ts`'s coordination pin — which held the permissions half verbatim so it would go red the day that half was corrected — has been discharged and replaced by pins on the truthful text, in both carriers, each with the negative assertion that keeps the retracted sentence from returning beside it. - -New in `@objectstack/core`: `granted-permissions-not-enforced.pin.test.ts` pins the MEASUREMENT as well as the words, so the claim cannot rot in either direction. It fails the day a production `SecurePluginContext` construction site appears — i.e. the day the ADR-0025 materialize seam lands — and names every text that then becomes false. - -No behaviour changes: no accept/reject, no registration, no gate is added or removed. diff --git a/.changeset/17147-retracted-verb-repo-wide.md b/.changeset/17147-retracted-verb-repo-wide.md deleted file mode 100644 index b0f43a9554..0000000000 --- a/.changeset/17147-retracted-verb-repo-wide.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/core': patch ---- - -Sweep the retracted "enforces exactly the consented surface" phrasing repo-wide, not just in the file it shipped on. - -The #17147 pin read one file, and a post-merge sweep found what that missed: `artifact-granted-permissions.test.ts` carried the retracted sentence as a CASE TITLE — "a CONSENTED entry enforces exactly the consented surface" — beside a sibling titled "registered, and denies". Neither case asserts a refusal; both read a permission bag and check what it answers. But a case title is read as evidence (ADR-0033), and those two said the platform confines plugins while nothing on the tree queries the registry at all. - -Both titles now name what they assert, the file carries a verb-discipline note (`answers` / `registered` / `bound`; ⛔ never `enforces` / `denies` / `gates` / `refuses` / `blocks` until the seam exists), and the pin's negative assertion is a repo-wide `git grep` excluding only its own specimen — with an anti-vacuity limb so a broken scan cannot read as a clean one. - -No behaviour, no assertion semantics, and no accept/reject changes. diff --git a/.changeset/17157-cache-warmup-scheduled-strategy-retired.md b/.changeset/17157-cache-warmup-scheduled-strategy-retired.md deleted file mode 100644 index 6b7a6394c1..0000000000 --- a/.changeset/17157-cache-warmup-scheduled-strategy-retired.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: retire the `scheduled` cache-warmup strategy — the cron it selected left in this same major, and nothing ever warmed on a cadence (ADR-0049) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` (the -lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness -is carried by this banner plus the ADR-0087 disposition above). - -`CacheWarmup.strategy` no longer accepts `'scheduled'`. - -| | before | after | -|:--|:--|:--| -| accept set | `'eager' \| 'lazy' \| 'scheduled'` | `'eager' \| 'lazy'` | -| describe | `… lazy (on first access), scheduled (cron)` | `… lazy (on first access)` | -| a document writing it | parsed green | **refused**, with the prescription | - -**The one-line fix:** write `strategy: 'eager'` (warm at startup) or `strategy: 'lazy'` -(warm on first access). For a warmup on a **cadence**, declare a `job` — that is the one -cron slot this platform evaluates: - -```ts -defineStack({ - jobs: [{ name: 'warm_config_cache', schedule: { expression: '0 * * * *' }, handler: 'warmConfigCache' }], -}); -``` - -## Why - -`cron-typed-positions-retired` (17.x → 18, #16320) deleted `CacheWarmup.schedule`, the -cron key this enum member selected, and left the member standing on the reading that it is -"a value, not a position the ruling names". That was a statement about that ruling's -**scope**, not a finding that the value was sound. After the deletion the member declared a -warmup cadence with **no key left to configure it and no engine that has ever run one**, -while its own `.describe()` still promised `(cron)` — ADR-0049 declared-not-enforced, in -the form Prime Directive 10 names outright: a capability advertised that the runtime does -not deliver. - -Nothing on the platform reads `CacheWarmupSchema`: outside its declaring file it resolves -to the generated reference page's import line, the `declaration-map` / `export-origins` -catalogues, the ADR-0058 D7 ledger comment and two of this package's own test files — zero -runtime consumers, measured beside a lit control (`ConnectorSchema`, 46 files, same sweep). -So **no runtime behaviour changes**: no warmup has ever run on a schedule, before or after. -What changes is that the contract stops promising it. - -## The retirement kit - -- the member leaves `z.enum(['eager','lazy','scheduled'])` and the `.describe()` stops - saying `(cron)` (`system/cache.zod.ts`) -- the prescription hangs on **the enum's own `error` map, dispatched by `issue.input`** — - the established route for an enum-VALUE retirement (`crypto.hash` on - `HookBodyCapability`, `object.managedBy: 'system'`, `HotReloadConfig.stateStrategy`). - There is no value-level analogue of `retiredKey()` and none is invented here. Only the - value that **used to be legal** gets the "was removed" sentence; `strategy: 'sheduled'` - keeps zod's own enum message, which already lists the legal values -- an **ADR-0087 D3 semantic entry**, `cache-warmup-scheduled-strategy-retired` — a semantic - entry rather than a D2 conversion because there is **no source to rewrite**: `CacheWarmup` - is bound to no metadata type and embedded in no stack collection, so no authored document - and no stored row has ever carried this value, and `os migrate meta` has nothing to list. - That is also why the prescription carries **no `os migrate meta` sentence** — it would - promise a listing the tool cannot produce, which is the very defect this card is about -- **nothing in `RETIRED_KEYS_BY_MAJOR`** — no authorable *key* changed — and **no - `retiredKey()` tombstone**, which tombstones keys, not values -- pin tests (`system/cache.test.ts`): the refusal and its prescription, a **lit control** - that a typo is *not* told it "was removed", and that the surviving members and the - `'lazy'` default still parse. `cron-typed-positions-retirement.test.ts`'s warmup fixture - moves to `'eager'`, since a fixture must be well-formed under the current schema - -## ⚠️ The four surface ratchets are byte-identical across this change, and that is correct - -An enum-VALUE narrowing moves no position, no exported name and no expression-typed slot: -`authorable-surface/` keys on **positions** (`system/CacheWarmup:strategy` stays — the key -is untouched), the ADR-0058 D7 ledger on **expression-typed slots**, and `api-surface/` / -`json-schema.manifest/` on **names**. None of them reads a def's *value set*, so none of -them can fail on this change — the `crypto.hash` precedent measured exactly this. The pin -tests above are therefore not a formality: they are the only instrument this retirement -has, and a green CI run on its own says nothing about whether the value is gone. diff --git a/.changeset/17159-etl-retirement-syncconfig-sentence.md b/.changeset/17159-etl-retirement-syncconfig-sentence.md deleted file mode 100644 index e1d66cef4b..0000000000 --- a/.changeset/17159-etl-retirement-syncconfig-sentence.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `etl-pipeline-layer-retired` D3 entry stops promising that connector-attached sync is EXECUTED - -The entry's `replacement` string is an ADR-0087 D4 projected field: it ships verbatim in -`packages/spec/spec-changes.json` (twice — the flat entry and the composed record), which is -in this package's `files[]` and therefore in the published tarball, and it renders into -`docs/protocol-upgrade-guide.md`. It is the advice an author displaced by the ETL layer's -retirement actually reads, and it said connector-attached synchronisation is -`ConnectorSchema.syncConfig`, "which IS parsed and executed". - -Parsed is true. Executed never was, and this tree measures it: - -- `AutomationEngine.registerConnector` / `registerDegradedConnector` - (`packages/services/service-automation/src/engine.ts`) run `ConnectorSchema.parse(def)` and - store the parsed definition in the engine's connector map. Only `actions` is read back off - it; `syncConfig` is never read. -- `syncConfig` has no reader outside `packages/spec` at all — the only non-spec occurrences in - `packages/` are two comment lines in the D7 expression-conformance ledger. That is the same - measurement that retired `syncConfig.schedule` in 18 under ADR-0049, and it is already - stated at the schema (`integration/connector.zod.ts`). - -The corrected sentence says what the block IS and what actually happens to it — parsed and -validated, then inert — and then names the surface that IS executed, so the reader still has -somewhere to go: a connector's `actions`, dispatched by a flow's `connector_action` node, -which resolves the registered handler and awaits it. - -Nothing about the ETL retirement itself changes: no key moves, no accept set moves, no schema -changes. The registry, `spec-changes.json` and the upgrade guide were regenerated by their -generators, and the corrected claim is pinned in `migrations.test.ts` beside the other -projected-string corrections so it cannot regress. diff --git a/.changeset/17166-object-grid-export-options-describe-members.md b/.changeset/17166-object-grid-export-options-describe-members.md deleted file mode 100644 index fa6e4ad06d..0000000000 --- a/.changeset/17166-object-grid-export-options-describe-members.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ComponentPropsMap['object-grid'].exportOptions` names all five members the renderer reads, not two - -The entry is `z.unknown()`, so nothing about this key is parsed, refused or -stripped: a member that does not exist draws no error and has no effect, and a -member that does exist cannot be discovered from the schema. That makes the -`.describe()` string the entire account of the key's shape rather than a summary -of an enforced one — and it projects straight into -`content/docs/references/ui/component.mdx`, which is what an author (or a -generating model, ADR-0033) reads. - -It named two members, `formats` and `streaming`. The only renderer reads five. - -Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` -— objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the -`schema.exportOptions` expression and the `exportConfig` local bound to it, with -objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose -comment/string stripping is what stops a prose mention of a key being counted as -a read): `formats` 2 read sites, `streaming` 2, `maxRecords` 1, -`includeHeaders` 1, `fileNamePrefix` 1, and an absent-name control -(`zzzNotAMember`) 0 on the same instrument — which is what makes those five -counts readings rather than a matcher that matches anything. The same instrument -answers the same five, with the same per-member counts, at objectui -`3fbdd4a2dae1`, so the set is not an artefact of the pin's age. - -The three missing members are `maxRecords`, `includeHeaders` and -`fileNamePrefix`. An author reading the old string learned that -`exportOptions` takes `{ formats, streaming }` and had no way to reach the other -three short of reading the renderer's source — the shape objectstack#8010 -closed for this same key one layer out, when `streaming` was read for releases -while no schema declared it. - -⛔ The key is unchanged: it stays `z.unknown()` and no accept set moves in either -direction. Giving `exportOptions` a real shape is a separate and much larger -change with its own review requirements; this is the docs half only. - -The new list is not restated in prose that can drift on its own. A pin holds the -describe string's member enumeration equal to the members -`ListViewExportOptionsSchema` declares — the spec's own five-key declaration of -this same authoring block, reached through `ListViewSchema.exportOptions`'s -object branch and itself derived from that same read set. Both spellings reach -one renderer, so narrowing or widening the declared block now reds the -`z.unknown()` prose instead of leaving it quietly behind: the declared side has -parse failures to catch drift, this side had nothing. The pin also records that -the key is unvalidated today, so the day it grows an accept set is a deliberate -decision rather than a silent one. - -`content/docs/references/ui/component.mdx` is regenerated from the string -(`gen:schema` then `gen:docs`) and carries the same one-line change. diff --git a/.changeset/17167-organization-probe-records-empty-channel.md b/.changeset/17167-organization-probe-records-empty-channel.md deleted file mode 100644 index a09236e8cc..0000000000 --- a/.changeset/17167-organization-probe-records-empty-channel.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -The seed-tenancy backfill's organization probe records the operator channel as is — an empty one included — instead of the placeholder `'unknown error'` (#17167) - -`packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts` had one site left -that did not follow the rule the rest of the file follows. Where the other four -`operatorFacingErrorText` calls record the helper's return value as is, the -`sys_organization` probe spelled `operatorFacingErrorText(e) || 'unknown error'`, so a -backend that failed WITHOUT saying anything was recorded as having said -`'unknown error'` — words no backend produced, in a field an operator reads to find out -which probe failed and why. - -**Measured before and after**, driving `backfillSeedTenancy` at each site in that file -with the same three empty-channel shapes (a thrown `''`, a thrown `[]`, an `Error` whose -`name` and `message` are both empty) and with `new Error('boom')` as the control: - -| site | before | after | -|---|---|---| -| split probe → `result.detail` | `''` | `''` | -| **organization probe** → the warning's `organizationProbeError` | **`'unknown error'`** | **`''`** | -| duplicate-list probe → the warning's `error` | `''` | `''` | -| stamp → the warning's `error` | `''` | `''` | -| counter merge → the warning's `error` | `''` | `''` | - -The control records `'boom'` at every site in both columns. - -**Why this was not a one-line deletion.** The placeholder was carrying two jobs and only -one of them was a record: the site also read `organizationProbeError === ''` as "the probe -did not fail", which is how a failed probe is kept out of the benign `no-organization-yet` -branch (#9261 — unknown is not zero). Deleting the placeholder and putting nothing in its -place was measured: a thrown `''` then reports `no-organization-yet` and warns about -nothing, while the control still reports `skipped-ambiguous-organization`. So the failure -fact moved into the TYPE — `organizationProbeError` is `string | undefined`, `undefined` -means the probe answered, and every string, empty or not, is a failure. The text is then -free to say exactly what the backend said. - -**What does NOT move.** No status value changes for any input: an organization probe that -throws still reports `skipped-ambiguous-organization`, whatever its channel holds, and -`SeedTenancyBackfillStatus`, `SeedTenancyBackfillResult` and every exported signature are -unchanged. This probe's text never reached the returned result in the first place — it is -carried only by the warning this migration logs (measured: the control text appears in -`result.detail` at the split-probe site and appears nowhere in the returned object at this -one). - -**One operator-visible detail beyond the text.** The warning's structured field is now -absent when the probe answered and present-but-empty when it failed silently, so "empty" -and "there was no failure" stay distinguishable in the stored line — the one job the -placeholder was doing that a reader could have depended on. The sentence in the same -warning drops its parenthetical rather than filling it in: `the sys_organization probe -FAILED, so the count above is "unknown"` when the backend said nothing. diff --git a/.changeset/17175-non-raising-table-presence-probe.md b/.changeset/17175-non-raising-table-presence-probe.md deleted file mode 100644 index 4b5a313531..0000000000 --- a/.changeset/17175-non-raising-table-presence-probe.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -The `kernel:ready` migrations ask whether a table exists WITHOUT running a statement that has to be refused, so a normal boot stops printing `[sql-driver] DATABASE_ERROR … no such table` (#17175) - -Two migrations on the boot hook asked "does this table exist?" with a statement -that cannot succeed when the answer is no — `SELECT "tenant_id" FROM -"_objectstack_sequences" WHERE 1 = 0` in `seed-tenancy-backfill.ts`, and `SELECT -1 FROM sys_setting WHERE 1 = 0` in `sys-setting-identity-index.ts` — and read -the refusal as "no". Both are correct on their own terms. Both make -`SqlDriver.execute()`'s raw terminal write the statement and the dialect's -message to the operator's log on the way out. - -Measured on this tree against real `better-sqlite3`: exactly one line per probe, -on `console.warn` — i.e. **stderr** — carrying both the `DATABASE_ERROR` token -and `no such table`. It fires on **every boot** of every install that has never -allocated an autonumber, and again on every boot of every kernel that does not -register the optional `service-settings`. - -⭐ The cost is not the line. It is that operators learn this product prints -errors when nothing is wrong, and then miss the one that matters. A consumer told -to read the boot log (`objectstack-ai/hotclm`'s `AGENTS.md` names `no such table` -as a failing boot) must either ignore an unactionable ERROR every boot or chase a -platform-internal probe. - -**The question is now asked of the CATALOG.** A new shared -`migrations/read-probe.ts` compiles one arm per dialect family — `sqlite_master` -for SQLite, `to_regclass` for Postgres, `information_schema.tables` scoped with -`DATABASE()` for MySQL — each of which returns zero rows for a table that is not -there instead of being refused. Both migrations call it; the probe lives once, -not once per site. - -**⛔ Why not in the driver.** Quietening a refusal requires classifying it, this -repo has one predicate for that (`isMissingTableError`), and it needs the name of -the thing the caller was reading — which the raw path structurally does not have -(`rawStatementFaultError` declares no targeted table, and -`driver-error-classification.callers.test.ts` fails any in-repo call that omits -`readObject`). An unclassified demotion of the driver's raw terminal would -quieten real failures too. The caller knows the table; the driver does not. - -**⛔ The fence, and it is the one way this repair can go wrong.** A catalog arm -mis-compiled for some dialect would be refused, caught by the same `catch` the -expected miss uses, and read as "the table is not there" — turning a stored-row -data repair into a silent no-op on whichever dialect nobody exercised. So the -probe answers four verdicts rather than a boolean, and `'unreadable'` is never -folded into `'absent'`: it is returned, and reported at `warn`. An unrecognised -dialect gets no guessed catalog statement at all — it keeps the caller's own -`WHERE 1 = 0` probe, whose refusal is now *classified* with -`isMissingTableError(error, table)` rather than swallowed as absence. - -**Why `minor`.** - -- `SeedTenancyBackfillStatus` gains `'unreadable'`. It is an OUTPUT union, so no - input a caller writes is affected; the one consumer shape that could break is - an exhaustive `switch` with a `never` default, which is why this is not a - `patch`. -- `ensureSysSettingIdentityIndex` gains an optional third parameter - (`{ client? }`). Callers that pass two arguments are unchanged and keep - today's behaviour exactly — without a client there is no catalog arm and the - pre-existing probe runs. -- `buildSequencesPresenceSql` and `buildSysSettingPresenceSql` are unchanged in - text and still exported. They are no longer what the boot path runs first. -- `isResultSet` and `normalizeRows` moved to `migrations/read-probe.ts` and are - re-exported from `seed-tenancy-backfill.ts` unchanged, so the package index and - every importer see no difference. - -**What did NOT change.** #10789's ruling stands: a seam that accepts a statement -and returns no result set still reports `absent` with the `detail` that separates -it. The driver's error channel is untouched — a statement the backend genuinely -refuses is still written to the log in full, asserted against the same driver and -the same sink in the same test as the silence. - -**Dialect coverage, stated rather than implied.** The SQLite arm is pinned end to -end against a real `SqlDriver` (`packages/runtime`'s -`seed-tenancy-autonumber-split.integration.test.ts`); the MySQL arm runs against -the live server in `seed-tenancy-backfill.live-mysql.test.ts`, in both directions -and with the connected-schema scope measured. ⛔ The **Postgres** arm is NOT -MEASURED against a live server: this package has no live-PG harness, no `pg` -dependency, and its CI leg supplies `OS_TEST_MYSQL_URL` only while filtering to -`live-mysql`. Its statement text is pinned; running it is not. diff --git a/.changeset/17177-seed-summary-declares-its-scope.md b/.changeset/17177-seed-summary-declares-its-scope.md deleted file mode 100644 index ba396c90f3..0000000000 --- a/.changeset/17177-seed-summary-declares-its-scope.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Seed loader: the pass-2 deferred-reference diagnostics now say which moment they describe - -`SeedLoaderService` runs inside `AppPlugin.start()`, which the kernel completes for every -plugin before it fires `kernel:ready` — where the first-admin handoff -(`claimSeedOwnership`) re-owns every `owner_id IS NULL` row of every user-authored object. -That handoff is the designed completion of a NULL owner column, so two of the loader's -pass-2 lines — `Deferred reference UNRESOLVED after pass 2` and -`Deferred reference back-fill FAILED` — were making a bare present-tense claim -(`x.owner_id stays NULL`) that the same boot then made false, with nothing in either the -log or the table to tell an operator that the other reading existed. - -Both lines now read `is NULL at the end of pass 2` and carry a scope sentence naming the -boot step that can supersede them and stating that a non-NULL value found later is not -evidence the reference resolved. Level, error count and remedy are unchanged — this is a -scope declaration, not a silencing. The two `Deferred reference DROPPED` lines are -deliberately untouched: they report a row that never landed, so no later boot step can -write a column of it and their claim survives to the end of boot as written. - -Nothing an author writes changes. Anything that greps the loader's output for the literal -`stays NULL` on these two lines should grep for `is NULL at the end of pass 2` instead. diff --git a/.changeset/17178-seed-write-execution-context-export.md b/.changeset/17178-seed-write-execution-context-export.md deleted file mode 100644 index 1ec0b5f451..0000000000 --- a/.changeset/17178-seed-write-execution-context-export.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': patch -'@objectstack/runtime': patch -'@objectstack/verify': patch ---- - -`@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads - -The execution context a seed write must use — `isSystem`, `skipTriggers`, -`seedReplay` — had **no exported form**, so every seeder held a private copy of -it and nothing held the copies equal. There were three on `main`: -`SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), -`SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock -already recorded that it "mirrors" the first) and `SEED_CONTEXT` -(`@objectstack/verify`'s fixture writer, which spelled it a third time -specifically because the runtime kept its copy module-private). - -**Why a shared constant rather than three accurate copies.** `skipTriggers` is -what suppresses "on create" automation for seed rows, and `isSystem` alone does -**not** suppress dispatch. A seed path that lost that flag once seeded with -automation live while the main path had it suppressed — a self-trigger loop that -wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging -defect is a kernel semantic, not a local detail. - -**What is exported, and what deliberately is not.** The **inner** -`ExecutionContext` value, and nothing wrapped around it: - -```ts -import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; - -await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); -``` - -The `{ context: … }` options bag stays at the call site. It is what all three -sites ultimately hand to `insert`, but it is an options envelope rather than the -posture: its type differs per engine method, so freezing one bag onto the -protocol surface would serve `insert` and no other operation, and it is -precisely the convenience bundle this export is not. - -⛔ **No behaviour change.** The value is byte-identical to all three previous -copies, the three flags keep their existing meanings, and no seed path changes -what it writes or how. The three former copies now read this export, so the two -option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` -context is the export itself. - -**Additive, so `minor` on `@objectstack/spec`**: one new name on the existing -`./kernel` entry point, no existing export removed, renamed or narrowed. The -three consumers take `patch` — their published `dist` changes (an import edge, -and the constant now resolves through `@objectstack/spec/kernel`) while their -own public surfaces do not move. diff --git a/.changeset/17210-oauth-register-name-trap-prose.md b/.changeset/17210-oauth-register-name-trap-prose.md deleted file mode 100644 index 986ddc0ea5..0000000000 --- a/.changeset/17210-oauth-register-name-trap-prose.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/client': patch ---- - -`oauth.applications.register`'s docblock says where a plain `name` IS honoured, and that it is not this route - -A caller who wants to name an OAuth client reaches for `name`. On the route this -method posts — the provider's `/oauth2/create-client` — that member is not in -the body schema and is stripped: driven on a real socket, the call answered -**201** and the value was absent from the response, from `applications.get`, -from `applications.list`, and `null` in the `sys_oauth_application` row's `name` -column. Nothing in the answer says so. - -The spelling is not wrong everywhere, which is what made it worth writing down: -`POST /api/v1/auth/sys-oauth-application/register` — the session-required -ObjectStack mount behind the Console's *Setup → OAuth Applications* form — -answered **200** to the same body, mapped `name` onto `client_name`, and set -that column. That mount is `disposition: 'server-only'` in the auth route ledger -and objectstack#17210 ruled it stays that way, so no SDK method builds its URL. - -The docblock now states both halves where the caller reads them: post -`client_name` to name a client from here, and `redirect_uris` must arrive -pre-split — the newline-separated-textarea split is the Console wrapper's, not -this route's. - -Docblock only. No method is added, no request or response type changes, and the -ledger row is untouched — but the text ships inside `dist/*.d.ts` as editor -hover, so it is a `patch` rather than a no-publish change. diff --git a/.changeset/17215-oauth-register-redirect-uris-optional.md b/.changeset/17215-oauth-register-redirect-uris-optional.md deleted file mode 100644 index b8e4f62fe7..0000000000 --- a/.changeset/17215-oauth-register-redirect-uris-optional.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `oauth.applications.register` declares `redirect_uris` optional, matching the body schema of the route it posts to (#17215) - -`ObjectStackClient.oauth.applications.register` declared `redirect_uris` **required**. `POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider`, and that route's body schema declares the member **optional** — so a request the route accepts had no spelling through this SDK. The caller never got a wrong answer; they got a call they could not write. - -## What changes for a caller - -Nothing they have to do. Every existing call still compiles — this only *adds* spellings: - -```ts -// now expressible, and accepted by the route: -await client.oauth.applications.register({ client_name: 'My App' }); - -// unchanged, and still the right call when you have redirect URIs: -await client.oauth.applications.register({ - client_name: 'My App', - redirect_uris: ['https://app.example.com/cb'], -}); -``` - -⛔ Not breaking in this direction — relaxing a required member to optional keeps every existing call valid. Tightening it back later would be breaking, which is why the parity is now pinned. - -## Measured at runtime, not read off a `.d.ts` - -The vendor body schema was re-introspected the way the card's original measurement was taken: instantiate `oauthProvider()`, walk `endpoints`, find the endpoint whose `path` is `/oauth2/create-client`, read `options.body`. At the installed **1.7.3** (the card measured 1.7.2; the package has since moved) the object still declares **21 members and every one of them is optional**, and `body.safeParse({ client_name: '…' })` succeeds with `redirect_uris` absent. - -⚠️ Optional does **not** mean an empty array will do: the vendor refuses `[]`, so when the member is present it must be non-empty. Omitting it and passing `[]` are different requests and only the first is legal. Nor does it mean a client registered without redirect URIs is *usable* — it cannot complete an `authorization_code` flow. The type states what the route accepts, never that every accepted call yields a client fit for every grant; the docblock now says both. - -## Why it was required, for the record - -Not as a guard. It is residue from the method's first commit, which declared `client_name` required too; the same-day follow-up relaxed `client_name` and left this one behind. No comment, test, ADR or review thread ever asserted a reason for it — which is exactly why it read as a defect to the next auditor. - -Nothing else on the signature moves: the other ten members are byte-identical. diff --git a/.changeset/17234-signin-signup-session-envelope.md b/.changeset/17234-signin-signup-session-envelope.md deleted file mode 100644 index ce8d290c5d..0000000000 --- a/.changeset/17234-signin-signup-session-envelope.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): `/sign-in/email` and `/sign-up/email` now attach the `session` their declared `SessionResponse` envelope requires (#17234) - -Both routes answered `{ token, user }` (`/sign-in/email` also carries -`redirect`) with no `session` member anywhere in the body or the response -headers, so `SessionResponseSchema.safeParse` on `auth.login()` / `auth.register()`'s -return value always reported a `data.session` issue — the second of two -departures measured on #17234 (`success` was closed in the previous round). - -**The fix is a read, never an invention.** better-auth stores sessions in the -database by default and `internalAdapter.createSession` is awaited to -completion — including the write — before either endpoint returns its -`{ token, user }` body (measured against the installed `better-auth@1.7.3`, -`dist/db/internal-adapter.mjs:247-319`). So the row the response's own `token` -names is already committed by the time this repo's global `after` hook runs. -The fix reads it back through `internalAdapter.findSession(token)` — the exact -seam `/get-session` already uses for `data.session` — and attaches it. No id or -expiry is ever fabricated; a read that fails for any reason (no -`internalAdapter`, no row, any error) leaves the response exactly as -better-auth wrote it. - -``` -FROM POST /api/v1/auth/sign-in/email -> 200 { redirect, token, user } -TO POST /api/v1/auth/sign-in/email -> 200 { redirect, token, user, session } - -FROM POST /api/v1/auth/sign-up/email -> 200 { token, user } -TO POST /api/v1/auth/sign-up/email -> 200 { token, user, session } -``` - -`session` is the SAME row a following `/get-session` call reads (same `id`, -same `expiresAt`, same `userId`) — one row read twice, not two arrangements — -and `session.token` is the same UNSIGNED credential the body already carried -at `token` / `data.token`, not a second credential this fix introduces. - -⛔ **No wire byte moves on any other member.** `token`, `user`, `redirect` are -byte-identical; `data.token` and the client's auto-`this.token = data.token` -are unchanged and pinned. `auth.me()` / `auth.refreshToken()` (`/get-session`, -#16760) are untouched — this change is scoped to the two credential-issuing -routes. - -This is additive on an already-declared field — `SessionResponseSchema.data.session` -existed in `@objectstack/spec` before this card; the two routes simply did not -serve it. No schema changes, no new exported symbol, no new key on any -published payload. diff --git a/.changeset/17260-object-kanban-quick-add-retired.md b/.changeset/17260-object-kanban-quick-add-retired.md deleted file mode 100644 index 4a611078cd..0000000000 --- a/.changeset/17260-object-kanban-quick-add-retired.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: retire `ObjectKanbanProps.quickAdd` — the `object-kanban` board forwarded it and nothing ever read it (ADR-0049) - - - -**BREAKING** — `quickAdd` is retired from the `object-kanban` component props. Executes the -objectui#8285 director-seat ruling (decision batch #91, 2026-09-08, standing maintainer -delegation), ruled **option B**: the key leaves the board and stays only on the `kanban-ui` -block, where a React host can supply the runtime function the control needs. - -| | before | after | -|:--|:--|:--| -| `object-kanban` | `quickAdd: true` parsed clean and did nothing | refused by the tombstone, with the prescription | -| `kanban-ui` (objectui block) | the control works when the host passes `onQuickAdd` | **unchanged** | - -**What was actually wrong.** Measured at the `.objectui-sha` pin this repo builds against -(`53ded82bf`): the board FORWARDS the key — `ObjectKanban.tsx:931` spreads the authored bag -into `KanbanRenderer`, which passes `quickAdd={schema.quickAdd}` alongside -`onQuickAdd={schema.onQuickAdd}` (`plugin-kanban/src/index.tsx:196`) — but `KanbanImpl` -gates the affordance on **both** (`:355`, `:368`), and `onQuickAdd` is a host-supplied -FUNCTION that JSON cannot carry and that no producer puts on an `object-kanban` node. -`ObjectKanban.tsx` names neither half of the pair (0 occurrences each, against 6 for the -sibling `onCardClick` in the same file), so the gate was permanently false. - -**And the drop was not silent, which is what made it worse than silence.** objectui's html -tier reported the published key as `unknown-prop` — the same diagnostic a typo gets — and -its registry↔spec ledger records it as `ESCALATED (object-kanban.quickAdd — measured NOT -honoured)`. An author following the published contract met a tool that contradicted it, with -nothing in either message to say which side was wrong. The tombstone collapses both halves -onto one answer. - -## What to write instead - -Nothing, on this board: there is no per-column quick-add affordance on `object-kanban` and -there never was one. Delete the key. - -```ts -// before — parsed clean, rendered nothing -{ type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status', quickAdd: true } } -// after -{ type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status' } } -``` - -The control itself is not withdrawn from the platform. It stays on the `kanban-ui` block, -which a React host renders directly and can hand the `onQuickAdd` slot to — that is what the -ruling preserved deliberately. - -Existing sources: `os migrate meta --from 17` lists the mechanical edits; apply them by hand. - -The retirement kit: - -- a `retiredKey()` tombstone on `ObjectKanbanPropsSchema` — `tsc` types the key `never`, and - a value reaching the parse raises the prescription rather than a bare unknown-key verdict -- the D2 conversion `object-kanban-quick-add-removed` (`RETIRED_KEYS_BY_MAJOR[18]` entry - `ui/ObjectKanbanProps:quickAdd`, wired into the protocol-18 chain step) — a **pure lossless - delete**, since the key never had an effect to preserve, scoped by component `type` so the - live `kanban-ui` spelling stays out of its reach -- the `authorable-surface/ui.json` row becomes `ui/ObjectKanbanProps:quickAdd [RETIRED]`, and - the generated reference page prints the prescription in place of the old describe -- the schema docblock's read-point list is corrected in the same stroke: it named `quickAdd` - among the keys reached "via the forwarded schema", a sentence true about the FORWARD and - false about the READ — which is how the key kept re-authorizing itself -- pin tests (`ui/component.test.ts`): the refusal carries the prescription; a clean parse does - not materialize the key; and the control pair separating the tombstone's answer from the - strict unknown-key arm's, so a shape that had merely DROPPED the key could not pass -- no liveness-ledger row (component props are not an enrolled ledger type) and no form or - i18n edit: zero `object-kanban` components are authored anywhere under `examples/` or - `apps/` (control: `object-grid` 3, `object-metric` 8 in the same corpora, same instrument) -- `api-surface/` is unchanged, correctly: it ratchets export existence, and no export leaves — - `ObjectKanbanProps` still exists, one key narrower diff --git a/.changeset/17265-nested-hook-refusal-is-a-rejection.md b/.changeset/17265-nested-hook-refusal-is-a-rejection.md deleted file mode 100644 index 777560ecd9..0000000000 --- a/.changeset/17265-nested-hook-refusal-is-a-rejection.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -A sandboxed hook's business refusal reached through a script action answers 4xx, not `500 INTERNAL_ERROR` - -`POST /api/v1/actions/:object/:action` answered **`500 INTERNAL_ERROR`** when a -`beforeUpdate` hook refused a state transition for a business reason and the -refusal travelled out through the action body's `ctx.api` write. The same refusal -has answered **`400`**, with the hook's sentence verbatim, on `/data` since -objectstack#11588. A 500 tells every client "the platform broke", so a -well-behaved one retries, alerts or pages for a guard that will never say yes. - -**Where the producer was.** Not in the action route's classifier — that read the -shape it was handed correctly, and both sides of the line it pins (`a deliberate -REJECTION is a 400` / `an unexpected FAULT is a 500`) are unchanged. The refusal -arrived already stripped of every mark that says "a body reported this on -purpose", one VM hop earlier: `hostErrorToVm` marked **every** `SandboxError` -crossing into the action body's VM as the sandbox's OWN fault (objectstack#4431) -on an `instanceof` test — and a nested sandboxed hook's refusal *is* a -`SandboxError`, wrapped by the same runner one level down. The pump branch that -reads that marker then discarded `innerMessage`, `code`, `status` and `fields`, -and the classifier read the missing business message as a crash. - -**What changed.** The marker now asks the question the `/data` door asks — -`sandboxBusinessMessage`, objectstack#11588 — instead of testing the error's -class. Both of that predicate's conditions travel, because both are load-bearing: -a capability denial carries no business message and stays a fault, and a nested -body that **crashed** carries `TypeError: …` and stays a fault too. - -**No status was picked for this route.** It matches what `/data` already answers -for the same producer: the status the body declared, or `400` when it declared -none. A refusal that declares `{ status: 409, code: 'RECORD_LOCKED' }` now -reaches the caller as `409 RECORD_LOCKED` instead of losing both. - -**The sentence a caller receives is byte-identical to what the 500 carried** — -this moves the status, not the prose. The flattened `SandboxError: ` name prefix -is stripped on the rejection path by the same helper the fault path already used. - -No authorable key, accept set or export surface moves; no consumer needs a -change. Clients branching on 5xx to decide whether to retry will stop retrying -these refusals. diff --git a/.changeset/17290-insertmany-dropped-fields-name-no-row.md b/.changeset/17290-insertmany-dropped-fields-name-no-row.md deleted file mode 100644 index f85ab720f1..0000000000 --- a/.changeset/17290-insertmany-dropped-fields-name-no-row.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/objectql': patch -'@objectstack/spec': patch ---- - -fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290) - - - -**BREAKING** — `@objectstack/metadata-protocol`'s `insertManyData` no longer hangs -`droppedFields` on each entry of `outcomes`; the response itself carries it, beside -`outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read -the per-row member stops compiling, and the compiler names the site. The set reported -is the same set — what is gone is a per-row attribution that could not be computed -here and was wrong whenever it mattered. Nothing authored or stored changes shape. - -**What it got wrong.** Every create-side strip is the engine's, and its -`onFieldsDropped` event is the UNION over the batch — the listener signature -carries no row index. This seam reconstructed a row set from that union by -asking which rows SUPPLIED each dropped name -(`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that -"the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs -to exactly the rows whose supplied payload carried it". Maintainer ruling C -falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`, -AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned — -recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where -a hook stamps a protected key on some rows and not others: - -- row A supplied `approval_status`, no hook write ⇒ stripped, enters the union; -- row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and - written**; -- and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]` - on a record that still held `approval_status`. - -A row the batch culled before the strip ran (a per-row validation failure) was -named on the same test, having dropped nothing at all. - -⇒ A wrong attribution costs the reader a wrong investigation, and the import -surface — which prefers this path over `createManyData` — is the consumer most -likely to act on it while reconciling what landed. - -**Why not attribute per row instead.** The honest set is `{rows whose payload -carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second -half is computed per row upstream but does not cross this seam. The outcome's -own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED -over exactly the keys the strip took, and a stripped `autonumber` is refilled by -`applyAutonumbers` — so on both, the key is PRESENT on the row that really did -drop it, and a post-hoc "is the key still there?" check would delete true -attributions while leaving the hook-exempt false one standing. Comparing values -fails on the very case `hookWrittenKeys` exists for: the hook assigning the -value the caller also sent. Restoring row precision means giving the engine's -drop report a per-row channel, not a reconstruction at the call site. - -**Prose corrected with it**, by CLAIM rather than by spelling — the docblock -that authorised the inference is the thing that re-authorises the next author: -`insertManyData`'s own docblock and `createManyData`'s parenthetical -(`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing -sentence, `engine.insertMany`'s docblock claim that "a caller holding the input -rows can attribute each name back to the rows that carried it" -(`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and -`CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical -(`@objectstack/spec`, a string printed AT the customer). - -**Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`, -and they always could: each row is its own `engine.update` / `engine.insert` -call, so that call's events are that row's — earned mechanically, not inferred. -`createManyData`'s aggregated shape is untouched. No strip changes, no row -changes, and the same field names are reported. diff --git a/.changeset/17299-view-union-retirement-prescription.md b/.changeset/17299-view-union-retirement-prescription.md deleted file mode 100644 index 7460867e9b..0000000000 --- a/.changeset/17299-view-union-retirement-prescription.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A retirement prescription is the top-level message a `PUT /api/v1/meta/view` 422 carries, instead of sitting buried in `invalid_union` sub-errors - -`ViewMetadataSchema` is the union behind the runtime write door — the one an -MCP/AI author reaches, with no CLI anywhere on the path. A shape-level refusal -raised inside one of its four branches did not become the union's message: the -top level read zod's bare `Invalid input`, and the upgrade prescription sat at -`error.issues[0].errors[k][j].message`. Every retirement this platform wrote for -list and form views was therefore invisible at the one door its intended reader -uses — shipped behaviour since 17.0.0 for `virtualScroll`, `striped` and -`bordered`, not a recent regression. - -The lift is family-wide rather than per case. `retiredKey()` raises one declared -issue shape — `code: 'invalid_type'`, `expected: 'never'`, with the prescription -as its `message` — so the union's existing `.check()` now lifts that message -verbatim from the branch the body claims. The next retirement on this shape is -surfaced without anyone remembering to wire it, which is what a per-case fix -could not promise. - -What does not move: the accept/reject verdict of every body (the lift runs after -the union has reached its verdict and writes one string), the issue codes, the -nested `errors` array and its order, and the message of every refusal that is -not a retirement — a plain shape error still reads `Invalid input`, and a -curated unknown-key refusal still reads exactly as it did. That boundary is -measured, not asserted: `strictObject()` closes a shape with a `z.never()` -catchall, so the union's members reach 67 `never` leaves of which only 8 are -tombstones — zod folds a rejecting `never` catchall into `unrecognized_keys`, so -the other 59 never raise the lifted shape at all. diff --git a/.changeset/17306-screen-field-bound-help-lookup.md b/.changeset/17306-screen-field-bound-help-lookup.md deleted file mode 100644 index cc9b106459..0000000000 --- a/.changeset/17306-screen-field-bound-help-lookup.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor ---- - -A flow screen field can now express a numeric bound, help text and a lookup target — spelled with the object field's own key names - - - -`ScreenFieldConfigSchema` was `.strict` over exactly -`name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`, -so three ordinary authoring intents had **no expression at all**. They did not -degrade quietly — `max`, `helpText` and every lookup-target spelling were -refused BY NAME — but a loud refusal with no landing key is still a dead end, -and the reference app worked around all three in prose: a discount ceiling -interpolated into the `label` and the `placeholder` (with a comment explaining -why there was no `max`), and a `type: 'lookup'` field whose `placeholder` asked -a human to type a record id because the picker could not be pointed anywhere. - -Four keys land, and **their names are derived from `FieldSchema`, not invented** -— one platform, one field vocabulary, so a name learned on an object field means -the same thing on a screen field: - -| Key | Derived from | | -|:---|:---|:---| -| `min` / `max` | `FieldSchema.min` / `.max` | the bound pair | -| `inlineHelpText` | `FieldSchema.inlineHelpText` | help under the input — `FieldSchema` renames `help`/`helpText`/`hint`/`tooltip` onto it, so a screen-local `helpText` would have been a second contract for one question | -| `reference` | `FieldSchema.reference` | the object a `type: 'lookup'` field picks records from | - -**The bound is enforced, not advisory.** It rides to the client on -`ScreenFieldSpec` so the user is stopped at the input, **and** -`validateScreenInputs` re-checks it when the run resumes (`min_value` / -`max_value`, both already in the ADR-0114 D2 field-error catalog — no new error -code). A screen field's declared contract is the only contract behind it, so a -bound the dialog alone applied would be bypassed by any caller posting to -`resume` directly — the gap #4477 closed for `required`. - -That sentence needs no "when the value is a number" qualifier, because the -value SHAPE is checked first: on a `type: 'number'` field a present value that -is not a finite JSON number is refused with `invalid_type` (also already in the -catalog — still no new code), ⛔ **not coerced**. Before this, a bound pass that -compares numbers was satisfied by anything that never reached it, so `"25"` -under a `max` of `20` was conformant. One member of the open `type` vocabulary -is read as a value domain; every other widget hint stays open, and a bound on a -non-numeric field still constrains nothing. - -**Delivered with its rendering, not ahead of it.** The executor forwards all -four onto the wire and the Studio designer form offers all four as repeater -columns; `builtin-node-form-zod-ledger.test.ts` reconciles the two key sets -against the Zod in both directions, so a key declared here and absent from the -form fails that test rather than shipping as a field nobody can author. - -**BREAKING** in the accept-set sense, in TWO places — landing as `minor` on -both packages because the launch-window guard (`check-changeset-no-major`) -keeps breaking changes off `major` outside pre-mode, not because the narrowing -is small. Both were ruled (maintainer ruling A′, decision batch #130 item 1, -2026-09-13); this release is **not** purely additive. - -1. `reference` is **required** when `type` is `lookup`, as it is on an object - field. A picker with no target object resolves nothing — ADR-0078's own - example of silently-inert metadata — and a degraded shape that ships today - is not a reason to bend the contract to it. A stored flow with a bare - `lookup` screen field parsed before and does not now. There is **no lossless - conversion**: nothing in the metadata says which object the author meant, so - this is an ADR-0087 **semantic** migration entry — a structured TODO - (`screen-field-lookup-reference-required`) that names the flow and the field - for a human to answer — and ⛔ never a D2 conversion that would have to - invent a target. -2. A non-number submitted for a `type: 'number'` screen field is refused on - resume (`invalid_type`) instead of passing silently. A resume bag that was - accepted before can be refused now; it was never doing what its author - declared. - -Everything else is additive: the bound itself fires only on a field that -declares one, which nothing did before this release. - -The neighbouring spellings are refused **with their landing key** rather than -with a bare key list: `help`/`helpText`/`hint`/`tooltip` name `inlineHelpText`, -and `object`/`referenceTo`/`targetObject`/`lookupObject`/`relatedTo`/`target` -name `reference`. ⚠️ `object` means different things one level apart — on the -screen **node** it renames to `objectName`, on a screen **field** it can only -mean the lookup target — so it earns its own row on both. - -**One stale claim corrected in passing, because this change falsified it.** The -flows translation surface documented `help`'s exclusion as *"`ScreenFieldConfig` -declares nothing help-shaped at all"*, in `translation.zod.ts`'s guidance string -(which enumerated the old key set verbatim), its doc block, and -`i18n-resolver.ts`'s `FLOW_SCREEN_FIELD_COPY_KEYS`. The screen field now -declares `inlineHelpText`, so the copy is real. The exclusion **stands** — the -flows bundle still carries `label` and `placeholder` only, and growing that face -is a ruled step against the #7646 enumeration, not a resolver-side accretion — -but its reason is now stated as a not-yet instead of telling an author the field -has no help copy when it has. ⛔ No translation key was added and no resolver -behaviour moved. diff --git a/.changeset/17319-action-bulk-dispatch-contract.md b/.changeset/17319-action-bulk-dispatch-contract.md deleted file mode 100644 index a2a9ddf6df..0000000000 --- a/.changeset/17319-action-bulk-dispatch-contract.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor ---- - -An action can now **declare which bulk dispatch contract its body is written for**, and a list view that wires it the other way is refused at authoring time instead of handing the body the opposite input in silence. - -A list view has always been able to wire the same declared action two ways, and the two deliver opposite shapes to the same body: `bulkActions: ['']` promotes the action to a def and dispatches it **once per selected row** (that row's `recordId`, no `_selectedIds`), while a `bulkActionDefs` entry with `execution: 'aggregate'` makes **one** dispatch for the whole selection (every id in `params._selectedIds`, no `recordId`). The action declared neither, so both mismatches failed quietly and in opposite directions — an aggregate body wired bare-string read `_selectedIds` as `undefined`, fell into its single-record branch and reported success for one row out of ten; a per-record body wired aggregate found no `recordId` and threw its own "nothing selected", which reads like a selection bug. Nothing caught either: `recordId` and `_selectedIds` are both built-in action params (ADR-0104), so the strict params gate admits either bag without a word, and the wiring lives on the view while the declaration would live on the action, so no single parse has both halves. - -- **`ActionSchema` gains `execution`**, and it is `bulkActionDefs`' own vocabulary — the same key, the same two values (`'perRecord' | 'aggregate'`), the def's `BulkActionExecutionSchema` **imported rather than re-declared**, so there is no second spelling to drift. The near-miss keys (`dispatch`, `dispatchContract`, `bulkExecution`, `bulkDispatch`) rename onto it; ⛔ `mode` deliberately does **not**, because on an action `mode` is a declared key of its own. -- **`@objectstack/lint` gains `action-dispatch-contract-mismatch`** (severity `error`), a member of the reference-integrity suite, so it runs on `os validate`, `os lint` and `os compile` at once. It names the action, the view and **both** contracts — the declared one and the wired one — and offers both ends of the fix, because which end is wrong is the author's call. It judges every list tier: a view's `list`, each `listViews.`, and an object's own `listViews`. -- **⛔ No silent default.** `execution` is optional and an action that omits it is *undeclared*, never defaulted to a contract — which is also the honest state of a body written to serve both (it reads `recordId` *and* `_selectedIds`), and why no third enum member was added. Existing sources are migrated by the new ADR-0087 semantic entry `action-bulk-dispatch-contract-undeclared`, which derives the declaration from the view wirings where they are unambiguous and hands back a structured TODO where one action is wired both ways. - -Nothing about dispatch changes: this release adds a declaration and a build-time refusal measured against it. Existing apps are unaffected until they declare the key — the new rule has nothing to judge on an undeclared action, by construction. diff --git a/.changeset/17320-filter-rule-array-guidance.md b/.changeset/17320-filter-rule-array-guidance.md deleted file mode 100644 index 8efc475d89..0000000000 --- a/.changeset/17320-filter-rule-array-guidance.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The seven converged rule-array `filter` doors name the ViewFilterRule array form when they refuse the record form - -Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the -objectui#6206 family — `ElementDataSourceSchema.filter` (`ui/page.zod.ts`) and -the `object-grid` / `object-metric` / `object-kanban` / `object-calendar` / -`element:number` / `element:record_picker` rows of `ComponentPropsMap` -(`ui/component.zod.ts`). Each previously accepted the MongoDB-style record -(`{ status: 'active' }`), and each now refuses it — measured on the built -artifact, with exactly one issue apiece: `invalid_type` at `filter`, *"Invalid -input: expected array, received object"*, and nothing else. - -The prescription for that transition was already written down twice, in two -places a parse never reaches: every one of the seven `.describe()` strings, and -in full in the three `18.*-filter-rule-array` semantic migration entries. -Nothing bridges `.describe()` into a zod issue and this package installs no -global error map, so the one population whose metadata the convergence broke — -the authors, human and AI, who wrote the previously-legal form — received the -single sentence that does not say what to write instead. - -Each of the seven now answers that value with the new spelling, through the -zod-v4 `{ error }` param this package already uses for targeted guidance -(`shared/expression.zod.ts`, `ui/view.zod.ts`, `shared/strict-object.ts`): - -> `filter` on this `object-grid` takes the ViewFilterRule ARRAY form -> `[{ field, operator, value }, ...]`, and this value is the MongoDB-style -> record form this door took before the one-filter-orthography convergence. -> Write one rule per record key — they AND — so this filter becomes -> `[{ field: 'status', operator: 'equals', value: 'active' }]`. Legacy operator -> shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. -> Full conversion table: migration -> `element-data-source-and-object-block-filter-rule-array`. - -Following `strictObject`'s model rather than transcribing a sentence seven -times: the rule shape is read from `ViewFilterRuleSchema`'s own shape, the -canonical operator is `normalizeFilterOperator('eq')` — the same fold the door -itself runs — and the worked rewrite is computed from the author's own record, -so the example names their fields. A pin holds each door's `migration` id equal -to a real registry entry and each door's `surface` equal to the one its own -`strictObject` declaration registered. - -⛔ No accept set moves. The doors refuse exactly the shapes they refused -before, the generated `json-schema/` and `authorable-surface` artifacts are -byte-identical after the change, and the map returns `undefined` for everything -that is not a plain record — so an array author's element-level issues -(`filter.0: Invalid option: expected one of "equals"|…`) and a non-record value -(*"expected array, received string"*) still arrive in zod's own words. - -**Shipped, which is why it carries a changeset rather than `skip-changeset`.** -Measured on the built artifact after both tsup passes finished: the new message -text is present in **18** published files of `npm pack --dry-run`'s 2012, the -test-only text is present in **0** (negative control), and a pre-existing -shipped string reaches **62** as the lit control proving the scan reaches. -`src/ui/page.zod.ts` and `src/ui/component.zod.ts` are also shipped as source -by `files[]`'s `src/**/*.zod.ts`. diff --git a/.changeset/17328-colspan-rule-withdrawn.md b/.changeset/17328-colspan-rule-withdrawn.md deleted file mode 100644 index 7647e1cdc6..0000000000 --- a/.changeset/17328-colspan-rule-withdrawn.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint)!: `absolute-colspan-discouraged` is withdrawn — its premise was measured false in a browser, and the alternative it recommended measured worse than the thing it warned about (#17328) - - - -**BREAKING** — `@objectstack/lint` no longer exports `FORM_COLSPAN_ABSOLUTE`, and -`validateFormLayout` no longer emits the `absolute-colspan-discouraged` finding. A -TypeScript consumer that imported that constant (to suppress the rule, or to route it) -stops compiling on the import, and the compiler names the site — a more precise channel -than any release note. Authored metadata is untouched: `FormField.colSpan` is unchanged -and still valid. - -The rule fired on **every** authored `colSpan`, `colSpan: 1` included, and asserted a -rendering consequence: the form's column count is derived per surface (mobile 1 / modal 2 -/ page 3-4), so a fixed span "only aligns at one width". Measured in Chromium on a real -authored 3-column section at all three of the widths that sentence names (390 / 720 / -1700), that misalignment does not happen. The renderer emits one container-query-scoped -span class clamped to the section's declared column count, so the cell starts at a real -column boundary at every width and rendered overflow is 0px in every configuration — -including `colSpan: 4` in a 3-column section, the case that would overflow if the clamp -did not work. The clamp is precisely why the claim was false, and the rule's own file -already recorded the clamp a few lines above the claim. - -The hint was the sharper defect. It steered authors to `span: 'full'`, which compiles to -the same class as `colSpan: 4` (`@2xl:col-span-3`) and measures byte-identically: the rule -warned about one spelling and recommended the other, and they are the same thing. At the -modal width `span: 'full'` renders pixel-identical to authoring nothing at all, so an -author who complied was left worse off than one who ignored it. - -With no authored `colSpan` shape left that misbehaves there was nothing to re-ground, so -the rule is withdrawn rather than narrowed: `colSpan: 1` emits no class at all, a -`colSpan` within the column count renders exactly as authored, and one above it clamps. -Every test that pinned the rule's wording or its firing set was re-judged in place with -the reason recorded, never deleted, and each re-judged pin is paired with a live finding -on the same fixture so that a walk which stopped reaching the site could not pass as a -withdrawal. diff --git a/.changeset/17329-seed-settled-ipc-message.md b/.changeset/17329-seed-settled-ipc-message.md deleted file mode 100644 index 2d44c38e3e..0000000000 --- a/.changeset/17329-seed-settled-ipc-message.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent process when one holds the channel. A script that spawns a dev server can finally wait for the boot to finish without reading the child's output. - -`✓ Server is ready` is true about the HTTP server and says nothing about the app. Seeding races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) and past it finishes in the background, so the banner can be a minute ahead of the seed's own result — measured downstream at **82 seconds of silence after the banner, then 120 `ERROR` lines**. The same command on the same corpus settles before the banner on a machine where the seed fits its budget, so the defect is invisible on exactly the boxes that would have caught it. Everything that distinguishes the two cases arrives on the child's inherited stdio, and reading that costs the boot its TTY. - -- **The producer is not new.** `@objectstack/runtime` already declares every seed source and settles it at the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This is the hop outward: the CLI subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. No service is registered and no tally is mutated — the contract is read-only by design. -- **Sent once, and never before `objectstack:listening`.** Seeding that settles during `runtime.start()` is latched and released after the bound port is published, so a parent that waits for the listening message and only then listens for the settle cannot miss it. -- ⛔ **Keyed on `inFlight`, not `pending`.** Multi-tenant replay and `skipSeedData` register a seed source and deliberately never run it, keeping `pending` above zero for the life of the process. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this closes, one level up. Those boots get the message with `suppressed` reasons attached instead, so a consumer can say *why* no rows landed. -- **Failure settles too.** A seed that failed has still come to rest; withholding there would recreate the hang. `ok` is a verdict on the per-source counts the boot recorded, and the message carries those counts. -- **The over-budget banner no longer omits seeding.** `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was ABSENT and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing`, with a line saying seeding continues in the background; suppressed sources are named rather than reported as pending. - -⛔ An ipc channel is **not** made a requirement of either command: `process.send` is undefined under an ordinary terminal boot, both sends are no-ops there, and no byte of that transcript changes. Nothing in the existing `objectstack:listening` publication moves. - -Note that `os dev` consumes `objectstack:listening` itself (it is how the bound-port readout and the MCP connect hint learn the real port) and relays only `objectstack:seed-settled`. Spawn `os serve` directly to receive both in one place. diff --git a/.changeset/17333-date-macros-header-adr-0053.md b/.changeset/17333-date-macros-header-adr-0053.md deleted file mode 100644 index 741f288f46..0000000000 --- a/.changeset/17333-date-macros-header-adr-0053.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`date-macros.zod.ts`'s module header states the ADR-0053 D-D upper-bound rule the platform implements, instead of the rule it replaced - -The header's "Out of scope" block told an author that on a `datetime` column -`<= {current_year_end}` **stops at midnight on the 31st**, and prescribed the -half-open `< {next_year_start}` as the fix. That is the pre-ADR-0053 reading. -The platform rule has been the opposite since #3777: a bare `YYYY-MM-DD` used -as an upper bound denotes the WHOLE day, compiled half-open to the next -calendar day. It is stated once, in -`packages/spec/src/data/calendar-day.ts` (ADR-0053 D-D), whose own operator -table reads: - -| Operator | A bare `YYYY-MM-DD` on a `datetime` column means | -|---|---| -| `$gte` / `$gt` / `$lt` | that day's `00:00:00.000` — already correct as written | -| `$lte`, a `$between` max, a `dateRange` end | the WHOLE day → compile `< nextUtcCalendarDay(day)` | - -and which `packages/spec/src/data/temporal-conformance.ts` pins cross-driver: -the case *"datetime: bare-day `$lte` keeps the whole final day"* expects -`d_mid` (09:15 on the boundary day) and `e_late` (21:40 on it) as members. - -**Why this header and not a note.** It is the doc comment on the vocabulary an -AI author reaches for, and it is the one place in the tree that says what a -`*_end` token does on the right-hand side of an operator. Both the old -prescription and the correct spelling parse, run and return rows, so nothing -downstream reports the mismatch — the author simply carries the wrong model -into every later filter. - -**What the correction does.** The load-bearing first clause is kept verbatim: a -`*_end` token IS the period's last calendar DAY. What follows now **cites** -`calendar-day.ts` rather than restating the rule, so the two statements cannot -drift apart again, and the half-open detour is refused by name for the reason -it is now wrong — the widening is already applied. - -⛔ No behaviour changes. The diff is comment lines only; no schema, accept set, -authorable key or published payload moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` lists -`src/**/*.zod.ts`, so this file ships verbatim as source, and the header is the -first thing in it. - -The generated reference page `content/docs/references/data/date-macros.mdx` -carried the same sentence — it is rendered from this header and is marked -AUTO-GENERATED — and is regenerated here with -`pnpm --filter @objectstack/spec gen:schema && … gen:docs`. diff --git a/.changeset/17343-multi-valued-boolean-contains-membership.md b/.changeset/17343-multi-valued-boolean-contains-membership.md deleted file mode 100644 index b4953d65f3..0000000000 --- a/.changeset/17343-multi-valued-boolean-contains-membership.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -A `multiple: true` boolean column keeps its `$contains` membership filter - -A `multiple: true` field is stored as a JSON TEXT array, and on such a column -`$contains` is not a substring test — it is the MEMBERSHIP spelling, the one -operator #7398 left working there after refusing the equality family. The -declared-type gate added in #14079 fired on the boolean limb regardless of -storage shape, so a membership filter over a `multiple: true` `boolean` or -`toggle` column compiled to the always-false constant: - -``` -{ flags: { $contains: 'true' } } -- select * from `probe_tbl` where 1 = 0 (matched nothing) -+ select * from `probe_tbl` where `flags` GLOB '*true*' (matches the rows whose array holds it) -``` - -That is the fail-CLOSED direction: the query returns a `200` with no rows, -byte-identical to a filter that legitimately matched nothing, so an author sees -"no matching records" and doubts their data rather than the filter. Both -registry fills — `initObjects` and `registerExternalObject` — were affected, and -both are fixed, because the repair is at the predicate they share. - -The same shape on a `multiple: true` NUMBER was already correct (its registry is -filled `!field.multiple`), and #15683 spelled the equivalent carve-out for the -temporal limb at the predicate. This change spells it on the boolean limb, the -one that had neither. `booleanFields` itself is deliberately unchanged: it is a -read-coercion registry, and the three other seams that read it — the Postgres -aggregate cast, the presentation-kind door and `formatOutput`'s row pass — are -about "this column holds a boolean", which a multi-valued column still does. - -⚠️ Not a widening of the gate: a SCALAR `boolean` / `toggle` column still -answers the declared no-match for every positive text operator and `$notContains` -its exact complement, unchanged. What moves is exactly the JSON-column cell. diff --git a/.changeset/17369-organizations-entitlement-boundary-prose.md b/.changeset/17369-organizations-entitlement-boundary-prose.md deleted file mode 100644 index dda4928577..0000000000 --- a/.changeset/17369-organizations-entitlement-boundary-prose.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/verify": patch ---- - -Comment-only correction: the reason `bootStack`'s cross-tenant proofs stand in for `@objectstack/organizations` is now stated as the true one. - -Those doc comments said the enterprise multi-organization runtime was **cloud-private / not installable in this workspace**. ADR-0132 falsified that: the runtime is open core, Apache-2.0, and published on npm. The effect they describe has not changed, so the text now gives the reason that is actually load-bearing — **ADR-0132's entitlement boundary forbids any framework package DECLARING `@objectstack/organizations`** (`packages/plugins/organizations/src/no-framework-dependents.pin.test.ts`, its mechanical half: "Apps declare it; packages do not"), because the commercial repository ships a licence-gated subclass under the same package name. So `packages/verify` cannot depend on the runtime and cannot resolve it, the `'posture-only'` stand-in stays exactly what it was, and the proof that the real plugin walls tenants still lives in cloud's `security-enterprise` multi-organization integration test. - -⛔ **No behaviour, no dependency and no public surface moves.** `BootOptions.multiTenant` accepts and does the same things it did; the only shipped bytes that change are the doc comments carried into `dist/index.d.ts`. Apps that mount the runtime keep declaring it in their own `package.json`, which is and remains the supported wiring. diff --git a/.changeset/17385-chartconfig-liveness-drill.md b/.changeset/17385-chartconfig-liveness-drill.md deleted file mode 100644 index a3c8b9e838..0000000000 --- a/.changeset/17385-chartconfig-liveness-drill.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -**Clause-②: no** — no schema key moves, no accept set widens or narrows, no export changes. This is the liveness ledger stating what the renderer actually does with an authored `chartConfig`, at one verdict per key instead of one blanket verdict for fourteen. - -`packages/spec/liveness/dashboard.json`'s `widgets.chartConfig` row is **drilled**: it now carries `children`, one status + evidence per `ChartConfigSchema` key, re-measured against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. The row ships — `packages/spec` publishes `liveness/` whole — so this changeset is a measurement, not a convention: `npm pack --dry-run` puts `liveness/dashboard.json`, `liveness/README.md` and `liveness/state-counts.md` in the tarball (38 files under `liveness/`), and the fourth changed path, the undrilled-containers baseline under `scripts/`, is not in it (0 files under `scripts/`). - -Per-key verdicts, all pinned in the renderer repo: - -- **12 live.** Nine chrome keys are lowered onto the chart schema by `chartConfigPresentation`, one guard each — `title`, `subtitle`, `description`, `colors` (split two ways into the positional palette and the per-category map), `height`, `showLegend`, `showDataLabels`, `annotations`, `interaction`. `xAxis`, `yAxis` and `series` join them by a different route: `mergeAuthoredPresentation` merges their **presentation** onto the bindings the dataset selection derived, dropping exactly the two binding keys `ChartAxis.field` and `ChartSeries.name` so that series membership and the plotted column stay with the dataset. -- **2 dead.** `chartConfig.type` parses and does nothing on a dashboard widget — the widget's own `type` picks the chart family — and `chartConfig.aria` has no reader on either face: the chart implementation declares no `aria` prop and the ARIA injection reads the flat `ariaLabel` / `ariaDescribedBy` / `role`. Both are pinned as **negatives** by name in the renderer's own tests, which is what makes them re-askable rather than merely asserted. - -Neither `dead` verdict is acted on here. Recording a verdict is what feeds the ADR-0049 enforce-or-remove worklist; executing one moves a published accept set and is a separate, ruled piece of work. - -The drill also makes six containers one level further down visible for the first time (`xAxis`, `yAxis`, `series`, `annotations`, `interaction`, `aria` — 39 child keys). They are **recorded** in the shrink-only undrilled-containers baseline rather than drilled: fanning this row's verdicts down over them would manufacture verdicts with no evidence behind them, which is the one thing the drill rule forbids by name. diff --git a/.changeset/17409-scope-roots-baseline-docblock.md b/.changeset/17409-scope-roots-baseline-docblock.md deleted file mode 100644 index 76f54d123d..0000000000 --- a/.changeset/17409-scope-roots-baseline-docblock.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/formula': patch ---- - -`SCOPE_ROOTS`'s docblock says it is a **baseline**, not a per-surface accept set, and points at where the per-surface verdict actually lives - -The exported `SCOPE_ROOTS` constant carried a docblock that made **a false statement about itself**. Its opening line read *"Namespace roots that a `record`-scoped CEL site may legitimately reference"* — which, read alone, is exactly the per-surface accept-set reading. Ninety lines below, the companion block asserted *"This list is a 'never faults' BASELINE, not a per-surface contract — **the doc-comment above says so**"*. The doc-comment above did not say so; it said close to the opposite. - -**This is not a docs nit, and the evidence is a card.** The accept-set reading is what a downstream seat took away, and it generated a cross-repo card filed against this package (this one) about a lint/runtime disagreement that is not a disagreement at all: the baseline declares a root, the per-surface gate refuses it, and both are correct. - -- **The opening line now states the contract it actually is**: the roots the strict check env declares, so that naming one is never itself a fault — and explicitly ⛔ *not* a claim that any surface **binds** the root. -- **It points at the per-surface authority by name**: `@objectstack/lint`'s `fieldRuleRootIssue`, judged against that surface's own closed `FIELD_RULE_BOUND_ROOTS` (`record` / `previous` / `parent`). A reader asking "may THIS surface reference this root?" is now sent one hop to the symbol that answers it, instead of reading the answer off this list. -- **It names `data` as the standing example** of a root this list declares and the field-rule surface does not bind — the two answers doing their separate jobs, ⛔ not something to repair by editing this list. -- **The self-reference is now true.** The companion block cites `SCOPE_ROOTS`'s own doc-comment, which now opens by saying exactly what the citation claims it says. - -⛔ **Zero behaviour change.** `SCOPE_ROOTS` keeps all **27** members, byte for byte — no member is added, removed or reordered, and ⛔ `app` is not added (objectstack#16420 closed `not_planned` on that and this does not reopen it). Narrowing was refuted by measurement rather than by preference: six `*.form.ts` metadata-form modules in this repo carry live `data.` predicates. The diff is comment lines only. - -**This publishes, which is why it is `patch` rather than `skip-changeset`.** `@objectstack/formula`'s `files[]` ships `dist`, and this TSDoc is emitted into the built declarations — measured on the built artifact at three readings: the new text's distinctive phrase present at 1 in both `dist/index.d.ts` and `dist/index.d.mts`, an untouched neighbouring sentence from the same docblock present at 1 as the lit control, and a fabricated phrase at 0 as the dark control. The companion block is a plain `/* */` comment attached to no declaration and reads 0 in `dist` — it is the half that does not ship, and the half that does is the half that was wrong. diff --git a/.changeset/17410-generate-reserved-word-barrel-refusal.md b/.changeset/17410-generate-reserved-word-barrel-refusal.md deleted file mode 100644 index 27e865a1cc..0000000000 --- a/.changeset/17410-generate-reserved-word-barrel-refusal.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli)!: `os generate` refuses a name whose barrel alias no consumer could import by name (#17410) - -`os generate view class` exited **0** and wrote `export { default as class } from './class.view';`. That line parses — an ES module export clause admits a reserved word as a `ModuleExportName` — so both landed layers admitted it, each correctly by its own terms: the #16726 charset gate because every character of `class` is a lowercase letter, and the #16541 parse check because the bytes really are parseable TypeScript. The import side is not: `import { class } from './views'` needs an `ImportedBinding`, and a reserved word is not one. So the command reported success and produced a barrel entry nothing can name, with the failure deferred into the author's own file where it reads as their mistake. - -A third layer now stands behind those two. After the identifier is derived and before anything is written or previewed, the barrel alias is put through TypeScript **in the exact position a consumer must write it**, and the command refuses when the compiler will not take it — naming the constraint, showing the line that would have been written, and writing nothing. This delivers the #16726 ruling's own closing sentence, 「`os generate view class` is therefore refused at the door rather than emitting a barrel line that binds a reserved word.」, which the charset mechanism specified in that same ruling could not. - -⛔ **No third charset** — the #16726 ruling forbids one and none is added: no character is judged. ⛔ **Nothing is rewritten.** Emitting a non-reserved alias while keeping the authored name was the other option and it loses on the reasoning that already refused option B: it decouples the name the author wrote from the name that gets emitted, silently. So this refuses, and the name you author stays the name that lands. - -**What this narrows:** 46 names — the 36 always-reserved words (`class`, `new`, `enum`, `default`, `import`, …) plus the ten reserved because a module is automatically in strict mode (`let`, `yield`, `static`, `implements`, `interface`, `package`, `private`, `protected`, `public`, and `await`, reserved at a module's top level). Every one is charset-legal and every one used to reach `exit 0` for the six generators that suffix their `const` binding (`view`, `action`, `flow`, `dashboard`, `app`, `skill`). The seventh, `object`, binds the bare identifier, so the parse check already refused **some** of them there — but only the always-reserved ones: `os g object let`, `os g object yield` and `os g object static` also exited 0, because a strict-mode reservation is a semantic diagnostic and that check is syntactic. Pick a name that survives as an import binding — `os g view order_line` works, and binds `orderLine`. - -**This is an observable change to accepted input:** those 46 names exit **0** today and will exit non-zero after this lands. Every one of them produced a barrel entry no consumer could name, so this is the fix rather than a break — but if you script `os generate`, a name in that set now stops the command instead of writing an unusable file. - -**One durability note.** The refused set is decided by the TypeScript compiler, asked in position, rather than by a list this package keeps — which is why it is right in both directions today. The consequence is that a TypeScript upgrade can move it: a word that becomes reserved starts being refused, and a word that stops being reserved starts being accepted. Both are correct, neither is a regression, and neither is predicted by a changeset. - -**What this deliberately does NOT narrow:** contextual reserved words. `type`, `as`, `from`, `async`, `get`, `set`, `of`, `keyof`, `readonly`, `satisfies`, `infer`, `declare`, `namespace`, `using`, `accessor`, `undefined`, `arguments`, `eval` and the rest are legal import bindings, they generate today, and they still generate. Refusing one of them would break a name that works — the expensive failure direction, and the one a hand-written keyword list gets wrong. There is no keyword list here for exactly that reason: a list is simultaneously too narrow (it stops at the obvious 36 and ships the defect for the other ten, which a syntactic-only check cannot even see, because the compiler reports strict-mode reservations as semantic diagnostics) and too wide (it swallows the contextual set). The judge is the compiler, asked in position. - -⛔ Neither layer in front is relaxed or reordered. `os g object class` still meets the parse check's own diagnostic in the compiler's words, a name outside the charset still meets the schema's own pattern, and the new layer is asked last, so it can only narrow what all three would otherwise have admitted. - - diff --git a/.changeset/17416-packages-get-version-scope.md b/.changeset/17416-packages-get-version-scope.md deleted file mode 100644 index 627bd47da4..0000000000 --- a/.changeset/17416-packages-get-version-scope.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): `GET /api/v1/packages/:id` honours `?version=` instead of silently ignoring it (#17416) - -The route accepted a `?version=` query parameter and the only surface serving it -never read the parameter. A caller asking for a version that is not installed -was answered `200` with the **installed** row, and nothing in the status, -headers or body distinguished that from a version-scoped read that actually -happened. - -The parameter is not hypothetical traffic: `ScopedEnvironmentClient.packages.get` -(`@objectstack/client`) declares `version?: string` and appends it, so the SDK -has been sending a parameter the runtime dropped. The handler that honoured it -— the REST registrar's twin of this route — was removed with the duplicate -response shape, and the dispatcher's `/packages` domain never had that read to -inherit. - -``` -FROM GET /api/v1/packages/com.acme.crm?version=99.0.0 (1.0.0 installed) - -> 200 { data: { manifest: { version: "1.0.0" }, … } } - -TO GET /api/v1/packages/com.acme.crm?version=99.0.0 - -> 404 { error: { message: "Package 'com.acme.crm' version '99.0.0' not - found — installed version is '1.0.0'" } } -``` - -**What does not change.** The unversioned read is untouched, down to the row and -the writability verdict it stamps — pinned as the lit control beside the new -assertions, because a green on only the scoped path would also pass with the -ordinary read broken. `?version=` naming the installed version is served -exactly as the unversioned read is, and so is `?version=latest`: the deleted -handler read `requested.value || 'latest'` and its store resolved `latest` to -the newest row, so "no version" and "`latest`" named one request there and name -one request here. An id the registry does not hold keeps its existing 404 -wording whether or not `?version=` rode along — a package that is not installed -cannot be at the wrong version. - -**This is request-side only.** The response shape is not touched, so the route -still answers with exactly one body shape; comparison is exact string equality -on the version, the same predicate the durable package store uses (`AND version -= ?`), so the two answers to "is this package at version v" cannot drift into -semver-range semantics at one of them. - -A repeated `?version=a&version=b` is no longer resolved by silently choosing -one — it is answered with a refusal naming what was seen. The repo's one rule -for a repeated single-valued parameter answers `400 VALIDATION_ERROR` and is -the right end state for this door too; it is not restated here, because the -helper that owns that rule and its message is not exported from -`@objectstack/rest`. diff --git a/.changeset/17424-liveness-depth-two-recursion.md b/.changeset/17424-liveness-depth-two-recursion.md deleted file mode 100644 index 70b1e002b3..0000000000 --- a/.changeset/17424-liveness-depth-two-recursion.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The liveness ledger's published README no longer declares a one-level drill — the walk follows a nested `children` map as deep as the ledger declares, and says so - -`check-liveness.mts` read `led.children[ck]` and never recursed into a child's -own `children`. A `children` map written at **depth two** was therefore accepted -by the file format and then ignored in silence: no evidence path resolved, no -key reported unclassified, no container reconcile, and no line of output saying -any of it was missing. Because the enforce-or-remove channel acts on this gate's -`dead` verdicts, a silently skipped subtree could retire a key that was alive. - -The walk now descends as far as the ledger nests, the reverse (orphan) direction -follows it down, and a drilled child that is itself a container owes the same -declared disposition — drilled, deferred or recorded — that its top-level peers -already owed. `MAX_DRILL_DEPTH` is a tripwire rather than the working limit: -every key below it is reported **UNCLASSIFIED**, which fails the gate, because a -depth limit the instrument does not announce would rebuild the same defect one -level lower. - -**No verdict moved.** Before and after: live 850, planned 10, dead 93, -experimental 5, live-elsewhere 1 — the full per-type `byStatus` map is -byte-identical. Nothing flipped to or from `dead`, so no retirement is in -question. What did move is the census the gate publishes about its own -completeness: 54 containers became visible at once, every one of them already -riding on a blanket verdict below a drilled container where a one-level walk -could not see it. Three are genuinely classified elsewhere (`app/navigation`'s -NavigationItem keys) and resolve as deferrals; the other 51 are recorded debt. - -**Why this carries a changeset rather than `skip-changeset`.** The tool, its -tests and its baseline all live under `packages/spec/scripts/`, which is absent -from the package's published `files[]` — measured at 0 entries in the packed -tarball, against `liveness/` ships at 38 as the lit positive control. But -`files[]` ships the `liveness` directory whole, and `liveness/README.md` is the -ledger's authoring contract: its "Granularity — drill one level" section is what -an author reads before writing a `children` map, and that sentence is now wrong. -The published bytes that change are that section, the depth rule that replaces -it, and the re-stated census. No ledger verdict file changed. diff --git a/.changeset/17425-retired-permission-residue-lint.md b/.changeset/17425-retired-permission-residue-lint.md deleted file mode 100644 index 3853225439..0000000000 --- a/.changeset/17425-retired-permission-residue-lint.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -feat(lint): `permission-retired-lifecycle-residue` — the retired `allowRestore` / `allowPurge` bits are now named at the authoring door (#17425) - -`ObjectPermissionSchema` accepts `allowRestore: false` / `allowPurge: false` as inert residue and strips them silently. That tolerance is #12840's class ruling and is unchanged here: the accept set does not move, no schema is touched, and every other value keeps the tombstone's loud refusal. - -The silence is deliberate — every artifact the published 17.x toolchain built has the retired default materialized in every permission entry, and a per-occurrence notice would be a storm. But `acceptRetiredDefaultResidue`'s own docblock names the channels that stay loud for authored sources — tsc `never`, `os migrate meta`, the ADR-0087 D2 conversion — and against a non-TypeScript author that list is one entry short. `tsc never` is a TypeScript channel. The conversion and `os migrate meta` are the same channel twice, and `permission-allow-restore-purge-removed` is declared `retiredFromLoadPath`, so it never fires while a stack loads. An author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent. - -`os validate`, `os build` and `os lint` now emit one advisory `warning` per carrying entry, on the raw pre-parse stack where the key is still present and still attributable to a line somebody wrote. The hint is the retirement's own prescription, read from the tombstone's published description rather than retyped, so it cannot drift from the parse-time wording the same author sees through the other door. - -It fires on the captured residue value and on nothing else: `true`, `"false"`, `0` and `null` are already refused at the parse with the prescription attached, and the surviving enforced lifecycle bit `allowTransfer: false` is not residue and is never named. - -New published exports on `@objectstack/lint`: `validateRetiredPermissionResidue`, `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` and the `RetiredPermissionResidueFinding` type. Nothing is removed and no existing finding changes shape or severity. diff --git a/.changeset/17456-prototype-fallthrough-guards.md b/.changeset/17456-prototype-fallthrough-guards.md deleted file mode 100644 index cbe2b59b00..0000000000 --- a/.changeset/17456-prototype-fallthrough-guards.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): three more lookups refuse an off-vocabulary key instead of handing back an `Object.prototype` member - -`BASE_ALIASES` / `DIALECT_ALIASES` (`canonicalizeSqlType`), -`DEFAULT_VALUE_TOKEN_SUGGESTIONS` (`suggestDefaultValueToken`) and -`CONTEXT_TOKEN_SUGGESTIONS` (`classifyFilterToken`) are plain object literals, so -all three inherit `Object.prototype`, and every lookup into them was a bare -index. Measured by importing the BUILT artifact (`dist/data/index.mjs`) on the -repo's Node 22 baseline (v22.22.2) and driving each function — the same way the -two landed siblings in this family were measured — over a fixed population of -five: `constructor`, `toString`, `valueOf`, `__proto__` and a plain unknown word. - -| call | before | after | -|:--|:--|:--| -| `canonicalizeSqlType('varchar')` | `'text'` | `'text'` — unmoved | -| `canonicalizeSqlType('timestamptz', 'postgres')` | `'datetime'` | `'datetime'` — unmoved | -| `canonicalizeSqlType('constructor')` | the `Object` **function**, out of a signature that admits only `CanonicalSqlType` string literals | `'unknown'` | -| `canonicalizeSqlType('constructor', )` | the `Object` **function** | `'unknown'` | -| `canonicalizeSqlType('__proto__')` | `'array'` | `'array'` — unmoved; the array-notation rule answers ahead of either table | -| `canonicalizeSqlType('toString' / 'valueOf' / 'nope')` | `'unknown'` | `'unknown'` — unmoved | -| `suggestFieldTypeForSqlType('constructor')` | **`TypeError: Cannot read properties of undefined (reading 'suggested')`** | `undefined` | -| `isCompatible('constructor', 'text')` | **`TypeError: … (reading 'exact')`** | `'lossy'` | -| `suggestDefaultValueToken('currentuser')` | `'current_user'` | `'current_user'` — unmoved | -| `suggestDefaultValueToken('constructor')` | the `Object` **function** | `undefined` | -| `suggestDefaultValueToken('__proto__')` | `Object.prototype` — an **object** | `undefined` | -| `classifyFilterToken('{current_user}').suggestion` | `'current_user_id'` | `'current_user_id'` — unmoved | -| `classifyFilterToken('{constructor}').suggestion` | the `Object` **function**, in a field declared `ContextToken` | `undefined` | -| `classifyFilterToken('{__proto__}').suggestion` | `Object.prototype` | `undefined` | - -The two `TypeError` rows are the sharpest consequence and were not previously -recorded: a non-`CanonicalSqlType` reaches `CANONICAL_TO_FIELD[canonical]`, which -is `undefined`, so both published sibling accessors threw on the member read -rather than merely returning something off-contract. `canonicalizeSqlType`'s -`rawType` comes off live database introspection, which is where an -attacker-free, entirely accidental `constructor` actually comes from. - -`classifyFilterToken`'s half is the one a type-checked consumer meets: the -declared `suggestion?: ContextToken` was a compile-time guarantee that was false -at runtime, and nothing in the type system would ever have flagged it. Its -wrapped-token regex captures `[^{}]+` — anything but braces — so the reachable -key set is not the identifier-shaped one; what bounds it is the `toLowerCase()`, -which leaves exactly the lower-case-stable prototype members (`constructor`, -`__proto__`) namable today. `toString` / `valueOf` were quiet by that casing -accident alone, not by a guard. - -All three sites now go through an `Object.prototype.hasOwnProperty.call` check -returning each function's own already-declared refusal value — `'unknown'`, -`undefined`, and an absent `suggestion` respectively. No declared signature -changes. This narrows and widens nothing an author can reach: every legal -spelling is an own key of its table, so nothing accepted before is refused now, -and only answers that were never inside the declared return types move. - -A null-prototype table was the other available shape and is not taken, for the -reason the two landed siblings measured rather than assumed: a `__proto__: null` -object literal does not type-check against the `Record<…>` annotation at all -(TS2353), and the `Object.assign(Object.create(null), …)` spelling that does -compile silently costs that annotation's exhaustiveness check (TS2741 stopped -firing for a table missing a member). A quiet failure is worse than a loud one. diff --git a/.changeset/17464-knowledge-source-docblock-runtime-registration.md b/.changeset/17464-knowledge-source-docblock-runtime-registration.md deleted file mode 100644 index 73f8462695..0000000000 --- a/.changeset/17464-knowledge-source-docblock-runtime-registration.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`KnowledgeSourceSchema`'s docblock stops claiming it is stored as metadata "exactly like a view or a flow", and says where a knowledge source actually lives - -The docblock above `KnowledgeSourceSchema` declared, verbatim: - -> Canonical KnowledgeSource. Stored as metadata, versioned, and -> environment-scoped exactly like a view or a flow. - -None of the three is true, measured on the tree this changeset lands on: - -- `listMetadataTypeSchemaTypes()` returns **26** governed metadata types and - **none is knowledge-shaped**. Controls that fire: `view`, `flow`, `skill`, - `agent` and `tool` are all present; a `zzz_nonsense` dark control is absent. -- `ObjectStackDefinitionSchema` has **44** top-level keys, none knowledge-shaped - (controls present: `skills`, `agents`, `tools`, `views`, `flows`). -- `defineStack({ knowledgeSources: [...] })` is refused with the **generic** - unrecognized-top-level-key message — byte-identical to the message for - `zzz_nonsense`. Lit control: `defineStack({ skills: [] })` is - accepted on the same base, so the probe does find an authoring route for a - type that has one. - -So an author who followed the sentence reached for a mounting that does not -exist and got a rejection that pointed nowhere — the authoring trap, not a -wrong example. - -**The prose was the outlier, not the schema.** No ADR in this repo mentions -`KnowledgeSource` at all, and the rest of the contract is already consistent: -`IKnowledgeService` declares `registerSource` / `unregisterSource` / -`listSources` / `getSource`, `KnowledgeServicePlugin` takes a `sources` option -at kernel wiring and calls `registerSource` for each, and the implementation -holds them in a process-lifetime `Map`. The `agent.knowledge` liveness row says -the same thing from the other side — *"restrict retrieval at the -knowledge-service/source level; describe grounding in `instructions`"*. - -The replacement docblock states what the schema is (the shape of a runtime -registration), names both routes a source actually arrives by, and says the -retrieval restriction is per-source at the service level. - -⛔ No behaviour, no key and no accept set changes: the diff is one docblock. -Running `gen:schema` and `gen:docs` afterwards produced no artefact change — -`content/docs/references/ai/knowledge-source.mdx` mirrors the file-level header -docblock, not this per-schema one. - -**Why this is not `skip-changeset`.** `@objectstack/spec`'s published `files[]` -ships `dist` *and* `src/**/*.zod.ts`, so this text is published twice over: the -old sentence was measured in the built `dist/knowledge-document.zod-*.d.ts` and -`.d.mts` (1 occurrence each) before the edit, and the source file is shipped -verbatim. Both move. diff --git a/.changeset/17475-record-picker-filter-docblock.md b/.changeset/17475-record-picker-filter-docblock.md deleted file mode 100644 index bc9a24292c..0000000000 --- a/.changeset/17475-record-picker-filter-docblock.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`element:record_picker`'s `filter` docblock now says what the `object-*` blocks actually declare - -The docblock on `ElementRecordPickerPropsSchema.filter` (anchor: -`Filter rules narrowing which records the picker offers`) carried a -parenthetical claiming *"the four `object-*` blocks declare `filter` as -`z.unknown()`, no orthography at all"*. Measured on the file itself: there is no -`filter` key anywhere in `packages/spec/src/ui/component.zod.ts` declared -`z.unknown()` — zero occurrences, against 61 occurrences of `z.unknown()` in the -same file on the same instrument, so the zero is a reading and not a broken -matcher. All eight Zod `filter` declarations in the file are -`z.array(ViewFilterRuleSchema).optional()`; the one remaining `filter:` line is a -`KeySetGuidance` prose entry, not a declaration. - -The `object-*` family in `ComponentPropsMap` has **six** entries. **Four** of -them carry a `filter` door — `object-grid`, `object-metric`, `object-kanban`, -`object-calendar` — and all four declare `z.array(ViewFilterRuleSchema)`. The -other two, `object-form` and `object-master-detail-form`, declare no `filter` -key at all. The corrected parenthetical states both numbers and names all six, -and keeps the `#15449` citation, which is accurate as provenance for when those -four doors moved onto the array form. - -**Why this is worth a patch rather than a silent tidy.** The sentence sat in the -one docblock that tells an author what the sibling `filter` doors accept, and it -told them those doors accept anything. The record form it thereby invited — -`{ field: { $eq: ... } }`, the MongoDB-style shape this very docblock says the -picker moved OFF — is refused at parse by all four. Prose only: no declaration -moves and no accept set changes. diff --git a/.changeset/17493-node-door-refusal-residues.md b/.changeset/17493-node-door-refusal-residues.md deleted file mode 100644 index e75807c61c..0000000000 --- a/.changeset/17493-node-door-refusal-residues.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the structural-condition ruling and the ADR-0087 entry both name the NODE slot (#17493) - -Two places in `packages/spec` still described the world as it was before the -blank structural condition became a defect. Neither changes behaviour: this is -the notification half of a refusal that has already shipped. - -**The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required` -named only the edge key.** Its `surface` and `acceptanceCriteria` told a -consumer replaying the chain to sweep `edges[].condition` and nothing else — -so a deployment carrying a blank `config.condition` on a flow node was never -told to look, even though `AutomationEngine.registerFlow` refuses it since -#17322 and `objectstack validate` since #17495. Both fields now name both -structural slots, the node key's own locator -(the phrase the structural pass builds, e.g. `node 'gate' (start) condition`) is -stated beside the edge's `flows.N.edges.N.condition`, and the sweep carries the -warning that removing a `condition` from a `start` node opens the trigger gate -rather than preserving it. The entry's `id`, `replacement` and `reason` are -untouched, and no new entry is added: this is one decision reaching its second -slot, not a second decision. - -**`structuralConditionRefusal`'s docblock stated a ruling that had become -false.** It admitted a whitespace-only string on the ground that such a -condition "is consistent on both sides and is ruled correct, not a defect" — -the ground #15807 removed at the edge door and #17322 ruled on. The admission -itself is unchanged and still correct, because this function answers the SHAPE -question only and the blank is refused beside it by the imported -evaluated-slot rule; what the docblock now records is which card removed the -ground, which door each refusal lives at, and why the two refusals are kept -distinct. - -It also records, without answering, the question one slot over: the ledger -`predicate` slots (`config.conditions[].expression`, -`screen.fields[].visibleWhen`) still admit a whitespace-only string, pinned as -correct by #15572 on the same ground. Narrowing them re-judges that pin and -moves a published accept-set, so it is a ruling and stays open on #17493. diff --git a/.changeset/17511-i18n-extract-region-screens.md b/.changeset/17511-i18n-extract-region-screens.md deleted file mode 100644 index add4430664..0000000000 --- a/.changeset/17511-i18n-extract-region-screens.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os i18n extract` reaches a `screen` node nested inside an ADR-0031 flow region - -`walkScreenFlows` (`packages/cli/src/utils/i18n-extract.ts`) iterated -`flow.nodes` flat, so a `type: 'screen'` node inside a region — -`loop.config.body`, `parallel.config.branches[].nodes`, -`try_catch.config.try` / `.catch`, nesting arbitrarily — was never reached. It -emitted **no** `flows.NAME.screens.NODE_ID.title` / `.fields.*` skeleton entry -and **no** coverage row. - -**Why that pairing is the defect and not just a missing translation.** A nested -wizard step is a real screen: the executor pauses on it and the client receives -its `ScreenSpec.nodeId`, so `translateFlow` overlays the bundle onto it and the -key is live. With no entry emitted, a translator was never shown the key AND -`os lint` / `pnpm check:i18n-coverage` had no row to demand — the gap was -invisible to the mechanism built to report gaps. A green i18n gate on a tree -whose nested steps render source-locale text was green because the surface was -unreachable, not because the app was translated. - -The node universe now comes from a region-aware descent that reads the one -shared declaration of WHERE a region lives, `FLOW_REGION_SLOTS_BY_TYPE` from -`@objectstack/spec/automation` — the same table `packages/lint`'s -`walkFlowNodes` reads. No local copy of the slot list is introduced: a second -region table in a fourth package is the very shape this defect is an instance -of. - -**Depth deliberately does not enter the key.** Entries stay -`flows.NAME.screens.NODE_ID.*` at every depth, because `lookupFlowScreenCopy` -is keyed by node id alone and the bundle schema knows nothing about depth; a -region path segment would offer a key nothing resolves. A node id repeated at -two depths therefore addresses one bundle slot and collapses to a single entry -(first emission wins, outer before inner) — one slot can serve only one string, -and the resolver overlays that string onto both nodes. - -Seeding is unchanged and applies at every depth: a screen `title` falls back to -the node `label` (what `ScreenSpec.title` draws), and a field `label` falls back -to its `name` as a *derived* seed, so the skeleton stays usable while the -coverage gate demands no translation of a string nobody authored. - -⛔ No authorable key, bundle shape or export moves — an author who wrote a -nested screen now gets scaffolding and a coverage row where both were silently -absent. Existing keys are byte-unchanged. diff --git a/.changeset/17527-metadata-stats-package-fold.md b/.changeset/17527-metadata-stats-package-fold.md deleted file mode 100644 index 1efcc2a273..0000000000 --- a/.changeset/17527-metadata-stats-package-fold.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os validate`, `os build` and `os info` count the objects an ADR-0130 D4 / option-B project actually declares, so `--strict` stops refusing a conforming stack - -`collectMetadataStats` — the one reader behind the metadata summary all three -commands print — counted every collection at the **top level only**. On an -option-B project (every definition inside `packages[]`, none flattened up) the -summary reported `Data: 0 Objects`, and `os validate` raised -`No objects defined — this stack has no data model` on a stack that declares a -data model. - -Under `--strict` that warning is not cosmetic. Measured through the real -binaries on the card's repro, before: - -``` -os validate exit 0 Data: 0 Objects - ⚠ No objects defined — this stack has no data model - ⚠ No apps or plugins defined — this stack may not do much -os validate --strict exit 1 ✗ Strict mode: warnings treated as errors -os build exit 0 Data: 0 Objects -os info exit 0 Data: 0 Objects -``` - -and after, on the same stack: - -``` -os validate --strict Data: 1 Objects 2 Fields - ⚠ No apps or plugins defined — this stack may not do much -``` - -A conforming project that also declares an app now exits **0** where it exited -**1**. - -**The fix reuses the existing fold, and that is what keeps the count a union.** -`authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one -resolution rule for a package-owned collection, and it is strictly additive: a -key the top level already carries wins, because in today's additive shape that -array already *is* the union. So an object reachable from both the top level and -a `packages[]` entry is counted once, never twice — a corrected number that -over-counts would be the same defect with the opposite sign. - -**One behaviour change beyond the counts, in `os info` only.** The fold resolves -package order through `resolveArtifactPackageOrder`, whose ADR-0112 refusals are -deliberately not swallowed. `os validate` and `os compile` already drove that -seam on the same config above their summary call, so they are unchanged; `os -info` did not, and now reports a stack whose `packages[]` repeats a package id -as a named `422` (`DUPLICATE_ARTIFACT_PACKAGE`) instead of printing -`Data: 0 Objects` for an artifact it could not read. - -⛔ No authorable key, spec schema or published export moves. A stack whose top -level carries its collections — every stack the platform emits today — gets a -byte-identical summary: the seam returns it by identity. diff --git a/.changeset/17528-lint-handwritten-checks-package-fold.md b/.changeset/17528-lint-handwritten-checks-package-fold.md deleted file mode 100644 index 23b3a829ee..0000000000 --- a/.changeset/17528-lint-handwritten-checks-package-fold.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os lint`'s own rubric and `os lint --score` judge the stack an ADR-0130 D4 / option-B project actually declares, instead of reporting `✓ All checks passed` on a stack they never opened - -`lintConfig` runs two families: the shared author-time rule registry and -`os lint`'s **own** hand-written checks — naming, labels, empty field maps, the -intra-package duplicate advisory, hook-body lowering and the data-model -conventions. The registry learned to resolve `packages[]` earlier; the -hand-written family and `scoreMetadata`, which reaches the same function, still -read the **top level only**. On an option-B project (every definition inside -`packages[]`, none flattened up) they were handed an empty stack. - -Measured through the real binary, on one object authored two ways — the same -metadata, differing only in where it is declared: - -``` -packages[] os lint exit 0 ✓ All checks passed - Metadata quality: 100/100 (A) - -top level os lint exit 0 ⚠ Label "order" should start with an uppercase letter - convention/label-case at objects[0].label - ℹ Object "ob_order" has no nameField and no name-like field … - object/missing-name-field at objects[0].fields - Metadata quality: 96/100 (A) -``` - -and after, on the same two projects: - -``` -packages[] os lint exit 0 ⚠ convention/label-case at objects[0].label - ℹ object/missing-name-field at objects[0].fields - Metadata quality: 96/100 (A) - -top level os lint exit 0 — byte-identical to before -``` - -The score is the sharper half. `100/100 (A)` with every count at zero is -byte-for-byte the verdict a genuinely clean project gets, on a rubric that had -judged nothing — the same indistinguishability a swallowed linter crash used to -produce, arriving through the input instead. - -**The fix folds once, at `lintConfig`'s entry, with the existing helper.** -`authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one -resolution rule for a package-owned collection and it is present-wins: a key the -top level already carries wins, because in today's additive shape that array -already *is* the union. So a multi-package artifact is judged once, never twice, -and a stack whose top level carries its collections — every stack the platform -emits today — is returned by identity and lints byte-identically to before. - -**This does not change what `scoreMetadata` scores.** It already scored the whole -project: its schema half reports `packages.0.manifest.objects.0: …` on an -option-B stack with no fold anywhere, and on today's additive multi-package shape -its lint half already read the flattened union across every package. The fold -makes the option-B shape agree with the additive one. - -⛔ No authorable key, spec schema, published export or accept set moves. -`os build` rejects and accepts exactly what it did; `os lint`'s own `error` -severity remains a lint verdict, not a publish gate. diff --git a/.changeset/17560-selecting-aggregate-field-type-refused.md b/.changeset/17560-selecting-aggregate-field-type-refused.md deleted file mode 100644 index a7a4d428fb..0000000000 --- a/.changeset/17560-selecting-aggregate-field-type-refused.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/spec": minor ---- - -feat(service-analytics)!: `min` and `max` are judged by the aggregate × field-type table too — all 74 refused pairs answer `400 DATASET_INVALID` through one compile door (#17560) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, and the last -one this table owed. A dataset measure pairing `aggregate: 'min'` (or `'max'`) with any -of the **37** field types outside the numeric, temporal and boolean classes — for example -`text`, `select`, `lookup`, `autonumber`, `json`, `multiselect`, `file`, `location`, -`vector` or `formula`; the ADR-0087 entry registered below carries the full list — used to -compile and reach the backend; it is now refused by -`compileDataset` with `DATASET_INVALID` / **400** before any query is built. Shipped as -`minor` under the repo's launch-window convention for accept-set narrowings. - -⛔ This changeset adds no rows to any table and restates none. The verdict is -`AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in -#16353 under the director ruling of decision batch #59 ("both legs, table in spec") — -read through `isAggregateCompatibleWithFieldType`. - -## What was wrong - -The table refused these 74 pairs from the day it was declared, and **four declarations -gave three different answers about them**: - -| declaration | what it said about `min` × `text` | -|---|---| -| `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec) | refused | -| `dataset-compiler`'s compile leg | never judged — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` | -| `measureResultType` (service-analytics, #15768) | a supported `'string'` result | -| two shipped test files, in prose | "ruled C — the table is to be AMENDED to accept it" | - -Driven through the real service door before anything was written, `min` / `max` over 13 -sampled refused pairs all compiled and emitted SQL, with `avg` × `datetime` as the -firing control (refused, `DATASET_INVALID` / 400, no SQL) — so the zero was a reading of -the tree rather than of a blind harness. - -The fourth row had nothing behind it. The card it cited (#17513) is closed as a -duplicate carrying zero rulings, and the one recorded ruling on this table says the -opposite. ⇒ The director ruling of decision batch #127 (2026-09-13) settled all three -sub-questions in one pass, because one shared fixture drove members of both halves: - -1. **the string classes** (42 pairs) stay refused, as batch #59 ruled — ⛔ the table is - not amended; -2. **the non-string classes** (32 pairs) are refused **and enforced**; -3. **`formula`** is refused on the table's own storage ground — it is VIRTUAL in SQL - storage, no column is emitted, so no aggregate can be lowered to it whatever - `returnType` says. - -The divergence is real, and for these two aggregates it is the **ORDER** rather than the -arithmetic: string order is collation-dependent, so two backends answer two different -"smallest" values for one metadata document, and `min(jsonb)` does not exist on -PostgreSQL at all. - -## What changed - -- **`dataset-compiler`**: the scope condition is gone. `assertAggregateFieldTypeCompatible` - judges all six `AggregationFunction` members against the table, through the same - `DATASET_INVALID` / 400 door. The refusal message names the divergence its own - aggregate class really has (`min` / `max` SELECT a stored value and diverge on order; - `sum` / `avg` DERIVE a number and diverge on arithmetic) and prescribes accordingly. -- **`measureResultType`** asks `isAggregateCompatibleWithFieldType` before it answers, so - the rule and the table agree **by construction**. Its `STRING_SOURCE_FIELD_TYPES` - branch and its `formula` branch are retired with them; `min` / `max` over the temporal - class still answers `'time'`, unchanged. -- **`AnalyticsServiceConfig.sourceFieldMeta`** no longer declares `returnType`. It was - carried (#16236) for one reader — the retired `formula` branch — and a declared input - nobody consumes is the declared-not-enforced shape Prime Directive #10 refuses. - - ⚠️ **That key was never released, so against every published version this removal is a - no-op.** #16236 is still a pending changeset in the same release window as this one; - the last published entry (17.4.0) says in as many words that `FieldSchema.returnType` - "is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape". The key was - therefore added and removed inside one window and no published tarball ever carried it. - - **Host fix, one line:** drop `returnType` from whatever your `sourceFieldMeta` returns. - You do not have to — the hook is a function RETURN position, so an extra key is not an - excess-property error and is simply ignored at runtime — but keeping it declares an - input nothing reads. Hosts on `AnalyticsServicePlugin` need no change at all: the plugin - stopped relaying the key in this same change. - -## FROM → TO, and the one-line fix - -| you wrote | write instead | -|---|---| -| `{ aggregate: 'min' \| 'max', field: }` | `count` / `count_distinct` if you were counting; a **sort** on the list/report if you wanted the first or last RECORD | -| `{ aggregate: 'min' \| 'max', field: }` | store the quantity you meant as a numeric or temporal field and aggregate that | -| `{ aggregate: 'min' \| 'max', field: }` | a formula emits no column; aggregate the stored field the formula reads, or persist the computed value | - -⚠️ **Untouched:** those field types used as a **DIMENSION** (grouping, labelling, -bucketing, filtering), `count` / `count_distinct` over any type, `min` / `max` over the -numeric, temporal and boolean classes, and every `sum` / `avg` row #16778 and #16099 -already settled. The refusal also still stands down rather than guessing wherever the -declared type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a -`relationship.field` path whose column lives on a joined object. - -⚠️ The hand-migration prescription ships as the ADR-0087 semantic TODO registered above, -which names the measure and the field type per affected pair — no lossless conversion -exists, because nothing can compute "the smallest text value" in a way every backend -agrees on. diff --git a/.changeset/17562-initial-failure-history-guard.md b/.changeset/17562-initial-failure-history-guard.md deleted file mode 100644 index bd20f4bcdd..0000000000 --- a/.changeset/17562-initial-failure-history-guard.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A run that genuinely failed is still answered in the declared shape when its own terminal run-history write throws (#17562) - -`AutomationEngine.execute()` and `executeWithoutRetry()` each ended their node-failure `catch` with an unguarded `recordLog({ status: 'failed' })`. That `catch` **is** the handler for node failures and there is no outer one, so a throw out of the history write escaped the method entirely and left `execute()` a **rejected promise**, where its declared return type is an `AutomationResult`. This is the failure-arm half of the completion-path guard shipped just before it, and the same shape already landed on the resume path's failure arm in 17.4.0. - -**What is lost is the shape, not the verdict.** The run really did fail, so nothing misleads an operator: there is no false `failed` and no double run. But a caller that branches on `{ success: false, status: 'failed' }` gets an exception instead, so the transport's `status` arm is bypassed and `errorMessage` (the author's failure text) and `summary` (how far the run got before dying) never arrive — a REST route or SDK caller sees a 500-class throw for a run that had a perfectly good failure envelope waiting, and the node's own error text is replaced by the history driver's. - -Reproduced with a control, the identical flow and the identical node failure differing only in the store: - -``` -store = SYNC-THROW -> {"kind":"threw","error":"run-history driver refused the terminal row"} -store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"} -``` - -**What can throw there is a host surface, not in-repo code** — the same two statements the completion-path fix names: the default-on run-summary line `logger.info(line, meta)`, which calls a host-injected `Logger` and needs no store at all; and `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise, which the `void write.catch(...)` beneath that call cannot see. Both stores shipped in this package are `async` and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. - -What changes: - -- **Each failure-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller now receives the envelope it was always promised — `success: false`, `status: 'failed'`, the **node's** own text in `error`, the flow's `errorMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first. -- **The retry budget survives the loss.** On the retry path the throw used to reject out through the retry loop and `execute()` both, ending the run early; the remaining attempts now run as the author's policy says. -- **The swallowed failure is reported once per abandoned write at `error`**, with the consequence and the fix in the first line: the run failed, its terminal row never landed, nothing retries it, and the caller *was* told the run failed so nothing needs re-driving. The thrown text rides the structured slot. - -⛔ No `catch` arm's meaning is widened: the suspend arm, the input-schema refusal and the retry strategy branch are untouched, and a genuine node failure against healthy sinks is answered exactly as before. diff --git a/.changeset/17579-approver-type-manager-describe.md b/.changeset/17579-approver-type-manager-describe.md deleted file mode 100644 index d4e9fb9879..0000000000 --- a/.changeset/17579-approver-type-manager-describe.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ApproverType` qualifies `manager` in its `.describe()` instead of offering it as a bare allowed value - -`ApproverType` carried **no** `.describe()` at all, so the generated reference -page rendered `## ApproverType` with nothing but an `### Allowed Values` list: -`manager` — the one rung an author cannot operate on a stock install — read -exactly like the nine members that work. `{ type: 'manager' }` resolves -`sys_user.manager_id`, and that column still has no product write surface -(re-measured on this tree: the identity write guard's managed-update whitelist -for `sys_user` is `{name, image, locale}`; the column carries `readonly: true`; -no `packages/plugins/plugin-auth` source writes it). An author who chose it got -a chain that passed `validate` and `lint` and then stalled on its first -submission. - -The new describe says what is true about `manager` and **points** at the remedy -rather than restating it: `MANAGER_ONLY_REMEDY` / `MANAGER_ONLY_ROUTES` in -`packages/lint/src/validate-approval-approvers.ts` remain the single -authoritative copy of the population routes, and that file's `DEPENDENCY` -docblock now names this new string among the lines that go stale if the column -ever gains a write surface. A pointer cannot drift into disagreement with what -it points at, which is why no third copy of the 667-character remedy was added. - -⛔ No member is added, removed or renamed, and no behaviour changes: the enum's -accept set is byte-identical and `check:api-surface` is green on the rebuilt -`dist/*.d.ts`. - -**Why this ships, and why `patch`.** `@objectstack/spec`'s published `files[]` -carries `dist`, `json-schema` and `src/**/*.zod.ts`, and the new string is -measured in all three on the built tree — `dist/automation/index.js` and -`.mjs` (2 files, against a lit control of an existing describe from the same -module, also 2), four `json-schema/` documents (`ApproverType.json`, -`ApprovalNodeApprover.json`, `ApprovalNodeConfig.json`, `objectstack.json`) and -the shipped `approval.zod.ts` source. Prose only, no surface widening ⇒ -`patch`. - -The `packages/lint` half is a docblock comment and is deliberately **not** -graded: that package publishes `dist` only, and the new sentence is absent from -it (0 files) while a runtime string from the same source file is present in 4 -and a pre-existing comment from the same docblock is absent in 0 — so comments -are stripped by construction and nothing published moves there. diff --git a/.changeset/17584-references-refusal-front-load-remedy.md b/.changeset/17584-references-refusal-front-load-remedy.md deleted file mode 100644 index ccb794c89a..0000000000 --- a/.changeset/17584-references-refusal-front-load-remedy.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): the `/references` refusal front-loads its ADR-0110 D3 prescription, so the #5423 bound cannot cut the remedy (#17584) - -`GET /api/v1/meta/:type/:name/references` refuses an unanswerable target type -(`field`, addressed by the composite key `.` that no reference -site can hold) with a prescriptive 501: it names the question that IS -answerable, `GET /api/v1/meta/object//references`. That clause is the -half ADR-0110 D3 exists to deliver — the admin "Used by" panel renders an empty -answer as *"Nothing in the metadata graph points at this item. Safe to delete."* -to an operator whose next click is a delete. - -Since #16146 the refusal crosses the REST boundary through the shared #5423 -bound (`CLIENT_MESSAGE_MAX`, 500 characters), which truncates the **tail**. The -sentence back-loaded the prescription and interpolates the object name twice, so -it grew about three characters per character of name and the remedy was the -first thing a long name cost. Measured through the real route on the unrepaired -sentence: a 37-character object name beside a 37-character field name composed -502 characters and arrived as `…/api/v1/meta/object//referenc…` — the -opener still readable, the URL cut mid-path, an instruction that 404s if -followed. `crm_opportunity_line_item_snapshot_v2` is 37 characters, and nothing -caps a metadata name near that (the ceiling is the storing column's -`maxLength`; the widest is `sys_metadata.name` at 255). - -The clauses are re-ordered so truncation costs the **explanation** instead. No -behaviour moves: the refusal decides exactly what it decided before, the same -`NOT_IMPLEMENTED` / `501` / `refusal` declaration is raised for exactly the same -targets, and the bound is untouched. Callers matching on the message's opening -words will see the new order; matching on `error.code` is unaffected. - -FROM: `References to a 'field' item cannot be computed. … Ask the owning object -instead: GET /api/v1/meta/object//references.` -TO: `Ask the owning object instead: GET /api/v1/meta/object//references. -References to a 'field' item cannot be computed, because …` diff --git a/.changeset/17586-multi-valued-boolean-read-inversion.md b/.changeset/17586-multi-valued-boolean-read-inversion.md deleted file mode 100644 index 224c0e3fb2..0000000000 --- a/.changeset/17586-multi-valued-boolean-read-inversion.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -A `multiple: true` boolean/toggle column reads back as its stored array, not as a single inverted `true` - -`formatOutput` runs its `jsonFields` pass first, which `JSON.parse`s the cell -into a real array, and then its `booleanFields` pass did -`data[field] = Boolean(data[field])`. Every non-empty array is truthy, so a -`multiple: true` `boolean`/`toggle` column presented a single `true` whatever -the array held — a stored `[false]` read back as **`true`**, the opposite of -what is stored, with no error anywhere. `readPresentationKind` hands the same -presenter to the `aggregate()` / `distinct()` doors, so the collapse was not -confined to the row-read door. - -**Fixed at the registry fill.** `&& !field.multiple` is the condition the three -neighbouring pushes in both registration blocks already carry (`mediaCols`, -`numericCols`, `numericValueCols`); `booleanCols.push(name)` was the single -omission, in **both** fills (`registerExternalObject` and -`registerManagedObjectMetadata`). A `multiple: true` boolean/toggle is a JSON -column here, and its array is written faithfully — only the read collapsed it. - -**What moves for a caller.** A `find()` / `aggregate()` / `distinct()` read of a -`multiple: true` `boolean` or `toggle` column now returns the stored array of JS -booleans (`[false]`, `[true, false]`) where it previously returned `true`. Code -that consumed the old scalar was reading a value that did not reflect storage — -including for an all-`false` array. Scalar `boolean`/`toggle` columns are -unchanged and keep their stored-`1`/`0` → JS `true`/`false` coercion; the -`multiple: true` number and `tags` classes were already correct and do not move. diff --git a/.changeset/17590-contains-membership-per-dialect.md b/.changeset/17590-contains-membership-per-dialect.md deleted file mode 100644 index 60db1bf1db..0000000000 --- a/.changeset/17590-contains-membership-per-dialect.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/spec': minor ---- - -`$contains` on a multi-valued / JSON column is a MEMBERSHIP test, compiled per dialect so SQLite, MySQL and PostgreSQL answer the same rows. - -`$contains` is the membership spelling on a `multiple: true` field or a `JSON_COLUMN_TYPES` member — the one operator that kept working on a JSON column after the scalar-comparison family was refused there, and the spelling that refusal's own message prescribes. It was lowered like any other text operator, so each backend was asked about the SERIALIZATION rather than about the members, and the three answered three different things: SQLite matched a substring of the stored array text, MySQL coerced its `json` column for `LIKE` and matched the same substring, and PostgreSQL raised SQLSTATE 42883 (`operator does not exist: json ~~ text`) — a `DATABASE_ERROR` 500 for a filter the spec accepts. - -`driver-sql` now compiles a real membership construct per dialect: `jsonb` containment on PostgreSQL, `JSON_CONTAINS` on MySQL, a `json_each` scan on SQLite. `$notContains` moves with it as its exact complement. - -**Behaviour change on SQLite and MySQL, in the narrowing direction.** Where the substring reading matched ACROSS element boundaries it no longer does: `{ tags: { $contains: 'red' } }` stops answering a row whose only tag is `redwood`, and `{ nums: { $contains: '1' } }` stops answering a row holding `[10, 21]`. Those rows were wrong answers, not a contract — a filter that needs the old reading is asking for a substring search over a serialization and should be written against a scalar column. On PostgreSQL the same filters change from a 500 to the member rows. - -Unchanged: `$contains` on a scalar string column is still the case-sensitive substring test, and the rest of the text family (`$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) keeps the lowering it had on every column. - -`packages/spec`'s `StringOperatorSchema` docblock — published source — now states the membership reading and records, per face, which runtimes answer it. diff --git a/.changeset/17594-step18-element-node-todo.md b/.changeset/17594-step18-element-node-todo.md deleted file mode 100644 index bf81d56375..0000000000 --- a/.changeset/17594-step18-element-node-todo.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the 17 → 18 chain now NAMES the bare `element:filter` / `element:form` node it leaves behind, instead of ending schema-invalid in silence (#17594) - -`element:filter` and `element:form` were retired whole at element grain, and the -two ADR-0087 D2 conversions that carry the retirement — `element-filter-removed` -and `element-form-removed` — strip every authorable key and **deliberately leave -the bare component node**: deleting an authored page node changes a page's -layout, which a mechanical conversion must not decide. That residue was inert -until both names joined `RETIRED_PAGE_COMPONENT_TYPES` and the parse began -refusing them by name — at which point deleting the node stopped being optional -and became a required step of the upgrade. - -The chain never said so. Measured on a stack carrying both nodes, before this -change: - -``` -os migrate meta --from 17 --to 18 - - --json schemaValid: false - human path "Migrated stack does not yet pass schema validation — - resolve the manual changes above" - the 115 step-18 todos 0 name `element:filter`, `element:form`, - `ElementFilter` or `ElementForm` -``` - -ADR-0087 D3 requires a structured TODO "rather than silence" for a migration -step that cannot be expressed declaratively, and this is one: only the author -knows what their region should hold once the node is gone. The new -`element-filter-and-form-node-refused` semantic entry supplies it — surface, the -two replacements (`userFilters` for the filter, the object-bound `object-form` -block for the form) and an `os validate`-clean acceptance criterion — so -`os migrate meta` and the generated upgrade guide both name the thing to delete. - -⛔ Nothing about either conversion's behaviour changes: they still strip the keys -and still leave the node, and no node is deleted for the author. - - diff --git a/.changeset/17596-daterange-array-arity.md b/.changeset/17596-daterange-array-arity.md deleted file mode 100644 index 0bf904421f..0000000000 --- a/.changeset/17596-daterange-array-arity.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/driver-memory': patch ---- - -`dateRange`'s array arm has ONE arity everywhere: a two-element window, or the ADR-0112 refusal (#17596) - -The shared conformance kit -(`analyticsDateRangeConformanceFindings`) had exactly one array case — a -two-element window — so the ARITY of the array arm was governed nowhere and -every analytics face was free to invent a meaning for `dateRange: -['2026-01-01']`. Four faces in one package had invented three (#17124), and a -fifth — `driver-memory`'s cube face — had invented a fourth. - -**The kit** now exports `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and holds every -registered face to the rule the `service-analytics` faces already carry: an -array that is not two non-empty string bounds is refused with -`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. No new rule was invented for it, and -the existing two-element window case is untouched — it is this case's control, -so "refuse every array" cannot pass. - -**`driver-memory`** now answers that refusal instead of dropping the window. -MEASURED end to end over four rows spanning 2020…2099: `['2026-01-01']`, `[]` -and `['2026-01-01', '2026-01-31', '2026-02-01']` each emitted a pipeline -byte-identical to one with **no `dateRange` at all** — every row selected, the -"plot all of history" failure #3650 was filed about — and `[null, null]` -compared instants against the string `'null'` and selected none. - -**Levels.** `@objectstack/core` is `minor`: it gains a new exported symbol on -its index (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`), and a purely additive widening -of a published package's public surface takes at least `minor` whatever the -commit type says. `@objectstack/driver-memory` is `patch`: its public surface is -byte-unchanged — no new export, no new accepted key or value. Its behaviour does -change, from selecting every row to refusing with `400 -ANALYTICS_DATE_RANGE_UNRECOGNIZED`, and that is a `patch` because the old -behaviour was a defect and never a contract: the spec's own refusal wording -already said an explicit window is the two-element array, and the #16322 -migration table already told authors to write a single day as two bounds. A -release that stops answering a shape the contract never admitted is a fix, not a -feature — and the shapes it now refuses had no correct answer to lose. - -**If you wrote a one-element array**, write both bounds: `['2026-01-01']` -becomes `['2026-01-01', '2026-01-01']`, which selects exactly that day on every -face and did so before this change too. The refusal names the shape that -arrived, the two-element contract and that spelling. diff --git a/.changeset/17610-notification-dispatcher-idle-cost.md b/.changeset/17610-notification-dispatcher-idle-cost.md deleted file mode 100644 index 90466f3bb0..0000000000 --- a/.changeset/17610-notification-dispatcher-idle-cost.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`NotificationDispatcher` reaps once per tick instead of once per claim, backs off while the outbox is idle, and `emit()` wakes it (#17610) - -**What an idle dispatcher cost.** Against an EMPTY `sys_notification_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` and `claimDigest()` in each — and each of those opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **32 statements a tick, 16 of them the identical reap UPDATE**, on a fixed 500 ms interval that never let up, one loop per warm kernel. On remote Turso every statement is an HTTP round trip. - -**Now:** - -- **The reap runs once per tick**, before any claim — an idle tick is `1 + 2 × partitionCount` = 17 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. -- **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s; `MessagingServicePlugin` option `dispatchMaxIdleIntervalMs`). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks instead of 1,201. -- **`emit()` wakes the dispatcher.** `MessagingService.setOutbox(outbox, { onEnqueued })` fires once per `emit()` that enqueued at least one delivery; the plugin points it at the new `NotificationDispatcher.wake()`, which ticks immediately — or once more, right after a tick already in flight. - -**Latency bound.** A notification emitted in the process that runs the dispatcher goes out on the tick `wake()` starts, no later than before. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): a deferred delivery coming due (retry schedule, quiet hours, digest window), a row enqueued by a process that does not run this dispatcher, and a crashed node's expired claim (recovered within `claimTtlMs` + `maxIdleIntervalMs`). Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. - -**Contract additions — all optional, nothing to change on upgrade.** `INotificationOutbox` gains an optional `reap(opts: ReapOptions)` — the visibility-timeout recovery `claim()` / `claimDigest()` already open with, as a method of its own — and `ClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost; implementing `reap()` and honouring `skipReap` is what earns the once-per-tick cost. Direct callers of `claim()` / `claimDigest()` are unaffected: without `skipReap` they reap exactly as before. Also new: `NotificationDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, and `MessagingService.setOutbox`'s optional second argument. diff --git a/.changeset/17611-terminal-delivery-retention.md b/.changeset/17611-terminal-delivery-retention.md deleted file mode 100644 index af9c4126e6..0000000000 --- a/.changeset/17611-terminal-delivery-retention.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`sys_notification_delivery` reaps its terminal-failure rows after **7 days** instead of 90 (#17611) - -**⚠️ Operational consequence, stated plainly: `dead` and `suppressed` delivery rows are now deleted 7 days after they were created.** Any report, SLA reading, dashboard or manual investigation that consulted them — "which notifications failed to send, and why" — must now read inside that window. Before this change those rows survived for 90 days. Nothing else about the table changes: `pending`, `in_flight` and `success` rows keep the same 90-day window they have always had, and no row is reaped sooner than before except the two terminal-failure statuses. - -**What was wrong.** Fan-out writes one delivery row per `(event × recipient × channel)`. A tenant with no transport configured for one of those channels dead-letters that channel's row on its **first** attempt, and every `notify` writes another one. Measured on a production tenant: 2,876 `email`/`dead` rows against 2,876 `inbox`/`success` rows, `max(attempts) = 1`, zero pending, growing +316 rows/day. Those rows carry no work — nothing ever claims, retries or acks them again — but they sat in the table the dispatcher's claim query reads on every hop for the full 90-day window, so the cost of every claim rose linearly with time. - -**The change** is one declaration on the object, using spec keys that already ship and are already consumed by the platform Reaper: - -```ts -lifecycle: { - class: 'telemetry', - ttl: { field: 'created_at', expireAfter: '90d' }, - retention: { - maxAge: '7d', - onlyWhen: { status: { $in: ['dead', 'suppressed'] } }, - }, -}, -``` - -`retention.onlyWhen` scopes the short window to the terminal-failure statuses — the same shape `sys_job_queue`, `sys_automation_run` and `sys_upload_session` already declare. No channel interface member, no new status value, no change to fan-out. - -The `ttl` leg is not new behaviour: it restates the 90-day bound the object has always declared. `lifecycle.retention` is a single block, so scoping it to terminal rows would otherwise have left `pending` / `in_flight` / `success` with **no age bound at all** — unbounding the larger half of this table's growth on the very change that exists to bound it. Both legs run: `LifecycleService.reapObject` takes `ttl` and `retention` in independent branches. `success` is deliberately outside the scope; delivery history stays at the table window. - -**If you override this object's lifecycle windows through the `lifecycle` settings namespace, re-read your configuration.** `retention_overrides.maxAge` for `sys_notification_delivery` used to move the whole table's window; it now moves the **terminal-failure** window only, and `expireAfter` moves the table window. An override left in place keeps parsing and keeps applying — to a narrower set of rows than it did before. - -**⚠️ This is worth nothing where the Reaper does not run.** The whole benefit is delivered by `LifecycleService`, which `OS_LIFECYCLE_DISABLED=1` or the plugin switch turns off. A deployment with lifecycle disabled kept these rows forever before this change and keeps them forever after it; a declaration is not a sweeper. Check that the Reaper is enabled before reading this entry as a bound on your table. diff --git a/.changeset/17620-action-engine-delete-nullish-id.md b/.changeset/17620-action-engine-delete-nullish-id.md deleted file mode 100644 index f02e242349..0000000000 --- a/.changeset/17620-action-engine-delete-nullish-id.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -`ActionEngineFacade.delete` refuses a nullish id instead of silently skipping it - -**Who this is for: untyped hosts.** A JS host, or a `registerAction` handler -whose context slot is still `(ctx: any)`, can hand `ctx.engine.delete()` a -nullish id — `delete('todo_task', null)`, or an array with a hole in it. Until -now the arm dropped that element on the floor: nothing refused it, nothing -warned, and the call **resolved as though the row had been deleted**. A silent -no-op on a destructive verb is the one failure an untyped caller has no way to -detect, which is why it is worth a line in your changelog rather than a shrug. - -**What changes.** Every id now reaches the engine as written, and the engine's -own delete-dispatch predicate refuses a `where.id` that is not a truthy scalar: -the call rejects with `Delete requires an ID or options.multi=true` where it -used to resolve in silence. In the array form the refusal stops the loop where -the declared member doc already said a failure stops it — ids before the -nullish element are deleted, ids after it are untouched. - -**If a host was leaning on the old behaviour**, filter before you call: - -```js -const ids = candidates.filter((id) => id != null); -if (ids.length > 0) await ctx.engine.delete('todo_task', ids); -// `delete nothing` is the EMPTY ARRAY (it resolves, deleting nothing) — -// never a null id. An empty array is contract; a nullish id never was. -``` - -⛔ **No declaration moves, and this is not a correction of the `string | string[]` -widening that shipped just before it.** That declaration is accurate: it takes a -single id or an array of them, and under it **no typed caller could ever reach -the skipped branch** — the accept set it publishes has never admitted nullish. -The array form, its per-row semantics, its ordering and its empty-array case are -all unchanged and pinned as controls. What moves is only the runtime's -undeclared tolerance for a value three separate statements already excluded: the -published type, the member's own doc comment, and the spec-side pin that reads -«"delete nothing" is the EMPTY ARRAY, never a null id». diff --git a/.changeset/17621-metadata-protocol-live-postgres-arm.md b/.changeset/17621-metadata-protocol-live-postgres-arm.md deleted file mode 100644 index dbb014223b..0000000000 --- a/.changeset/17621-metadata-protocol-live-postgres-arm.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Execute the read-probe's PostgreSQL catalog arm against a live server, closing the one dialect this package pinned as text and never ran. - -`read-probe.ts` compiles one non-raising table-presence arm per dialect family. Two were executed against something real — SQLite end to end through a real `SqlDriver`, MySQL on the live server the `Temporal Conformance` job provisions. The PostgreSQL arm (`SELECT 1 WHERE to_regclass('""') IS NOT NULL`) was pinned character-for-character against all four knex client spellings and run nowhere: this package had no live-PG harness, no `pg` dependency, and its CI step supplied `OS_TEST_MYSQL_URL` alone while filtering vitest to `live-mysql`. - -A text pin cannot close that gap, because the failure this module is fenced against is an arm mis-compiled for one dialect: it raises, the `catch` that exists for the expected miss swallows it, and a stored-row data repair silently becomes a no-op. Whether `to_regclass` answers ZERO ROWS rather than raising is a claim about PostgreSQL, not about this repo's string concatenation. `seed-tenancy-backfill.live-postgres.test.ts` now runs every statement the migration builds, both presence directions with the refusal control beside them, the search-path scoping the arm depends on, and the whole backfill end to end — on a live server, in its own derived schema. Ablated (the Postgres arm re-compiled to MySQL's `DATABASE()` form), six of its seven cases go red, reporting `verdict: 'unreadable'` with `detail: "function database() does not exist"` — the exact shape the fence exists to keep out of `'absent'`. - -Grade: `patch`, measured rather than defaulted. Not `minor` — no new export, no widened accept-set, no runtime behaviour change of any kind. Not `skip-changeset` either, and that is the measurement worth recording: `dist/` is byte-untouched (grepped for this change's markers: zero hits, against a positive control that hits `dist/index.js` and `dist/index.cjs`), but `package.json` is one of the 27 files `npm pack` ships, and it now carries `pg` and `@types/pg` in `devDependencies`. `skip-changeset` is for a diff that publishes nothing from a released package; this one publishes two manifest lines a consumer never installs, which is still publishing. diff --git a/.changeset/17623-http-dispatcher-idle-cost.md b/.changeset/17623-http-dispatcher-idle-cost.md deleted file mode 100644 index 08d3d5a130..0000000000 --- a/.changeset/17623-http-dispatcher-idle-cost.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`HttpDispatcher` reaps once per tick instead of once per partition, backs off while `sys_http_delivery` is idle, and `enqueueHttp()` / `redeliverHttp()` wake it (#17623) - -**What an idle dispatcher cost.** Against an EMPTY `sys_http_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` in each — and each claim opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **16 SQL statements a tick, 8 of them the identical reap UPDATE**, on a fixed 500 ms `setInterval` that never let up, one loop per warm kernel. It is the shape #17610 removed from `NotificationDispatcher`, still running beside it. On remote Turso every statement is an HTTP round trip. - -**Now:** - -- **The reap runs once per tick**, before any claim — an idle tick is `1 + partitionCount` = 9 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. -- **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s, the notification dispatcher's default). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks and 216 statements instead of 1,201 ticks and 19,216. -- **`MessagingServicePlugin`'s `dispatchMaxIdleIntervalMs` sets the ceiling for both dispatchers**, the way `dispatchIntervalMs` and `partitionCount` already govern both. -- **Writes in this process wake the dispatcher.** `MessagingService.setHttpOutbox(outbox, { onEnqueued })` fires after an `enqueueHttp()` that enqueues a delivery — not one that parks an undeliverable record, which is `dead` on arrival — and after a `redeliverHttp()`. The plugin points it at the new `HttpDispatcher.wake()`, which ticks immediately, or once more right after a tick already in flight. - -**Latency bound.** A delivery enqueued or redelivered in the process that runs the dispatcher goes out on the tick `wake()` starts. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): - -- a retry coming due is attempted less than `min(its delay + intervalMs, maxIdleIntervalMs)` late, because the backoff restarts from `intervalMs` at the attempt that scheduled it; -- a row enqueued by a process that does not run this dispatcher; -- a crashed node's expired claim, recovered within `claimTtlMs` + `maxIdleIntervalMs` (about 35 s at defaults, where it was about 5.5 s). - -Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. - -**Contract additions — all optional, nothing to change on upgrade.** `IHttpOutbox` gains an optional `reap(opts: HttpReapOptions)` — the visibility-timeout recovery `claim()` already opens with, as a method of its own — and `HttpClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlHttpOutbox`, `MemoryHttpOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost. Direct callers of `claim()` are unaffected: without `skipReap` they reap exactly as before. Also new: `HttpDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, the `HttpReapOptions` type, and `MessagingService.setHttpOutbox`'s optional second argument. - -**One loop, not two copies.** The timer loop — idle backoff, collapsing wakes into one follow-up tick, `stop()` — moved out of `NotificationDispatcher` into a module both dispatchers share. `NotificationDispatcher`'s behaviour and public surface are unchanged; its #17610 tests pass as they were. diff --git a/.changeset/17625-api-root-is-the-discovery-route.md b/.changeset/17625-api-root-is-the-discovery-route.md deleted file mode 100644 index ee2057d3c9..0000000000 --- a/.changeset/17625-api-root-is-the-discovery-route.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): the API root is the discovery route, under a second spelling — a gated session's `GET ${prefix}/` reaches discovery again (#17625) - -`HttpDispatcher.dispatch()` strips one trailing slash, so both root spellings it -accepts collapsed onto the empty string: `${prefix}/` arrives as `/` and -`${prefix}` arrives as `` (the MSW / base-URL-stripped form). Only the discovery -branch at the foot of the method knew that empty string meant the API root. The -ADR-0069 authentication-policy gate, which runs far above it, did not. - -That disagreement was invisible while `isAuthGateAllowlisted` answered `true` -for a falsy path. objectstack#7898 made the predicate fail-closed at the source -— exemption is now something a path EARNS by naming an allow-listed route — and -the bare-root discovery request started answering 403 for a session carrying an -`authGate` posture (expired password, required MFA): - -``` -FROM GET ${prefix}/ (session with user.authGate) -> 200 discovery document -TO GET ${prefix}/ (session with user.authGate) -> 403 PASSWORD_EXPIRED // regression -NOW GET ${prefix}/ (session with user.authGate) -> 200 discovery document -``` - -**Normalising the root to `/` is measured insufficient and is not what landed.** -`isAuthGateAllowlisted('/')` is `false` — a segment-less path matches no -`ALLOW_ROUTES` entry — and the discovery branch tests `/discovery` or the empty -string, neither of which `/` satisfies. `'' -> '/'` therefore relocates the 403 -rather than removing it. Both legs are pinned upstream in -`packages/core/src/security/auth-gate.test.ts` ("does not exempt the dispatcher -bare-root `cleanPath` — step 2 is #17625"). - -The root is canonicalised to `/discovery` instead — the route it has always -served — read from one constant by both the canonicalisation and the branch that -serves it, so the two cannot drift into a third disagreement about what the -empty path means. - -**⛔ No allow-list was widened and `packages/core` is untouched.** The only input -whose gate answer moves is the API root, and it gains exactly the exemption -`/discovery` already carried, by BEING that route — no new information is -reachable, since `/discovery` was already exempt and already outside the -project-membership skip check. A caller that reaches the gate with no path at -all is still refused at the predicate, and the pathless case stays declared -where it lives (`shouldDenyAnonymous`) rather than re-derived at this seam. - -**What does NOT change.** `${prefix}` with no trailing slash keeps serving the -same document; the named `/discovery` route is untouched; the -environment-scoped root `${prefix}/environments/` keeps its own answer, -which matched no allow-listed route before objectstack#7898 either. `//` strips -to `/`, not to the empty string, so it is not the root and is not canonicalised. - -**Why `minor` on a change whose commit type is `fix`.** The two are independent -and the floor is mechanical, not editorial: this PR's clause ② is declared -affirmative, and the maintainer's ruling of 2026-09-04 (decision batch #35, on -objectstack#15294) puts an affirmative clause ② on a package whose -`packages/**/src/**` the diff moves at AT LEAST `minor` — *the commit type may -raise a bump but never lower it below what the act requires*, written out under -"WHICH LEVEL" in the `Check Changeset` step of -`.github/workflows/pr-automation.yml`. ⛔ So the reading that this is "a 403 that -should be a 200, therefore a patch" is an argument about INTENT and does not -reach the level: the act re-admits an input class the merged tree refuses, on an -authorisation surface, and that is what the level grades. The commit type stays -`fix(runtime)`, because the type describes the act and the level prices it. - -**ADR-0087 disposition: no ledger entry is owed and no marker is required.** -This changeset declares no breaking change, which is the only condition under -which `check:adr-0087-registration` demands a disposition marker. On the -substance: no ADR-0087 shape surface moved — the diff touches one -`packages/runtime` transport file and its sibling test, no `*.zod.ts`, no -`packages/spec/**`, no `packages/spec/src/contracts/**` entry and no object -definition — so `objectstack migrate meta` has nothing to reach, and no -authorable metadata key, accept set or stored shape changes. Nor is this an -ADR-0087 conversion-layer entry: nothing lenient is being accepted from a -metadata producer. One transport's two spellings of its own route are being -reconciled to the route's own name, which is the opposite direction — a dialect -removed, not tolerated. diff --git a/.changeset/17634-http-ack-claim-credential.md b/.changeset/17634-http-ack-claim-credential.md deleted file mode 100644 index 738a2744bc..0000000000 --- a/.changeset/17634-http-ack-claim-credential.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`IHttpOutbox.ack()` takes an optional third argument, the claim credential, and `HttpDispatcher` now always passes it (#17634). A late ack from a claim the visibility-timeout reap had taken back — a send that outran `claimTtlMs` while another dispatcher re-claimed the row — used to write its outcome by row id over that dispatcher's live attempt: a delivery still in progress could be marked `dead`, or one attempt's outcome overwrite another's. Handed the credential, `SqlHttpOutbox` and `MemoryHttpOutbox` perform the compare-and-set `INotificationOutbox.ack()` has performed since #11859: the outcome is written only while the row is still `in_flight` under the same (`claimedBy`, `claimedAt`) pair `claim()` stamped on it. A lost claim writes nothing and throws the new `HttpAckError` (`DELIVERY_NOT_ELIGIBLE`, the code this package already raises for a delivery row in the wrong state); the dispatcher logs `http-dispatcher: ack refused, claim no longer held`, carries on with the rest of its batch, and whoever holds the row re-drives the delivery. - -Nothing written against the two-argument `ack(id, result)` has to change. An `IHttpOutbox` implementation that does not read the third argument compiles and works as before, and a caller that does not pass it gets the by-id write it always got — that arity is deprecated, because it checks no ownership. New exports: `HttpClaimCredential` and `HttpAckError`. A subclass that overrides a built-in store's `ack()` should forward the third argument to `super.ack()`, or its dispatcher acks keep the old unchecked write. diff --git a/.changeset/17639-distinct-backend-fault-envelope.md b/.changeset/17639-distinct-backend-fault-envelope.md deleted file mode 100644 index 4a83d93c09..0000000000 --- a/.changeset/17639-distinct-backend-fault-envelope.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -`distinct()` answers a backend refusal with the ADR-0112 envelope instead of leaking the dialect's own error - -`SqlDriver.distinct` awaited its query builder bare — no `try`/`catch`, no -envelope — so any refusal the statement raised left the driver as the backend's -own object: a raw SQLSTATE in `code`, `status` **undefined**, and the compiled -statement as the message. `@objectstack/rest` builds a wire status from the -envelope, so an error carrying no `status` and a `code` that is a raw SQLSTATE -is on no list it reads: an ordinary caller shape — *list the distinct values of -this column* — surfaced as an UNHANDLED server fault rather than a declared -`DATABASE_ERROR` 500. - -Measured on live PostgreSQL 16.13: this driver stores every `multiple: true` -column as `json`, and PostgreSQL's `json` defines no equality operator, so -`SELECT DISTINCT` over one is refused — -`code=42883 status=undefined`, `msg=select distinct "toggles" from "…" - could -not identify an equality operator for type json`. Class-wide across every JSON -column (`toggle`, `boolean` and `number` with `multiple: true`, and `tags`), -with a scalar `boolean` column in the same table answering normally. - -The third read door now routes through the same terminal -`backendStatementFault` that `find()` and `count()` have used since -objectstack#8931 and `aggregate()` since objectstack#11455: one catalogued -code, one status, the dialect's own text written to the server log for an -operator and withheld from the caller, and the original error kept as a -non-enumerable `cause` so `isMissingTableError` still reads through it. - -⛔ No new export, no new error code, no new envelope field, and the accepted -input set does not move: `status` and `code` are fields this envelope already -declares. ⛔ This does not make `distinct()` ANSWER over a `json` column — the -call fails either way; what changes is whether the failure is classified. -Whether such a column should support a distinct read belongs with -objectstack#17590. diff --git a/.changeset/17672-repeated-version-400-reachability.md b/.changeset/17672-repeated-version-400-reachability.md deleted file mode 100644 index de972866db..0000000000 --- a/.changeset/17672-repeated-version-400-reachability.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': patch ---- - -fix(runtime): a repeated `?version=` on `GET /packages/:id` is refused `400 VALIDATION_ERROR` in the repo's one message, and `@objectstack/rest` publishes the rule that owns it (#17672) - -`GET /api/v1/packages/:id?version=a&version=b` answered **`404`**, with a second -sentence written at that door. This repo already had a landed answer for exactly -that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112 -nested body (#6307) — and one implementation of it, `refuseRepeatedQueryParams` -/ `repeatedQueryParamMessage` in `packages/rest/src/query-multiplicity.ts`, -whose header is the authority on the rule. - -Driven before the change, one host, three refusals: - -``` -GET /packages/com.acme.crm?version=a&version=b -> 404 RESOURCE_NOT_FOUND -GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND -GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND -``` - -A client branching on the answer could not tell "your request named the -parameter twice" from the two genuine not-founds. After: - -``` -GET /packages/com.acme.crm?version=a&version=b -> 400 VALIDATION_ERROR -GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND -GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND -``` - -The body is the dispatcher's declared envelope — -`{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }` -— with `VALIDATION_ERROR` derived by `buildApiError` from -`standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400. -⛔ Nothing in `packages/spec` moves. - -**What was actually blocking this was reachability, not judgement.** -`@objectstack/rest` declares exactly one export subpath and that module was not -on it, so #17668 could neither call the rule nor (correctly) copy it, and -shipped the `404` with its own sentence instead. The barrel now publishes -`repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, and the dispatcher -domain calls the message function — so the sentence a caller is told for a -repeated parameter is the same one on every door that carries the rule, ⛔ never -a second copy that drifts. - -⚠️ The two published symbols are not interchangeable across a package boundary, -and the barrel entry says so. `repeatedQueryParamMessage` is the portable half: -a pure function of two primitives. `refuseRepeatedQueryParams` writes the bare -ADR-0112 body onto a `res`, which suits handlers of that shape and ⛔ not a -runtime dispatcher domain — measured, its body fails that surface's -`BaseResponseSchema` with `success is missing, must be a boolean`. - -**Not a breaking change, measured rather than assumed.** The `404` it replaces -was introduced by #17668 (`1a25f4a8d`), which is not an ancestor of -`@objectstack/runtime@17.4.0` (exit 1; two control commits from that tag's own -history answer exit 0 on the same predicate, in a checkout -`--is-shallow-repository` reports `false`). It has never been published, so no -released consumer can have branched on it. Everything else about the door is -unchanged: `?version=` and `?version=latest` still serve the -installed row, an absent version and an unknown id still answer `404`, and a -one-element array is still one occurrence. - -Also corrected, on the module that owns the rule: its header said the -dispatcher's `/packages` domain "reads no `version`" — load-bearing prose, -since it is part of why the rule needs only one home. That stopped being true -when #17668 landed. The paragraph now states what is true, which is that the one -home did not move and now serves two doors. diff --git a/.changeset/17681-native-error-name-one-reader.md b/.changeset/17681-native-error-name-one-reader.md deleted file mode 100644 index 11b05cef10..0000000000 --- a/.changeset/17681-native-error-name-one-reader.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/rest': patch -'@objectstack/objectql': patch -'@objectstack/runtime': patch ---- - -refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) - -The predicate that decides whether a sandboxed body's `throw` is a business -REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) -had **three byte-identical copies** — measured, one distinct 74-character regex -literal across three packages: - -| copy | package | its stated reason for being a copy | -|:--|:--|:--| -| `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | -| `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | -| `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | - -⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of -them survives moving the rule.** `@objectstack/types` now owns -`isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence -of a bare `Error:`. All three packages already depend on it and it depends on -none of them, so this fold **adds zero dependency edges** and cannot cycle. - -⚠️ The hazard was never style. One copy learning a new native error name and the -others not means the same throw is a refusal at one door and a crash at the -next — a crash message **leaked** at one boundary and **withheld** at another. -#16013's argument for extracting exactly this class applies verbatim: the -classification is the part nobody may get wrong, so one *tested* helper is worth -more than N correct copies that must each stay correct forever. - -⛔ **No behaviour changes at any door, per case.** This is a pure refactor and -the three WRAPPERS are deliberately NOT folded, because they are not the same -shape and merging them would move a door's answer: - -- rest asks a trimmed message and answers a boolean; -- objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — - because a code hook and a sandboxed body carry the native name in different - places; -- runtime asks the trimmed inner message and answers the **message**, not a - boolean. - -What the three share is the predicate, so the predicate is what moved. Each call -site keeps its own slot choice and its own trimming, and `isNativeErrorName` -deliberately does **not** trim for its callers — a contract pinned in its test. - -**Shipped rather than `skip-changeset`**, measured on a real build: all four -packages publish `files[]: ["dist", …]`, and the built `dist` of each carries -the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, -`@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` -scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. -The retired copies are gone from the artifacts too: the regex literal scores -**0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in -`types/dist` (the ESM and CJS bundles). - -`@objectstack/types` takes **minor**: a new export is a purely additive widening -of a published surface, which is at least minor whatever the commit type says. -The three consumers take `patch` — their artifacts change, their behaviour does -not. diff --git a/.changeset/17690-idatadriver-masked-doors.md b/.changeset/17690-idatadriver-masked-doors.md deleted file mode 100644 index 2629676b29..0000000000 --- a/.changeset/17690-idatadriver-masked-doors.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/driver-turso": minor ---- - -fix(driver-sql,driver-turso): eight more `IDataDriver` doors publish their declared return type, not a nested `any` (#17690) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (PR #15280 for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, PR #17258 for the five `SqlDriver` doors of #15267, PR #17689 for `aggregate()`). No runtime behaviour changes. - -Eight doors published an annotation whose `any` sat **inside** a wider type, while `packages/spec/src/contracts/data-driver.ts` had already declared each one narrower. A consumer holding one of these classes got `any` back and the compiler stopped checking: - -| class | door | published | now | -|---|---|---|---| -| `SqlDriver` | `find` | `Promise` | `Promise[]>` | -| `SqlDriver` | `upsert` | `Promise>` | `Promise>` | -| `SqlDriver` | `bulkUpdate` | `Promise[]>` | `Promise[]>` | -| `SqlDriver` | `temporalFilterValue` | `any` | `unknown` | -| `TursoDriver` | `find` (override) | `Promise` | `Promise[]>` | -| `TursoDriver` | `upsert` (override) | `Promise>` | `Promise>` | -| `TursoDriver` | `bulkUpdate` (override) | `Promise[]>` | `Promise[]>` | -| `RemoteTransport` | `beginTransaction` | `Promise` | `Promise` | - -The `TursoDriver` rows are separate sites, not consequences: an override re-declares the door in that package's own `.d.ts`, so the `@objectstack/driver-sql` narrowing does not reach a consumer holding a `TursoDriver`. - -**What a consumer does.** A cell read off a row now arrives as `unknown` and is typed before use (`String(row.name)`, `Number(cell)`, or a `typeof` narrowing); `Array.prototype.find` over a result set answers `… | undefined` and the absent arm is separated rather than asserted past. Measured across the whole consumer closure of both packages at this change's tree — 115 `typecheck` tasks — the repo-wide cost is **11 sites**, all inside `@objectstack/driver-sql` (9) and `@objectstack/driver-sqlite-wasm` (2), and **zero** outside the driver packages. - -`TursoDriver.beginTransaction` is deliberately NOT narrowed here and stays `Promise`. It overrides `SqlDriver.beginTransaction(): Promise` — narrower than the contract, the honest direction, and the binding declaration for an override — so the contract's `Promise` does not compile there (TS2416). That `any` masks an LSP violation, not an un-narrowed door, and closing it is a separate decision. - - diff --git a/.changeset/17732-channel-availability-fanout.md b/.changeset/17732-channel-availability-fanout.md deleted file mode 100644 index 3ad3fb864f..0000000000 --- a/.changeset/17732-channel-availability-fanout.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/service-messaging": minor -"@objectstack/platform-objects": minor ---- - -Notification fan-out asks a channel whether the tenant can send on it before writing anything, so a channel with no transport no longer produces `sys_notification_delivery` rows that exist only to dead-letter (#17732). - -`MessagingChannel` gains one **optional** member, `isAvailable(ctx, { organizationId })`, answering `{ available: true }` or `{ available: false, reason }` from the closed vocabulary `CHANNEL_UNAVAILABLE_REASONS` (today: `transport_not_configured`). `emit()` consults it once per channel per emit — availability is a property of `(tenant × channel)`, not of a recipient — and a channel that answers unavailable gets no delivery row and no `send()` call on either the outbox (P1) or the inline (P0) path. - -- **Optional means available.** A channel that does not implement the member is treated exactly as before. Every existing implementation, in this repo and in yours, keeps working unchanged with no edit; the same is true of a channel that is registered but unknown to this version. ⛔ There is no way to configure the opposite default. -- **The suppression is recorded, not swallowed.** `sys_notification` gains one key, `suppressed_channels` — `[{ channel, reason }]`, `NULL` when nothing was suppressed — written in the *same* insert that creates the event row, so the feature costs no additional write. `EmitResult` gains the matching `suppressed` array, so a caller is never handed a delivery count that silently omits a channel it asked for. -- **The `email` channel answers from the transport it was handed** — a service-registry lookup, no I/O, nothing cached. Mail configuration in this tree is the `mail` settings namespace at `scope: 'global'`, materialised into a single in-memory transport that the settings change bus hot-swaps, so there is no per-tenant row to read and a memoized answer would survive the settings save that fixed it. The query still takes the tenant context so a future tenant-scoped transport needs no interface change. -- **A probe that throws is treated as available** and logged at `warn`: a broken availability check degrades into today's behaviour, never into a silent notification outage. -- ⚠️ **Unchanged on purpose**: a channel named in `channels` that is not *registered* at all keeps its existing path — the inline fan-out reports it as a failed delivery, the outbox enqueues a row the dispatcher dead-letters. It has no implementation to ask, and widening this ruling to cover it is filed separately. diff --git a/.changeset/17759-account-nav-connect-agent-i18n.md b/.changeset/17759-account-nav-connect-agent-i18n.md deleted file mode 100644 index 540fabcea0..0000000000 --- a/.changeset/17759-account-nav-connect-agent-i18n.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -`apps.account.navigation.nav_connect_agent` is translated in all four locales, so the Connect an Agent page renders the same string behind both doors - -`@objectstack/mcp` contributes the Connect an Agent page into **two** apps — `setup` (admins) and, since #17646, `account` → Developer (every authenticated user). The translation bundles are keyed `apps..navigation.`, one namespace per app, and only the `setup` key existed. So `apps.setup.navigation.nav_connect_agent` never answered for the Account door, and one destination rendered two different strings for the same signed-in user: - -| door | before | -|:--|:--| -| Setup → Integrations | 「连接智能体」 / 「エージェントを接続」 / "Conectar un agente" | -| Account → Developer | `Connect an Agent`, the English literal, in every locale | - -The population that got the untranslated one is precisely the non-admin on a non-English locale: Account is the only one of the two doors they can open. - -Adds the key to `en` / `zh-CN` / `ja-JP` / `es-ES`, mirroring the Setup twin's strings verbatim, plus the `#8765` provenance row in each of the three hand-maintained `.source-hashes.ts` tables (`en` is the source, not a copy of one, so it has no table and gets no row). The recorded digest is `collectSourceHashes(en)['apps.account.navigation.nav_connect_agent.label']` — the repo's own `hashSource`, not a hand-written value. - -⛔ No behaviour outside the bundle moves. No nav item, permission, route or page is added: the contribution and the destination already existed and are untouched, and the Setup key is byte-unchanged. This is an additive key on a published payload, which is why it ships `minor` rather than `patch`. - -Neither gate over this surface could see the gap, and neither is changed here: `pnpm check:app-nav-i18n` scopes itself to `APP_NAME = 'setup'` and skips every contribution targeting another app, and `app-nav-translation-parity.test.ts` walks statically declared nav — the Account entry is contributed at runtime, so no static walk reaches it. Extending the gate is the next step in the standing repair order and lands in `packages/cli/scripts/**` under its own card, deliberately not folded in here. diff --git a/.changeset/17780-plugin-lifecycle-duration-units.md b/.changeset/17780-plugin-lifecycle-duration-units.md deleted file mode 100644 index 2702e1b133..0000000000 --- a/.changeset/17780-plugin-lifecycle-duration-units.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -feat(spec)!: the three `kernel/plugin-lifecycle-advanced.zod.ts` duration keys carry their unit in the key name (#17780, ruling A on #15939) - - - -**BREAKING** — the health-check period, the health-check deadline and the hot-reload debounce -now carry `Ms` in the key name. - -| | before | after | -|:--|:--|:--| -| `PluginHealthCheck` | `interval: 30000` | `intervalMs: 30000` | -| `PluginHealthCheck` | `timeout: 5000` | `timeoutMs: 5000` | -| `HotReloadConfig` | `debounceDelay: 1000` | `debounceDelayMs: 1000` | -| values, defaults, min bounds | ms; 30000 / 5000 / 1000; min 1000 / 100 / 0 | **unchanged** | - -## Migration - -```diff - const health = PluginHealthCheckSchema.parse({ -- interval: 30000, -- timeout: 5000, -+ intervalMs: 30000, -+ timeoutMs: 5000, - }); - - hotReload.registerPlugin('my-plugin', { -- debounceDelay: 1000, -+ debounceDelayMs: 1000, - }); -``` - -Rename the keys. Every value is the same number of milliseconds it always was, and the -30000 / 5000 / 1000 defaults are unchanged; nothing else on either def moves. - -## Why - -Each key named milliseconds in a source JSDoc — "Health check interval in milliseconds", -"Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — -and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. -Measured by the `check:duration-unit-keys` census on this tree, all three read -`[name: -] [prose: -]`: no unit in the name and none in the published prose either. -`interval` was the sharpest of the three — its describe carried one unit-shaped token, the -parenthetical "(default: 30s)", naming SECONDS for a value the schema bounds and defaults in -MILLISECONDS. Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, -decision batch #115), the per-file remediation of the #14478 rule. - -The suffix is the family's own spelling, counted on this tree: 100 key-position `*Ms` -declarations across `packages/spec`, `timeoutMs` 29 of them and `intervalMs` 3. -`debounceDelay` takes the plain suffix rather than a shortened form because it is the only -debounce-shaped key spelling in the repo (no `debounceMs` variant anywhere) while the -Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, -`delayMs`) — so unlike the `Ttl`-versus-`TTL` question the sibling round settled, there was no -competing family spelling to choose between. - -## The kit - -- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value - reaching the parse raises the rename prescription instead of being silently stripped — - neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, and here the - stripped value would land on a `setInterval` period, a race deadline and a `setTimeout` delay -- the ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` and - three `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: neither def is an authorable - surface — both are library parameters a host passes to `PluginHealthMonitor` / - `HotReloadManager` in TypeScript — so the chain has no seam that runs on them, the same - reading `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` - recorded for keys on these two defs -- `@objectstack/core` moves with the rename: `PluginHealthMonitor` and `HotReloadManager` read - the suffixed keys, and each class's registration-time refusal table gains a row so a host - still passing an old spelling is answered with an ADR-0112 `VALIDATION_ERROR` / 400 naming - the rename, rather than getting `undefined` where a duration belongs -- pin tests on both schemas and both classes: the refusal carries the rename prescription, the - suffixed keys parse at the magnitude the retired ones carried with the same defaults, and the - describes publish the unit. The two minimum-bound pins were rewritten rather than left: spelled - through the bare keys they would have stayed green off the tombstone's refusal instead of the - bound, so they now assert the `too_small` issue code on the suffixed keys -- `HotReloadConfig.shutdownTimeout` is deliberately NOT renamed with them — its JSDoc reads - "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape the - #14478 gate leaves outside its verdict, not part of this row set diff --git a/.changeset/17781-runtime-config-resource-limits-timeout-ms.md b/.changeset/17781-runtime-config-resource-limits-timeout-ms.md deleted file mode 100644 index 8cc7780ac5..0000000000 --- a/.changeset/17781-runtime-config-resource-limits-timeout-ms.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the fifth `kernel/plugin-security-advanced.zod.ts` duration — `RuntimeConfig.resourceLimits.timeout` — carries its unit in the key name (#17781, ruling A on #15939) - - - -**BREAKING** — the execution timeout on a plugin sandbox's runtime block carries its unit in the -key name. - -| | before | after | -|:--|:--|:--| -| authored key | `resourceLimits.timeout: 60000` | `resourceLimits.timeoutMs: 60000` | -| published describe | `Maximum execution time` | `Maximum execution time in milliseconds` | -| value + bound | milliseconds, `int().min(0)` | **unchanged** | - -## Migration - -```diff - resourceLimits: { - maxMemory: 1073741824, -- timeout: 60000, -+ timeoutMs: 60000, - } -``` - -Rename the key. The value is the same number of milliseconds it always was and the `int().min(0)` -bound rides along with it; nothing else on `RuntimeConfig` moves. - -## Why - -This is the key #15678 deliberately left alone, and this changeset closes it. `#15678` renamed the -four other plugin-security durations on this same file and recorded, accurately, that this one was -out of its scope: `resourceLimits.timeout` named its unit only in the JSDoc above it — "Execution -timeout in milliseconds" — a channel `check:duration-unit-keys` does not read (it reads -`.describe()` and `.meta({ description })`), and its describe said "Maximum execution time" and -named no unit at all. So the gate listed the key among the duration-shaped keys without judging it, -neither an offender nor an exemption, and the reader who most needs the unit — the reader of -`content/docs/references/kernel/plugin-security-advanced.mdx`, who never sees the source JSDoc — -got a bare integer and could not tell 60000 milliseconds from 60000 seconds. That JSDoc-channel gap -was filed as #15939 and is now ruled: director-seat **ruling A** (2026-09-11, maintainer 「同意」, -decision batch #115) remediates the population per file. Under the #14478 rule, moving the unit -into the describe alone is itself a violation — unit in prose, none in the name — so the key is -renamed and the describe is corrected in one stroke. - -Spelled `Ms`, the same token `SandboxConfig.process.timeoutMs` on this very file already carries: -counted on this tree, the suffixed family spells it that way in every member (29 key-position -`timeoutMs` declarations across `packages/spec/src/**/*.zod.ts`, 40 distinct `*Ms` keys), and no -`timeoutMillis`, `timeout_ms` or `timeoutMS` variant exists anywhere in `packages/spec/src`. - -⚠️ Two keys on this one file spelled `timeout` and both now retire to a key spelled `timeoutMs`: -`RuntimeConfig.resourceLimits.timeout` (this one) and `SandboxConfig.process.timeout` (#15678). -They are different keys on different shapes, so each refusal names its own shape — check which -block you are editing. - -## The kit - -- a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching - the parse raises the rename prescription instead of being silently stripped (the nested - `resourceLimits` object is not `.strict()`) -- the ADR-0087 D3 semantic entry `kernel-runtime-config-timeout-unit-in-key`, which states - explicitly that it completes what #15678 left alone so the two read as a sequence, and the - `RETIRED_KEYS_BY_MAJOR[18]` row `kernel/RuntimeConfig:resourceLimits.timeout`. No D2 conversion: - a `RuntimeConfig` is the engine block of the `SandboxConfig` a host or a plugin security manifest - constructs, `stack.zod.ts` declares no sandbox, security-policy or runtime-config collection, and - it is not a stored `sys_metadata` row — so the chain has no seam that runs on it. That is the - same reading #15678 recorded for the four keys it renamed. -- the pin test that asserted this key stays bare is **replaced, not removed**: it now pins that the - bare spelling is refused with the rename prescription, that `timeoutMs` parses at the same - magnitude beside its siblings, that the describe publishes the unit, and that the two same-named - `timeout` retirements on this file name their own shapes apart -- `content/docs/references/kernel/plugin-security-advanced.mdx` regenerated by `gen:docs`: three - rows move and the tombstone prescription renders in place of the old describe -- no authorable-surface row moves — that ratchet records top-level keys per def, and this key is - nested under `resourceLimits` (measured: `kernel/RuntimeConfig:` carries exactly - `engine`, `engineConfig` and `resourceLimits` across `authorable-surface/` and - `authorable-surface.base.json`, and `check:authorable-surface` is green without regeneration) diff --git a/.changeset/17782-logging-duration-units.md b/.changeset/17782-logging-duration-units.md deleted file mode 100644 index 193e3ba160..0000000000 --- a/.changeset/17782-logging-duration-units.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the four `system/logging.zod.ts` duration keys carry their unit in the key name (#17782, ruling A on #15939) - - - -**BREAKING** — the HTTP log destination's batch flush, retry backoff start and request deadline, -and the logging buffer's flush, now carry `Ms` in the key name. - -| def | before | after | -|:--|:--|:--| -| `HttpDestinationConfig` | `batch.flushInterval: 5000` | `batch.flushIntervalMs: 5000` | -| `HttpDestinationConfig` | `retry.initialDelay: 1000` | `retry.initialDelayMs: 1000` | -| `HttpDestinationConfig` | `timeout: 30000` | `timeoutMs: 30000` | -| `LoggingConfig` | `buffer.flushInterval: 1000` | `buffer.flushIntervalMs: 1000` | -| values, defaults, bounds | ms; 5000 / 1000 / 30000 / 1000; positive int | **unchanged** | - -## Migration - -```diff - const destination = HttpDestinationConfigSchema.parse({ - url: 'https://logs.example.com/v1/logs', -- batch: { maxSize: 500, flushInterval: 10000 }, -- retry: { maxAttempts: 3, initialDelay: 1000 }, -- timeout: 30000, -+ batch: { maxSize: 500, flushIntervalMs: 10000 }, -+ retry: { maxAttempts: 3, initialDelayMs: 1000 }, -+ timeoutMs: 30000, - }); - - const logging = LoggingConfigSchema.parse({ - name: 'app_logging', - label: 'App logging', - destinations: [], -- buffer: { enabled: true, size: 5000, flushInterval: 2000 }, -+ buffer: { enabled: true, size: 5000, flushIntervalMs: 2000 }, - }); -``` - -Rename the keys. Every value is the same number of milliseconds it always was, and the -5000 / 1000 / 30000 / 1000 defaults are unchanged; nothing else on either def moves. - -## Why - -Each key named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry -delay in milliseconds", "Timeout in milliseconds" — and the JSDoc above a key is not what -`content/docs/references/**` renders; `.describe()` is, and **none of the four carried one at -all**. Measured by the `check:duration-unit-keys` census on this tree before the change, all four -read `[name: -] [prose: -]`: no unit in the key, and no published prose to supply it either. So -`content/docs/references/system/logging.mdx` printed a bare `5000` / `1000` / `30000` / `1000`, -and nothing on the page decided milliseconds from seconds. Under the #14478 rule, moving the unit -into the describe alone would itself be a violation (unit in prose, none in the name), so each key -is renamed and given the describe it never had in the same stroke. Executes director-seat ruling A -on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the -#14478 rule. - -⚠️ `flushInterval` was declared **twice** on this file, in two different defs and with two -different defaults — 5000 on the HTTP destination's `batch`, 1000 on the logging `buffer`. They are -two keys, not one; each gets its own tombstone, its own registered row, and a prescription that -names its def, so an author who lands on one is not sent to the other. - -The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` -declarations in `packages/spec/src` against 75 `*Seconds:`. The only competing unit spellings are -3 `*MS:` and 9 `*Millis:`, and every one of them mirrors a name fixed outside this repo — MongoDB's -`maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and -`connectionTimeoutMillis` on `PoolConfigSchema` — so unlike the `Ttl`-versus-`TTL` question a -sibling round had to settle, there was no in-repo alternative to choose between. All three target -spellings were already attested as key-position `*.zod.ts` declarations before this change: -`flushIntervalMs` 1 (on `kernel/events/integrations.zod.ts`, at the same 1000 default), -`initialDelayMs` 5, `timeoutMs` 30. - -## The kit - -- a `retiredKey()` tombstone on each of the four old spellings, so `tsc` types it `never` and a - value reaching the parse raises the rename prescription instead of being silently stripped — none - of the four enclosing objects is `.strict()` (`HttpDestinationConfig` itself and its nested - `batch` and `retry`; `LoggingConfig`'s nested `buffer`) -- the ADR-0087 D3 semantic entry `logging-durations-unit-in-key` and four - `RETIRED_KEYS_BY_MAJOR[18]` rows, one per key. No D2 conversion: `stack.zod.ts` declares no - logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is - referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no - rehydration seam that runs on an authored logging document — the same reading - `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key -- pin tests per key: the refusal carries the rename prescription and names the def, the suffixed - key parses at the magnitude the retired one carried with the same default, and the describe - publishes the unit -- exactly one authorable-surface row pair moves, and it is the one that should: that ratchet records - top-level keys per def (`build-schemas.ts` reads `schema.properties` one level deep), and - `HttpDestinationConfig.timeout` is the only top-level key of the four — - `system/HttpDestinationConfig:timeout` becomes `[RETIRED]` beside a new - `system/HttpDestinationConfig:timeoutMs`, and the `authorable-defaults/` row is renamed with it. - The three nested keys move neither file, which is correct and not an omission -- the pinned objectui checkout is untouched by this rename: at `.objectui-sha` pin - `53ded82bf7a494f54e344e19099dbf00854b8694` it spells `flushInterval` 0 times, `initialDelay` 0, - `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 6409 tracked files, against lit - controls `useState` 2304 and `timeout` 702 on the same corpus diff --git a/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md b/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md deleted file mode 100644 index 4e5525fe7c..0000000000 --- a/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the five `system/metrics.zod.ts` durations carry their unit in the key name (#17783, ruling A on #15939) - - - -**BREAKING** — the five metrics durations whose unit was stated only in a source JSDoc now carry -it in the key name, and each published `.describe()` states it too. - -| def | before | after | -|:--|:--|:--| -| `MetricDefinition` | `summary.maxAge: 600` | `summary.maxAgeSeconds: 600` | -| `ServiceLevelObjective` | `errorBudget.burnRateWindows[].window: 3600` | `errorBudget.burnRateWindows[].durationSeconds: 3600` | -| `MetricExportConfig` | `interval: 60` | `intervalSeconds: 60` | -| `MetricsConfig` | `collectionInterval: 15` | `collectionIntervalSeconds: 15` | -| `MetricsConfig` | `retention.period: 604800` | `retention.durationSeconds: 604800` | - -Every value is seconds, exactly as before, and every default (600, 3600 as authored, 60, 15, -604800) is unchanged. - -## Migration - -```diff - summary: { -- maxAge: 600, -+ maxAgeSeconds: 600, - } - - errorBudget: { -- burnRateWindows: [{ window: 3600, threshold: 14.4 }], -+ burnRateWindows: [{ durationSeconds: 3600, threshold: 14.4 }], - } - - exports: [{ - type: 'prometheus', -- interval: 60, -+ intervalSeconds: 60, - }], -- collectionInterval: 15, -+ collectionIntervalSeconds: 15, - retention: { -- period: 604800, -+ durationSeconds: 604800, - }, -``` - -Rename the keys. Nothing else on these four defs moves, and the three same-named objects on this -file — `MetricAggregationConfig.window`, `ServiceLevelIndicator.window` and -`ServiceLevelObjective.period` — are untouched. - -## Why - -Each key named its unit in a source JSDoc — "Max age of observations in seconds", "Window size in -seconds", "Export interval in seconds", "Collection interval in seconds", "Retention period in -seconds" — and nowhere else. Four of the five carried no `.describe()` at all and the fifth read -"Window size", so the text `content/docs/references/system/metrics.mdx` publishes named no unit: -600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of -milliseconds, and nothing on the page decided between them. Executes director-seat ruling A on -#15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the -#14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit -in prose, none in the name), so each key is renamed and its describe corrected together. - -Three of the five new names are deliberately **not** the mechanical suffix, and this file supplied -the reason for each: - -- `burnRateWindows[].window` → **`durationSeconds`**, not `windowSeconds`. It is the fourth window - length on this file, and #15679 already settled that a window length here reads `durationSeconds` - so the measurements read alike. `windowSeconds` would stutter against the enclosing - `burnRateWindows` array — the same objection #15679 recorded against `window.windowSeconds` — and - on this tree `windowSeconds` is not an authorable key at all: its only key-position occurrence is - an alias-map entry in `ServerRateLimitConfigSchema` that maps the spelling *away* to `windowMs`. -- `retention.period` → **`durationSeconds`**, not `periodSeconds`. `period` is calendar vocabulary - elsewhere in this spec (`ServiceLevelObjective.period.type` selects rolling or calendar, - `PluginRegistryEntry.pricing.billingPeriod` is monthly or yearly), so `periodSeconds` would have - kept the ambiguous half of the name — the same objection #15679 raised against `sizeSeconds`. -- `collectionInterval` → **`collectionIntervalSeconds`**, keeping the qualifier, because - `MetricExportConfig.intervalSeconds` is a different cadence one def over that this same change - creates. - -The two mechanical spellings are attested: `maxAgeSeconds` is the token -`AccessControlConfig.maxAgeSeconds` already carries after this same rule renamed it on -`system/object-storage.zod.ts`, and it keeps the `age` stem that the sibling `ageBuckets` counts -buckets of; `intervalSeconds` is the token four seconds-valued cadences already carry. Counted in -key position across `packages/spec/src` at `fc28c1d38`, the base of this change, the seconds -suffixes run `Seconds` 40, `Sec` 1 (`maxExecutionTimeSec`) and `S` 0 — the two bare `*S` keys on -that corpus, `maxCommitTimeMS` and `enableRLS`, are a millisecond spelling and a boolean. This -change takes `Seconds` to 45 at `9b62f54671`. - -## The kit - -- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching - the parse raises the rename prescription instead of being silently stripped (none of the five - enclosing shapes is `.strict()`) -- the ADR-0087 D3 semantic entry `system-metrics-jsdoc-durations-unit-in-key` and five - `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no metrics collection - and none of these defs is a stored metadata row — the reading - `system-metrics-window-durations-unit-in-key` already recorded for this file -- pin tests per key: the refusal carries the rename prescription and is not an `unrecognized_keys` - issue, the suffixed key parses at the magnitude the retired one carried with the same default, - and each describe publishes the unit -- two authorable-surface rows move, three do not: that ratchet records **top-level** keys per def, - so `MetricExportConfig:interval` and `MetricsConfig:collectionInterval` become `[RETIRED]` beside - their suffixed rows (and their `authorable-defaults` rows move with them), while - `summary.maxAge`, `burnRateWindows[].window` and `retention.period` are nested and move nothing diff --git a/.changeset/17784-tenant-schema-cache-ttl-seconds.md b/.changeset/17784-tenant-schema-cache-ttl-seconds.md deleted file mode 100644 index faf3b8e088..0000000000 --- a/.changeset/17784-tenant-schema-cache-ttl-seconds.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the `system/tenant.zod.ts` schema-cache TTL key carries its unit in the key name (#17784, ruling A on #15939) - - - -**BREAKING** — the schema-cache TTL on the `isolated_schema` tenant isolation strategy carries -its unit in the key name. - -| | before | after | -|:--|:--|:--| -| authored key | `performance.schemaCacheTTL: 3600` | `performance.schemaCacheTtlSeconds: 3600` | -| published describe | `Schema cache TTL` | `Schema cache TTL in seconds` | -| value + default | seconds, `3600` | **unchanged** | - -## Migration - -```diff - performance: { -- schemaCacheTTL: 3600, -+ schemaCacheTtlSeconds: 3600, - } -``` - -Rename the key. The value is the same number of seconds it always was, and the `3600` default is -unchanged; nothing else on `SchemaLevelIsolationStrategy` moves. - -## Why - -The key named its unit in a source JSDoc — "Schema cache TTL in seconds" — and nowhere else. The -`.describe()` that `content/docs/references/system/tenant.mdx` renders said "Schema cache TTL" and -named no unit at all, so the one reader who most needs it, the reader of the published reference -page, was the only reader who never saw it: `3600` is a plausible number of seconds and a plausible -number of milliseconds, and nothing on the page decided between them. Executes director-seat ruling -A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the -#14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit -in prose, none in the name), so the key is renamed and the describe is corrected together. - -The new spelling is `Ttl`, not `TTL`: counted on this tree, every member of the suffixed family -already spells it that way — `cacheTtlSeconds` (11), `ttlSeconds` (3), `defaultCacheTtlSeconds` (1). - -## The kit - -- a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching the - parse raises the rename prescription instead of being silently stripped (the nested `performance` - object is not `.strict()`) -- the ADR-0087 D3 semantic entry `tenant-schema-cache-ttl-unit-in-key` and the - `RETIRED_KEYS_BY_MAJOR[18]` row `system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL`. - No D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is - not a stored metadata row, so the chain has no seam that runs on it — the same reading - `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file -- pin tests on `SchemaLevelIsolationStrategySchema`: the refusal carries the rename prescription, the - suffixed key parses at the magnitude the retired one carried with the same `3600` default, and the - describe publishes the unit -- no authorable-surface row moves — that ratchet records top-level keys per def, and this one is - nested under `performance` (measured: 0 hits for the key across `authorable-surface/` and - `authorable-surface.base.json`, against 4 for the `system/MigrationPlan:` control) diff --git a/.changeset/17785-tracing-otel-exporter-duration-units.md b/.changeset/17785-tracing-otel-exporter-duration-units.md deleted file mode 100644 index ab053b6829..0000000000 --- a/.changeset/17785-tracing-otel-exporter-duration-units.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the four `system/tracing.zod.ts` duration keys carry their unit in the key name (#17785, ruling A on #15939) - - - -**BREAKING** — the OTel exporter deadline, the batch processor's two knobs and the background -span-export period now carry `Ms` in the key name. - -| | before | after | -|:--|:--|:--| -| `OpenTelemetryCompatibility.exporter` | `timeout: 10000` | `timeoutMs: 10000` | -| `OpenTelemetryCompatibility.exporter.batch` | `exportTimeout: 30000` | `exportTimeoutMs: 30000` | -| `OpenTelemetryCompatibility.exporter.batch` | `scheduledDelay: 5000` | `scheduledDelayMs: 5000` | -| `TracingConfig.performance` | `exportInterval: 5000` | `exportIntervalMs: 5000` | -| values, defaults, bounds | ms; 10000 / 30000 / 5000 / 5000; `int().positive()` | **unchanged** | - -## Migration - -```diff - const otel = OpenTelemetryCompatibilitySchema.parse({ - exporter: { - type: 'otlp_grpc', -- timeout: 10000, -+ timeoutMs: 10000, - batch: { -- exportTimeout: 30000, -- scheduledDelay: 5000, -+ exportTimeoutMs: 30000, -+ scheduledDelayMs: 5000, - }, - }, - resource: { serviceName: 'api-server' }, - }); - - const tracing = TracingConfigSchema.parse({ - name: 'default_tracing', - label: 'Default Tracing', -- performance: { exportInterval: 5000 }, -+ performance: { exportIntervalMs: 5000 }, - }); -``` - -Rename the keys. Every value is the same number of milliseconds it always was, the -10000 / 30000 / 5000 / 5000 defaults are unchanged, and nothing else on either def moves. - -## Why - -Each key named milliseconds in a source JSDoc — "Timeout in milliseconds", "Export timeout in -milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — -and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. -Measured on this tree: all four carried **no `.describe()` at all**, so the published reference -row for each was a bare integer with no unit anywhere on the page. That is a strictly worse -channel than the unit-in-prose shape #14478 already refuses — here the reference reader had no -prose to misread. All four magnitudes read plausibly in both units (10000, 30000, 5000, 5000), -and an operator who reads seconds sets an exporter deadline 1000x short. Executes director-seat -ruling A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file -remediation of the #14478 rule, and closes the last of that ruling's seven cards. - -The suffix is the family's own spelling, counted in key position across `packages/spec/src`: -281 `*Ms` declarations over 42 distinct names, `timeoutMs` 65 of them and `intervalMs` 14, -against **0** key-position `timeoutSeconds`. The Delay-plus-`Ms` pairing is likewise already -attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`, `debounceDelayMs`) with no -competing `scheduledDelay` spelling anywhere. This file is milliseconds throughout and its own -landed precedent is `Span.duration → durationMs` (#15679) — the opposite of the sibling metrics -card, whose rows were seconds. - -`exporter.timeoutMs` and `exporter.batch.exportTimeoutMs` deliberately sit one nesting level -apart. The pair pre-exists the rename: the `batch` sub-object is the OpenTelemetry batch span -processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) -beside the exporter's own request deadline. Renaming either to something more distinctive would -depart from the vocabulary this shape mirrors, and the nesting already disambiguates every read -point — `exporter.timeoutMs` versus `exporter.batch.exportTimeoutMs`. - -## The kit - -- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value - reaching the parse raises the rename prescription instead of being silently stripped. Neither - `OpenTelemetryCompatibilitySchema` nor `TracingConfigSchema` nor any object nested inside them - is `.strict()`, so `unrecognized_keys` was never the alternative — a bare deletion would have - landed a default on an exporter deadline and a background export period -- the ADR-0087 D3 semantic entry `system-tracing-otel-exporter-durations-unit-in-key` and four - `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no tracing - collection, no metadata-type binding or manifest embed carries either def, and a tracing - configuration is never a stored `sys_metadata` row — so the chain has no seam that runs on - them, the same reading `system-tracing-span-duration-unit-in-key` recorded for the other key - on this file -- pin tests: a refusal pin per row asserting the issue **code** (never a bare `toThrow()`) and - the FROM → TO prescription, an acceptance pin at each retired key's magnitude with the same - default, a bounds pin, and a describe pin proving the unit now reaches the published channel -- the `authorable-surface` / `authorable-defaults` ratchets move **nothing**, and that is the - correct outcome rather than an omission: those artifacts record top-level keys per def - (`build-schemas.ts` reads `schema.properties` one level deep) and every one of these four is - nested -- `Span.duration → durationMs`'s own entry is untouched — a predecessor's scoped record stays - true, and this round's entry opens by saying how it relates to it diff --git a/.changeset/17786-duration-describe-units.md b/.changeset/17786-duration-describe-units.md deleted file mode 100644 index 9295e04201..0000000000 --- a/.changeset/17786-duration-describe-units.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Three duration keys now name their unit in the `.describe()` prose that reaches the published output, not only in the key name and the JSDoc above them: `PluginLoadingEvent.durationMs` (`kernel/plugin-loading.zod.ts`), `AppInstallResult.durationMs` (`system/app-install.zod.ts`) and `MigrationPlan.estimatedDurationMs` (`system/deploy-bundle.zod.ts`). - -The first carried no `.describe()` at all, so the generated reference row for `durationMs` rendered an empty description cell; the other two said `Installation duration` and `Estimated execution time`, naming a duration with no unit. All three JSDoc blocks already said milliseconds, and all three key names already carry `Ms`. Only the channel an author — very often a model (ADR-0033) — actually reads was missing it. - -⛔ Not a rename, and no key moves: the unit is already in the key name, which is what the #14478 rule asks for. This is the describe-only remediation of Ruling A on #15939, and it is the one of the seven remediations that needs no ADR-0087 conversion, no tombstone and no published-key rename. - -**The published surface was measured rather than assumed**, because a changeset is owed only if the changed text actually ships. Measured after `pnpm --filter @objectstack/spec build`, over the paths this package's `files[]` actually publishes: - -- **The changed text ships.** `Duration in milliseconds` reads 24 occurrences across 12 `dist/` bundle files and 6 across `json-schema/`; the other two read 8 in `dist/` and 2 in `json-schema/` each. The generated reference pages under `content/docs/references/**` render all three rows and are regenerated in this change. -- **Positive control that ships**: the neighbouring describe `Objects created/updated` — `dist` 4, `json-schema` 2. -- **Negative control that does not ship**: `no exemption by blindness`, a sentence that exists only in `packages/spec/scripts/`, a path outside `files[]` — 0 across every published path, 1 in its own unpublished file. -- **Dark control**: a fabricated needle reads 0 everywhere, so a zero above is a reading rather than a broken instrument. - -One measured refinement worth recording for the next author, since it cuts against the obvious reading of "published output": **`dist/` alone does not discriminate the two prose channels.** JSDoc text and even a `//` line comment ride into the emitted bundles verbatim (`Objects created or updated`, JSDoc-only, reads 4 in `dist/`). What separates the channels is `json-schema/`, which carries describe prose and 0 comment prose. So `dist` presence is necessary and not sufficient evidence that a string reached the governed channel; the `json-schema/` reading is the one that decides it. diff --git a/.changeset/17790-info-detail-package-fold.md b/.changeset/17790-info-detail-package-fold.md deleted file mode 100644 index 5b5307e069..0000000000 --- a/.changeset/17790-info-detail-package-fold.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os info`'s **detail** reads now resolve a package-owned collection through the seam the package already has for it, so an ADR-0130 D4 / option-B project (every definition inside `packages[]`, none flattened up) stops contradicting itself. - -Measured through the real binary on the card's own repro, before the change: - -``` -os info --json exit 0 stats.objects = 1 · objects[] length = 0 -os info exit 0 Data: 1 Objects 2 Fields (no `Objects:` section, no `Apps:` section) -``` - -`stats` had learned to resolve `packages[]`; the four reads beside it had not, so one `--json` payload asserted `stats.objects: 1` next to `objects: []` — and nothing in the payload distinguished *this project has no objects* from *this reader could not see them*. `--json` is the face a machine reads, so a consumer could not recover from it. - -- **The four reads** — the `--json` `objects` array and the `Objects:` / `Agents:` / `Apps:` text sections in `commands/info.ts` — go through `resolveStackCollection` (`utils/stack-collections.ts`), the one place this package resolves a package-owned collection. -- **Strictly additive.** That seam answers the caller's original expression FIRST and consults `packages[]` only when the top level does not carry the key at all, so **every stack the platform emits today reports exactly what it reported before** — pinned by a control run whose definitions are the same literals, authored at the top level instead. -- **No new failure mode.** `collectMetadataStats` on the line above already resolves the same package list through the same seam, so a malformed `packages` has already answered its ADR-0112 `422` before these reads run. - -⛔ **Not decided here:** whether an option-B project's detail listing should be this flat union or grouped per package. Each entry keeps the shape and the key set it has always had — no package attribution is added — so that published-output-shape question stays exactly as open as it was. diff --git a/.changeset/17825-environments-update-jsdoc-accept-set.md b/.changeset/17825-environments-update-jsdoc-accept-set.md deleted file mode 100644 index a8d126f3d3..0000000000 --- a/.changeset/17825-environments-update-jsdoc-accept-set.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/client": patch ---- - -`environments.update`'s JSDoc no longer advertises writes the control plane refuses, and `environments.updateVisibility` carries a current-state note. - -The `update` comment listed `display_name, plan, status, is_default, metadata` as the updatable set, and the namespace route table listed `plan` and `status` too. `plan` and `status` are read-only columns on the control plane; the generic `PATCH /api/v1/cloud/environments/:id` route answers an unknown or read-only key with a **400** rather than dropping it, so the comment was actively teaching a call that fails. The same prose implied `visibility` was writable while it is server-owned. - -Three prose sites move, all in `packages/client/src/index.ts`: - -- **The namespace route-table docblock.** The PATCH accept-set now reads `display_name, is_default, metadata`, with the redirects stated: plan changes go through the billing routes, status changes through the lifecycle actions (archive / restore / suspend / resume), and `visibility` is server-owned. -- **`update`'s JSDoc.** The same accept-set with per-field detail, and — the sentence that matters most to a caller — that an unknown or read-only key is answered with a 400 and is **not** dropped silently. Silent-drop is the assumption a caller reasonably makes today, and it is the wrong one. -- **`updateVisibility`'s JSDoc.** A note that the call is refused today, so the paragraph describing what `public` does describes a capability that does not exist yet. The 2026-09-12 maintainer ruling keeps `visibility` server-owned and forced to `private` until the public-listing feature ships, at which point it gets its own endpoint rather than this generic update. - -⛔ **No signature, type or runtime byte moves.** `patch` stays `Record` and `updateVisibility`'s signature and body are byte-for-byte unchanged (verified by hash, before and after). Narrowing a published accept-set is as much a breaking change as widening one, and retiring, re-signing or throwing from a published SDK method is a maintainer ruling — neither is a doc fix's to make. What reaches consumers is the hover text in `dist/index.d.ts`. - -⚠️ The 400 is an **inherited reading**, not one measured from this repo: `/api/v1/cloud/*` is served by `objectstack-ai/cloud`, which is not readable from here, so no gate here can check it. The comments say so at the point of the claim rather than leaving a later reader to try. diff --git a/.changeset/17857-distinct-unresolvable-column-attribution.md b/.changeset/17857-distinct-unresolvable-column-attribution.md deleted file mode 100644 index f498f1b650..0000000000 --- a/.changeset/17857-distinct-unresolvable-column-attribution.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -`SqlDriver.distinct()` now answers **one unresolvable column the way the other three read doors do** — a `400` that names it — instead of a `DATABASE_ERROR` / `500` server fault. - -The same condition (a column name the table does not have) asked at four doors used to get three answers and one server fault. Measured on `origin/main` at `dbea1756d9`, embedded SQLite, and identical on live PostgreSQL 16.13: - -| door | before | after | -|:--|:--|:--| -| `count(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 | unchanged | -| `find(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 | unchanged | -| `aggregate(t, { groupBy: ['nosuchcol'] })` | `INVALID_FIELD` / 400 | unchanged | -| `distinct(t, 'title', { nosuchcol: 1 })` | **`DATABASE_ERROR` / 500** | **`INVALID_FILTER` / 400** | -| `distinct(t, 'nosuchcol')` | **`DATABASE_ERROR` / 500** | **`INVALID_FIELD` / 400** | - -A caller's own mistake — a field name that does not exist — was served as a server fault naming nothing they could act on, one door away from a `400` that names the column. A picklist-populating `distinct()` sits beside the `find()` and `count()` of the same list view. - -**Attribution comes from the caller's own request, never from the backend's prose.** The dialect names the column but not the clause, so the clause is read off the call this driver just compiled — the shape `aggregateBackendFault` established for `aggregate()`: - -1. the name **equals the `field` argument** ⇒ `INVALID_FIELD` / 400 naming the listed column, with the `field` and `object` riders the ingress door's refusals carry; -2. it does not ⇒ the statement's only remaining column sources are the WHERE compiled from `filters` and the tenant-scope predicate, both filters, so the existing `INVALID_FILTER` refusal applies verbatim — the same sentence `find()` and `count()` give; -3. the dialect wording yields **no name** ⇒ no attribution is supportable and the terminal `DATABASE_ERROR` / 500 envelope stands unchanged. - -Arm 2 is the **complement** of arm 1 rather than a search of the `filters` AST, which keeps a nested filter (`{ $or: [{ nosuchcol: 1 }] }`) on the same `400` as a flat one. - -⛔ **No input that was refused before is accepted now, and no exported symbol moves.** The call fails either way; what changes is the refusal's code, status and words. No error code is minted — `INVALID_FIELD` is a standard-catalog member (ADR-0112) and already this repo's answer for a named column an object does not have. No new dialect recognizer is added: both predicates are the ones `find()`, `count()` and `aggregate()` already share. - -A caller that branched on `DATABASE_ERROR` / `500` for a mistyped `distinct()` field or filter key now sees `INVALID_FIELD` / `INVALID_FILTER` `400`s instead; that is the point of the change, and it matches what the same mistake already returned from every other read door. diff --git a/.changeset/17883-generate-migration-file-family-width.md b/.changeset/17883-generate-migration-file-family-width.md deleted file mode 100644 index f8ffcb8d95..0000000000 --- a/.changeset/17883-generate-migration-file-family-width.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os generate migration` gives the file family — `file` / `image` / `avatar` / `video` / `audio` — the **same column width in both formats**. The typescript format emitted a bare `table.string(name)`, knex's `varchar(255)`, while `--format sql` emitted `VARCHAR(2048)` for the same field, so one command answered one field with two widths depending on the flag (#17883). - -2048 is not a new number: ADR-0104 ruled the generator's `VARCHAR(2048)` the end-state for this family, `driver-sql` moved to it (`MEDIA_ID_VARCHAR_CHARS`, #15989), and `os migrate files-to-references --apply` retypes the column to `varchar(2048)`. The typescript format was the one producer left at 255 — so a deployment scaffolded from it declared a width the migration it will later run retypes away from. - -```diff -- table.string('cover_image').nullable(); -+ table.string('cover_image', 2048).nullable(); -``` - -- **No regeneration is required of anyone.** `syncSchema` / `initObjects` are additive and never alter an existing column's type, and a `sys_file` id is far shorter than 255, so nothing stored today is at risk either way. What moves is the **declared** width of tables generated from now on. -- **The width is now read from the sql format's own entry** instead of being retyped beside it, so the two formats cannot drift apart again; `generate-file-reference-width.pin.test.ts` measures both against `driver-sql`'s constant, which is what stops the two halves from "meeting in the middle" at some third value. -- ⛔ **Nothing outside the family moved.** The `text` family, the reference types the file family used to share an arm with (`lookup` / `master_detail` / `user` / `tree`), `autonumber`, and every `--format sql` answer are byte-identical. diff --git a/.changeset/17885-artifact-door-default-flip-class.md b/.changeset/17885-artifact-door-default-flip-class.md deleted file mode 100644 index b6fa7a453a..0000000000 --- a/.changeset/17885-artifact-door-default-flip-class.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-core": patch ---- - -The artifact-ingestion door no longer replays the **default-flip** class of ADR-0087 conversion, so an artifact carrying `defineApp({ hidden: true })` is registered with `hidden: true` — not as an unpublished app (#17885, #4829). - -`app-hidden-to-unpublished` rewrites `app.hidden: true` into `app._unpublished: true`. Both keys are live and they mean opposite kinds of thing: `hidden` is navigation presentation and *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the machine-managed publish gate `filterAppForUser` drops the app on for every user without `studio.access` / `setup.access`. Measured before the change, on an artifact declaring `engines.protocol: ^17.0.0` — the range `create-objectstack` stamps — against a 17.4.0 runtime: the door emitted the `app-hidden-to-unpublished` notice and the object that reached registration carried `hidden: undefined`, `_unpublished: true`. So an author who asked for "keep this out of the App Switcher" got "nobody but a builder can see this" — the incident the `_unpublished` split was introduced to end, arriving through the conversion layer. - -- **The entry is not withdrawn and no key moves.** It still fires where its precondition is a fact — the stored-row rehydration seams (a pre-split `hidden: true` row can only have come from the materialization path) and `os migrate meta`, where the operator asserts the source's age. What changed is that the artifact door, whose evidence is the artifact's **declared `engines.protocol` floor** rather than its age, no longer treats that guess as sufficient for a rewrite that reinterprets a live authorable key. -- **The retired window stays open.** Closing it wholesale would fix this and re-break #12772: an artifact built by 17.1.0 tooling carrying `allowRestore` / `allowPurge` would again be refused at the tombstone with no operator remedy. The door refuses one named class by id, with its reason written beside it, and the pin drives a retired conversion and a non-retired one through the same window to prove it. -- **New seam option, no new export.** `applyConversions` accepts `excludeConversionIds` — the seat-level spelling of "my evidence cannot carry this entry". `retiredFromLoadPath` cannot express it: that flag's jurisdiction is the authoring funnel and nothing else. -- ⛔ **The consumer is unchanged.** `filterAppForUser` withholding on `_unpublished` is correct; the defect was who writes `_unpublished`. - -Deployments whose apps were being served as unpublished purely because of a permissive `engines.protocol` range will see those apps again, for every user, on the next boot. No artifact file changes and no stored row is rewritten. diff --git a/.changeset/17891-app-nav-i18n-app-population.md b/.changeset/17891-app-nav-i18n-app-population.md deleted file mode 100644 index 09db136ae4..0000000000 --- a/.changeset/17891-app-nav-i18n-app-population.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`check:app-nav-i18n` now judges the PLATFORM APPS' navigation — Setup **and Account** — instead of narrowing to `setup` at every site. - -The gate is named "every id labelled in every locale" and was structurally blind to one whole app: it printed a byte-identical `OK (10 contributor(s), 54 merged setup nav id(s), 4 locale(s), …)` line before and after the Account app's contributed `nav_connect_agent` label landed, so nothing it printed could tell you it had skipped an app. - -Six sites narrowed it, only three of which were the obvious filters: - -- the contribution filter, the app-shell filter and the merged-app lookup; -- the **locale-file lookup** (`data.apps..navigation`) — widening the first three without this one yields a gate that collects `account` ids and then hunts for their labels under `apps.setup.navigation`; -- the **build prerequisite**, a package path hard-coded to `@objectstack/setup`; -- the **contributor roster**, which booted no package that registers the Account shell — so `account` had no merged app to judge at all. - -Behaviour now: - -- the population is declared with its criterion (an app is judged iff the ADR-0048 platform-app loop registers its shell by default **and** at least one package contributes navigation into it at runtime), which is why `studio` and `crm_app` are out; -- the per-contributor "landed at least one nav id" invariant is applied **per app**, never over a union across apps — a union would let a contributor serving two apps keep passing on one of them after the other silently stopped; -- every verdict, the refusal advisory and the pass line name the app they are actually about, and the pass line carries a per-app id count; -- `--self-test` gains negative controls for the union softening, for a verdict that names the wrong app subtree, and for a pass line that cannot notice an app leaving the population. - -The `setup` judgement is unchanged: the same 54 merged ids, the same verdict, and the same count in the pass line. diff --git a/.changeset/17909-approvals-resume-failed-provenance.md b/.changeset/17909-approvals-resume-failed-provenance.md deleted file mode 100644 index 3c0f172539..0000000000 --- a/.changeset/17909-approvals-resume-failed-provenance.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`@objectstack/plugin-approvals` is now registered as a second emitter of the already-registered `RESUME_FAILED` in `ERROR_CODE_LEDGER`, so the only correct implementation of `ResumeFailureReport.code` stops being refused by `check:error-code-provenance`. - -**The contradiction this closes.** `ResumeFailureReport` (`contracts/approval-service.ts`) declares `code: ErrorCode` as **required** — "a success answer has no envelope `code` to fall back on" — and its docblock prescribes `RESUME_FAILED` for a run that could not be advanced. But the ledger listed that code only under `@objectstack/rest`, so the first producer to fill the slot stamped a registered code its own owner key did not list, which the provenance gate refuses. The declaration shipped in a state where satisfying it tripped a sibling gate. - -**Measured, not derived.** With PR #17908's stamp site present and the ledger unchanged, the guard answers exit 1 and names it: `@objectstack/plugin-approvals stamps 'RESUME_FAILED' (objlit) at packages/plugins/plugin-approvals/src/approval-service.ts:3370 — not listed under its own owner key`. With this row, the same tree answers exit 0 with the site counted as listed. - -**A row, not a waiver — the precedent's own predicate decides it.** The `EXTERNAL_IMPORT_ERROR` waiver records "the door stamps this code itself for every throw and never reads the producer's declaration". Both halves fail for `resumeFailure`: it rides a **success** answer, which the REST approvals door serves with `res.json(out)` verbatim, and `packages/rest/src` spells `resumeFailure` nowhere. The producer's literal *is* the wire value, so the door names no vocabulary to waive it under. - -**One code, not the three the docblock names.** `RESUME_TARGET_LOST` is a thrown message prefix mapped by rest's catch and stays under rest's row; `RESUME_IN_PROGRESS` is compared and never constructed in this package, and is emitted by `@objectstack/service-automation`, which carries its own row. A row for a code the package does not stamp would be the dead weight this file's gate refuses. - -⛔ **No wire byte moves and no accept set widens.** `RESUME_FAILED` was already in the registered union, so no response can now carry a code it could not carry before; the per-package rows are provenance, not identity. No exported symbol is added and no published payload gains a key. diff --git a/.changeset/18031-permissions-key-two-readings.md b/.changeset/18031-permissions-key-two-readings.md deleted file mode 100644 index 96ad72556a..0000000000 --- a/.changeset/18031-permissions-key-two-readings.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/spec": patch ---- - -A package whose `manifest.permissions` carries the ADR-0025 capability grant is now NAMED when the audience-binding reconciler skips it, instead of vanishing; and both halves of the `permissions` key now point at each other in the spec (#18031). - -`permissions` has two incompatible readings and the package registry stores both in the same slot. At the AUTHORING stage `ManifestSchema.permissions` is the capability grant a plugin requests — the legacy flat `string[]`, or the structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). At the ASSEMBLED stage the collection wins and the same key is the ADR-0090 `PermissionSet[]` collection (`AssembledPackageBodySchema`, ADR-0130 D4). `SchemaRegistry.installPackage` records whichever stage its caller handed it. - -- **`collectDeclaredSuggestions` reports the reading it cannot use.** It wants the assembled one. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, so `Array.isArray(manifest.permissions)` was false and the value never entered the loop; every member of the legacy arm is a bare string, so `consider`'s `typeof ps !== 'object'` line dropped all of them. A package declaring the other reading produced no `sys_audience_binding_suggestion` row, no prompt and no log. It now warns once per engine per package and arm, naming which arm it found, what is lost if the author meant permission sets (no admin is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong — the package's own `defineStack({ permissions: [ … ] })`, which is what the assembled body carries. -- **`warn`, not `error`, and deliberately.** Nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reasoning, one step weaker, as the write-refusal report beside it, and the same sink (`SuggestionDeps['logger']`, which declares no `error`). -- **Reported once per engine per package+arm.** The pass runs at boot, after every package-door `permission` publish and on every list call, while a manifest's shape is fixed for as long as that package is installed; an undeduplicated line would repeat on every console page load and be skimmed past. -- **The spec half is declaration text only — no key, export, arm or accept-set moved.** `ManifestSchema.permissions` now says it describes the AUTHORING stage and names the assembled-stage counterpart; the stack collection `permissions` names the manifest-stage grant; and `InstalledPackageSchema.manifest` says it is the authoring STAGE rather than "whatever was stored", pointing at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` for the stage a `defineStack()` host installs. -- ⛔ **The union at the key was NOT widened, and must not be.** Widening a manifest key into a union of both stages is road C of #14242, rejected by name by the maintainer on 2026-09-02 in favour of road B — declare the assembled stage rather than widen the authoring one — because a union at the key makes neither stage checkable (Prime Directive #12). That ruling is why the fix here is a report and a cross-reference rather than a schema change. diff --git a/.changeset/7898-auth-gate-fail-close.md b/.changeset/7898-auth-gate-fail-close.md deleted file mode 100644 index 093df3dda5..0000000000 --- a/.changeset/7898-auth-gate-fail-close.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/core': patch ---- - -fix(core): an absent or empty path is no longer exempt from the ADR-0069 auth gate (#7898) - -`isAuthGateAllowlisted` answered `true` for a falsy path — it treated "no path" -as allow-listed. That is a fail-OPEN default on an authorization seam: any -caller that reached the ADR-0069 gate with an absent or empty `path` was exempt -on **every** route, and a transport author who simply forgot to populate `path` -disabled the gate with no diagnostic of any kind. - -``` -FROM isAuthGateAllowlisted(undefined) -> true // exempt, on every route - isAuthGateAllowlisted('') -> true - -TO isAuthGateAllowlisted(undefined) -> false // exemption must be earned - isAuthGateAllowlisted('') -> false -``` - -Exemption is now something a path has to EARN by naming an allow-listed route, -so the failure mode of omission is a `403` rather than a bypass. The predicate -is split in two so it carries exactly one meaning: a private -`matchesAllowlistedRoute` answers the route question for a real, non-empty path -— its body is unchanged, the #16839 anchoring rules included — and the exported -predicate answers "is this request exempt", which a request with no path is not. - -**No current caller's behaviour moves.** The caller census was re-run: the same -four production call sites, and no fifth. Two of them (`RestServer.enforceAuth`, -`shouldDenyAnonymous`) already guard for a non-empty path and so only ever reach -the predicate with a real string; a corpus differential against the pre-flip -predicate over more than 10,000 paths moves exactly one input — the empty string -— and nothing else, in either direction. - -**The one exemption that remains for a genuinely pathless caller is explicit**, -and lives at the one seam that really routes by body: `shouldDenyAnonymous` -declares `path` optional and decides the no-path case itself (it denies), ahead -of this predicate. That guard is deliberately kept rather than collapsed into -the now-agreeing default — a seam's contract should not be re-derived from what -a predicate happens to do with a falsy argument. - -**Known follow-up, tracked as #17625.** The dispatcher's bare-root -`` `${prefix}/` `` arrives as `cleanPath === ''` (the trailing slash is -stripped), which was exempt via the fail-open default and is not exempt now, so -a *gated* session — one carrying an `authGate`, i.e. an expired password or a -required MFA enrollment — reaching the bare root gets a `403` instead of the -discovery payload. Every named remediation route (`/auth/*`, `/health`, -`/ready`, `/discovery`, `/me/apps`, `/me/localization`) is unaffected, so -remediation itself stays reachable. Normalising that empty `cleanPath` is step 2 -of the same ruling and is **not** a tolerance re-added here. diff --git a/.changeset/action-confirmation-gate-enforced.md b/.changeset/action-confirmation-gate-enforced.md deleted file mode 100644 index 1b1e44cb00..0000000000 --- a/.changeset/action-confirmation-gate-enforced.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/mcp": minor ---- - -fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) - -**Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. - -FROM → TO, for a caller of a gated action: - -``` -run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran -run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required -``` - -The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. - -**What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: - -- Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. -- Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. -- Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. -- `list_actions` is unchanged. - -**A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. - -Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). diff --git a/.changeset/admin-create-user-reads-membership-policy.md b/.changeset/admin-create-user-reads-membership-policy.md deleted file mode 100644 index 61414b1a24..0000000000 --- a/.changeset/admin-create-user-reads-membership-policy.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -fix(plugin-auth)!: `POST /admin/create-user` reads the deployment's membership policy instead of hard-coding `auto` (#16683) - -**BREAKING** — the membership this published endpoint writes moves for existing inputs on `invite-only` deployments. The route, its request body, its response fields and every exported signature are byte-identical; what changes is what an existing call does on a deployment that declared a non-default policy, stated as a FROM/TO pair below. - -ADR-0093 D1 makes the deployment's `membershipPolicy` the one answer to "does this new account get an organization membership", and enumerates the `invite-only` flows as a closed set — "which endpoint created the user" is explicitly not a determinant. The `user.create.after` reconciler and the D6 backfill both read it through `AuthManager.getMembershipPolicy()`. This endpoint did not: its belt-and-suspenders bind handed the reconciler a literal `'auto'`, so it was the one membership-writing path in the product that ignored the setting. - -FROM: on a deployment declaring `membershipPolicy: 'invite-only'`, an account created through `POST /api/v1/auth/admin/create-user` was bound to the default organization anyway, and the 200 response answered `membershipCreated: true`. The `user.create.after` reconciler had already declined to bind it; this endpoint bound it afterwards. - -TO: the same call creates the account and binds no membership. The response answers `membershipCreated: false` and omits `organizationId`, and the audit row records the same. The account is created and can sign in — `invite-only` withholds the membership, not the login. - -Who is affected: only deployments that set `auth.membership_policy` (or `OS_AUTH_MEMBERSHIP_POLICY`) to `invite-only`. Under the default `auto` posture behaviour is unchanged in every observable respect — response body, `sys_member` write and audit metadata — and that equivalence is pinned by a test rather than asserted here. - -If you relied on admin-created accounts acquiring a membership on an `invite-only` deployment, the supported way to keep it is to bind the membership explicitly (the `add_member` action / `POST /organization/add-member`), which is what `invite-only` means: memberships are granted deliberately, never as a side effect of account creation. Setting the deployment back to `auto` restores the old behaviour for every path at once, including sign-up. - -The direction of the old defect was open, not closed: it GRANTED a membership the operator had configured the platform to withhold, and reported success while doing it. An operator who set `invite-only` specifically to keep a shared organization identity off their users got one anyway. - - diff --git a/.changeset/adr-0112-envelope-refusal-declaration.md b/.changeset/adr-0112-envelope-refusal-declaration.md deleted file mode 100644 index 01b968d9f2..0000000000 --- a/.changeset/adr-0112-envelope-refusal-declaration.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the ADR-0112 error envelope gains a producer-side `refusal` declaration, so a deliberate 5xx refusal can keep its caller-authored `message` (#16335) - -`ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, **`refusal: true`** — the producer's declaration that the 5xx it named is a deliberate REFUSAL whose `message` is authored for the caller, so the boundary keeps that message verbatim instead of withholding it. Director ruling, decision batch #58 (2026-09-06, option C): the refusal/fault distinction is a producer-side declaration on the published envelope — not a status heuristic and not a second allow-list. - -The three cases are now documented side by side on the envelope's TSDoc: - -- **undeclared 5xx** (no `status` on the throw) — unchanged: the leak heuristic decides per message. -- **declared fault** (`status >= 500` + `code`, nothing declared here) — unchanged, and still the DEFAULT: `message` is withheld from the body and logged for the operator. -- **declared refusal** (`status >= 500` + `code` + `refusal: true`) — new: `message` is kept verbatim, bounded exactly as a 4xx message is. - -Purely additive: a producer that says nothing here gets exactly the previous behaviour. `true` is the only value — `refusal: false` fails parse instead of becoming a third state consumers would have to interpret. `userMessage` is orthogonal (end-user text; it never replaces `message`) and may ride the same envelope; the TSDoc reconciles this flag with the recorded reason `userMessage` is a text-carrying field rather than "a boolean beside `message`". - -This is the spec half. The relay half — the three withhold arms reading the declaration (two in `@objectstack/rest`: `declaredServerFaultAnswer`, and `resolveErrorResponse`'s own 5xx passthrough arm, which the `/references` door reaches; one at `@objectstack/runtime`'s dispatcher exit, `errorResponseBase`, which `objectstack serve` mounts and which never consults the first), plus retiring the route-local patch from PR #16143 on `/meta/:type/:name/references` — is #16146 for the REST pair and its sub-issue #17153 for the runtime exit; until they land, a declared refusal is still withheld at the wire. diff --git a/.changeset/analytics-compareto-kind-refusal.md b/.changeset/analytics-compareto-kind-refusal.md deleted file mode 100644 index a47684f9ae..0000000000 --- a/.changeset/analytics-compareto-kind-refusal.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): an unrecognised `compareTo.kind` is refused, not answered with a previous-period window under a 200 (#17550) - -`shiftRange` had one branch and a fall-through — `previousYear` was named, and -**everything else** landed in the `previousPeriod` arm. No `default`, no -exhaustiveness check. So `compareTo: { kind: 'previousQuarter' }` came back as a -previous-period comparison under an ordinary **200**, and the caller was told -nothing. The wrong answer is a comparison **window**: a number a dashboard -renders and a person reads as fact, with no status, header or field in the -response to distinguish it from a real answer. - -`DatasetCompareTo.kind` has only ever declared two values -(`'previousPeriod' | 'previousYear'`), but `DatasetSelection` is a TypeScript -interface with no Zod schema anywhere, and `/analytics/dataset/query`'s door -parses only the seven members the selection shares with `AnalyticsQuery` — -`compareTo` is one of the four it projects away before its parse, and the route -forwards the caller's selection to the service untouched. So `kind` was checked -by `tsc` inside this repo and by nothing at all on the wire. - -## FROM → TO - -| Input | Was | Now | -|:--|:--|:--| -| `compareTo: { kind: 'previousPeriod' }` | the equal-length window before | **unchanged** | -| `compareTo: { kind: 'previousYear' }` | the same window one year back | **unchanged** | -| `compareTo: { kind: }` | a previous-period window, **200** | `DATASET_INVALID` / **400**, naming the value received and both legal ones | - -The fix is to name one of the two declared windows, or drop `compareTo` — which -is what the refusal says. No accept set widens, no new error code is minted: the -refusal is the fourth member of the `datasetInvalidError` family -`resolveCompareDimension` already raises three times for the same document, so it -arrives at the route through the envelope that route already classifies on. - -## Why this is a `patch` - -It pulls behaviour back onto the contract the type has always declared, rather -than narrowing past it: every input `DatasetCompareTo` permits returns -byte-identical windows, pinned by a control in the same change. What flips from -200 to 400 is input the declared contract never permitted. The reachable-today -population for that input was measured on the tree — the dashboard authoring path -is already doored (`DashboardWidgetSchema` parses the widget's `kind` as a -`z.enum`, so a third kind cannot arrive through a parsed widget), and no producer -in this repository sends a third value. What is not enumerable from here is a -consumer outside it calling the published `shiftRange` export, or posting a -hand-rolled body to the dataset route; for those, the refusal replaces a wrong -answer with a located one. - -`alignedCompareBucketKey` reads the same two-valued `kind` and deliberately gains -no refusal of its own: it is not on the package's public surface, and its only -caller runs `shiftRange` first — both pinned, so exporting it turns the pin red -rather than silently reopening this defect. diff --git a/.changeset/analytics-dataset-query-selection-door-parse.md b/.changeset/analytics-dataset-query-selection-door-parse.md deleted file mode 100644 index afd9fb60f5..0000000000 --- a/.changeset/analytics-dataset-query-selection-door-parse.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -`POST /analytics/dataset/query` parses its `selection` at the door, the way its two siblings already do - -The route checked one thing about the body it forwards — that -`selection.measures` was a non-empty array — and forwarded everything else -unexamined. `/analytics/query` and `/analytics/sql` Zod-parse their body at -the entry and lift a malformed member to a 400 before the service is reached, -so a client met two postures on one family depending on which door it knocked -on, and a malformed member of `selection` travelled into `dataset-executor` to -be answered by whatever the face behind it happened to do with it. - -⚠️ **A 400 is newly reachable.** Requests that previously slipped through are -now refused. Two shapes: - -- A `timeDimensions[].dateRange` outside the closed preset vocabulary answers - `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` — the same code, status and wording - the sibling door has answered for the identical condition since the - vocabulary closed. Measured on the tree before this change, the literal - string `not a range at all` reached the executor under an ordinary `200`. -- Anything else malformed answers `400 VALIDATION_FAILED` with - `details.fields[]`, each entry naming the member as `selection.`. - -**What is NOT newly refused, deliberately.** `selection` is a -`DatasetSelection`, which is *not* the `AnalyticsQuery` the siblings parse: it -carries no `cube`, and `runtimeFilter`, `dateGranularity`, `compareTo` and -`totals` are members of its own. Reusing the sibling schema would have refused -every real dashboard widget. What the door parses is the projection of the -seven members whose declaration on `DatasetSelection` *is* the `AnalyticsQuery` -member of the same name — `dimensions`, `measures`, `timeDimensions` (declared -there by reference), `order`, `limit`, `offset`, `timezone` — so the refusal -set is exactly what the published interface already declared. The four -dataset-only members are projected away before the parse and keep reaching the -executor untouched. - -Validation-only: the caller's `selection` object is what `queryDataset` -receives, by identity, never a parse output. diff --git a/.changeset/analytics-daterange-driver-alignment.md b/.changeset/analytics-daterange-driver-alignment.md deleted file mode 100644 index 576241cbf8..0000000000 --- a/.changeset/analytics-daterange-driver-alignment.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/driver-memory": minor -"@objectstack/service-analytics": minor -"@objectstack/spec": patch ---- - -fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) - - - -**BREAKING** for an in-process caller that reaches an analytics face PAST the -schema door with a string the closed vocabulary does not contain: it used to be -answered, and is now refused. Shipped as `minor` under the repo's launch-window -convention. The driver half of #16041, whose spec change closed -`AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen -dashboard preset names; every value affected here was already refused at -`POST /analytics/query` and `/analytics/sql` when that landed. - -## What was wrong - -#16041 closed the contract; the faces behind it never aligned, so the defect it -abolished simply moved onto the newly-blessed vocabulary. Measured on the built -`driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, -2099): - -| input | before | after | -|:--|--:|--:| -| `today` | 1/5 | 1/5 | -| the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | -| `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | - -`driver-memory` recognised exactly `today`: every snake_case preset missed its -`startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose -two bounds were the preset's own NAME, which matched every `Date`-typed row -under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered -the same names — and unrecognised strings, and `today` — to the point window -`created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is -whatever the dialect decides a vocabulary word compares as. So a dashboard -asking for one month got all of history on one backend and a nonsense -comparison on the other, at HTTP 200 on both. - -## What it does now - -- **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / - `resolveAnalyticsDateRangeString` resolve every declared preset to - `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens - handed to the existing macro resolver, so `dateRange: 'this_month'` and a - `{month_start}` filter token cannot answer differently, and the anchoring on - `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) - come from that resolver rather than from each face. -- **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 - envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own - `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door - answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call - it, so "memory and SQL refuse identically" is one function rather than an - agreement. -- **The upper bound keeps #16179's separation.** A window a face RESOLVED is - compared exclusively (`$lt` / `<`) for the ten calendar presets and - inclusively for the three rolling `last_N_days`, whose bound is NOW; an - explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. -- The fifteen `driver-memory` date-range pins #16041 retired are reinstated in - preset form (DST cells re-measured under calendar semantics, not re-spelled), - and one cross-face conformance fixture holds all FOUR faces to the same - windows and the same refusal. -- **The draft-preview evaluator is the fourth face**, and it is in that fixture - for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — - the Live Canvas preview over a pending seed draft) carried the identical - `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, - silently, while the published chart beside it answered a real window — across - a publish boundary the preview exists to make continuous, since publish - materialises the same seed. - -## FROM → TO - -Unchanged from #16041's — the spelling that is refused here is the spelling that -was already refused at the door. - -| you wrote | write instead | -|:--|:--| -| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | -| `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | -| `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | -| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | - -The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's -code stays registered under `@objectstack/runtime` (the door that names the wire -vocabulary), and the waiver records that the shared constructor spelling it -lives one package over. diff --git a/.changeset/analytics-inline-dataset-object-read-admission.md b/.changeset/analytics-inline-dataset-object-read-admission.md deleted file mode 100644 index 880074199e..0000000000 --- a/.changeset/analytics-inline-dataset-object-read-admission.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-security": minor -"@objectstack/service-analytics": minor -"@objectstack/verify": minor ---- - -fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) - - - -**BREAKING** in the accept-set sense — an accept-set narrowing on a published -route — landing in the launch window as `minor` on all four packages (the -lockstep convention: during the window the bump level is not the carrier, this -banner and the disposition above are). Nothing that was already admitted -becomes refused **except** the requests `GET /data/` refuses today for -the same principal, which is the defect. Nothing that was refused becomes -admitted. - -`POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. - -The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. - -**This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. - -- **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. -- **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. -- **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. -- **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. - -The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. diff --git a/.changeset/analytics-reference-dimension-display-labels.md b/.changeset/analytics-reference-dimension-display-labels.md deleted file mode 100644 index 56fff05f31..0000000000 --- a/.changeset/analytics-reference-dimension-display-labels.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -A dataset dimension over a `user` or `tree` field renders the referenced record's display name, the same way a `lookup` dimension already did. A "by person" chart's axis is people's names, not a column of user ids. - -`packages/spec` declares one reference class — `REFERENCE_VALUE_TYPES` = `lookup`, `master_detail`, `user`, `tree`, "value points at another record … a record-id string in stored form" — and this service already treated it as one class where it annotates measure result types (`measure-result-type.ts` imports that very set). The label resolver, one file away, hand-wrote a two-member subset of it (`lookup`, `master_detail`), so within a single dataset query one axis came back as a name and the other as a raw id, for two fields that differ in one word: - -``` -Field.user({ label: 'Person' }) -> { type: 'user', reference: 'sys_user' } -Field.lookup('sys_business_unit', { … }) -> { type: 'lookup', reference: 'sys_business_unit' } -``` - -- **The subset is gone, not extended.** The resolver now asks `referenceTargetOf` (`@objectstack/spec/data`) — the declared single arbiter of "what does this reference field point at" — at all three sites that classified a dimension: the display pass, the `#3680` sort-key hook's `isLabelBearing`, and its `resolveLabels`. Adding two literals to a private `Set` would have left the next member of the class to be re-reported by the next user. -- **A `user` field authored without `reference` resolves too.** `sys_user` is a constant of the type, which `referenceTargetOf` materializes; requiring an author to restate it is exactly the disagreement between two readers of one field that arbiter exists to end. -- **The label read stays scoped (`#3602`).** Turning a user id into a name is a read of `sys_user`, and it travels the same `LabelScopeResolver` path every other member of the class travels — the referenced object's own RLS is resolved and ANDed into the lookup, and an unresolvable scope still fails closed to the raw id rather than fetching unscoped. This is the half of the change that had to land with it, not after it. -- **Nothing degrades into an error or a blank.** An orphaned or RLS-hidden user id, a `sys_user` with no display field, and a user object unknown to the engine all leave the raw id in place and answer the query, which is the pre-existing contract for an unresolved lookup id. - -No new authorable key and no new export: `DatasetDimensionSchema` is untouched, and a dimension's own declared `type` still does not decide this — the resolver reads the object field's type, as it always has. diff --git a/.changeset/analytics-row-scope-bridge-three-way.md b/.changeset/analytics-row-scope-bridge-three-way.md deleted file mode 100644 index 7fcf9b4e83..0000000000 --- a/.changeset/analytics-row-scope-bridge-three-way.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics): the ROW-SCOPE bridge to the `security` service tells the same three resolutions apart as the object-level one — a broken security service refuses the query instead of running it with no row policy (#16918) - -`AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead` → `canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope` → `getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one: - -```ts -const trySecurity = () => { - try { - const svc = ctx.getService('security'); - return svc && typeof svc.getReadFilter === 'function' ? svc : undefined; - } catch { return undefined; } -}; -getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context); -``` - -A throwing resolver and a registered service without `getReadFilter` both produced `undefined` — the same value an absent security service produces, and the value `ISecurityService.getReadFilter` reserves for one meaning only: *"this caller has no row restriction on this object"*. So on a deployment whose security service was wired but broken (a boot-order fault, a mis-registered plugin, a failing dependency, a provider that is not the contract it claims to be) analytics queries ran with **no row-level policy at all**, and nothing said so. One door of the file failed closed on a throwing resolver and its neighbour failed open — and the neighbour is the one carrying row-level policy. - -**What changes.** The bridge now resolves the same explicit three-way, at the same reporting level: - -- **ABSENT** — no `security` service resolved: **unchanged**. No row-scope provider on this deployment, which is a legitimate configuration (a single-tenant kernel that ships no `plugin-security`, where `/data` carries no row-level policy either) and is already reported loudly at init. ⛔ Deliberately not tightened: refusing here would break every such deployment. -- **THROWING** resolver, or a registered service with **no `getReadFilter`** — the query is **REFUSED**, and the reason is reported at `error` naming the object and which of the two states it was. The refusal is a throw, which `AnalyticsService.resolveReadScopes` — fail-closed since ADR-0021 D-C — already turns into "deny the whole query rather than emit SQL with that object unscoped". A log over an `undefined` would not have been a refusal. - -**This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service, and no deployment with none, changes behaviour by so much as a byte. Nothing that was refused becomes admitted. - -**No published-surface delta.** No new error code (the refusal rides the seam's existing fail-closed error), no exported symbol, no key on `AnalyticsServicePluginOptions` or any payload, and no documented envelope changes shape. Graded `minor` rather than `patch` because it is a behaviour narrowing on a published package's read path, matching how its object-level sibling was graded in the same lockstep window. - -⚠️ Deliberately **not** answered here: which tenant wall the platform's is (plugin-security's posture-gated Layer 0, or driver-sql's posture-independent auto-scope) — the escalated maintainer decision of triage condition 5. Refusing to serve is neutral between them: it answers *"should we serve at all"*, never *"what shape is the wall"*. diff --git a/.changeset/analytics-row-scope-refusal-envelope.md b/.changeset/analytics-row-scope-refusal-envelope.md deleted file mode 100644 index 0a5439bf46..0000000000 --- a/.changeset/analytics-row-scope-refusal-envelope.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): a fail-closed row-scope refusal can no longer be served as an empty chart (#17130) - -`queryDataset` degrades to `{rows: [], fields: [], totals: []}` when a BARE error looks like a driver reporting an absent table — a deliberate leniency (#5033) so a dashboard widget over an unmounted object renders "no data" instead of failing. The test is a substring match over the message, and three of its six limbs — `not registered`, `unknown object`, `is not a registered object` — are exactly the phrasings a registry or security refusal reaches for. - -Both sites of the row-scope RESOLUTION stage refused with a bare `throw new Error(…)`: the `security` bridge in `AnalyticsServicePlugin`, and `AnalyticsService.resolveReadScopes`. They propagated only because their wording happened to miss all six — so any reword, or any refusal added to that stage later, could silently turn a fail-closed gate into a `200` with no rows. - -Both now declare `READ_SCOPE_COMPILE_FAILED` / `500` — the code the sibling read-scope LOWERING stage has answered with since #5367, so the registered wire vocabulary is unchanged. Two visible consequences for a deployment whose wired `security` service cannot answer a row-level read scope: - -- the refusal reaches the caller as a declared `500` instead of relying on its phrasing to escape the degradation path; -- its message is withheld from the response body by declaration (the operator still gets the full text, at `error`, from the producing site) rather than echoed. - -Every refusal message is byte-unchanged, and #5033's leniency is untouched: a genuine absent source table still degrades to the empty result with its `warn`, and a deployment with NO security service still runs unscoped exactly as before. A guard derived from the source (`refusal-wording-collision.test.ts`) now walks every `throw` in the package and fails if an un-enveloped refusal can be read as a missing source table. diff --git a/.changeset/analytics-sqldialect-declared-vocabulary.md b/.changeset/analytics-sqldialect-declared-vocabulary.md deleted file mode 100644 index 4911b281a3..0000000000 --- a/.changeset/analytics-sqldialect-declared-vocabulary.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(analytics)!: `AnalyticsServiceConfig.sqlDialect` declares its three-name accept set, and a host that answers outside it is told once (#16206) - - - -**BREAKING** for a TypeScript host that declares its `sqlDialect` hook as returning -`string`: the hook's declared return is now the three canonical dialect names or -`undefined`, so such a composition stops compiling until the host's own annotation -says which names it can answer. Shipped as `minor` under the repo's launch-window -convention, in which breaking-ness is carried by this banner and the disposition -above rather than by the bump level. Runtime behaviour for every host is unchanged: -the same three names were the only ones that ever did anything. - -## What was wrong - -`AnalyticsServiceConfig.sqlDialect` — the hook a host answers to say which SQL -dialect backs an object — was typed as free `string`, while `normalizeSqlDialect` -has only ever recognised `sqlite`, `postgres` and `mysql`. Nothing said so, and -nothing told a host that answered otherwise. - -So a host that owns a SQLite datasource and answers the spelling its own stack uses -— knex's canonical `sqlite3`, or `better-sqlite3`, both of which `driver-sql` itself -lists in `SQLITE_EMIT_CLIENTS` — was read as `unknown`. And because `sqlDialectFor` -is tiered "cannot answer, do not block", **a wrong answer and no answer were the -same answer**: the host that tried hardest to help got the residue arm, silently. - -## What it does now - -- **The vocabulary is declared**, on the type and in the docblock, as - `AcceptedSqlDialect` — `sqlite` | `postgres` | `mysql` — so a host reading the - config learns the accept set without running anything. The type and the runtime - membership set are generated from one `const` tuple, so a future widening cannot - land in one and miss the other. -- **A non-empty answer outside the set is diagnosed**: one `warn` naming the object, - the answer and the accepted set. It is emitted **once per distinct unrecognised - spelling** — the failure's identity — so the line count is bounded by the host's - own hook and never grows with query volume. -- **`undefined` stays silent and legal.** The hook is optional and "cannot answer, - do not block" is a supported composition, not a misconfiguration. A pin holds both - halves, because a diagnostic that also shouted at hosts who wired nothing would be - a worse defect than the one being fixed. -- **The accept set is NOT widened.** Teaching this package `driver-sql`'s knex - aliases would be a second copy of that driver's table, and an unrecognised - spelling is sometimes deliberate (`mariadb`, #11756). The answer is still read as - `unknown`; only the silence changed. -- **The plugin bridge translates the driver's own residue.** `SqlDriver.dialectName` - carries a fourth name, `unknown`, meaning "I cannot say"; handed on verbatim it - would have presented a correctly-behaving driver as a host answering out of - contract. It now arrives as `undefined`, this hook's own spelling for the same - thing. The dialect the compilers end up with is unchanged either way. - -## Measured, and worth reading before relying on the residue arm - -Driven on sql.js through a host answering `sqlite3`, against the shared -`FILTER_TEXT_CASES` fixture, with a host answering `sqlite` as the control: **five of -the six case-EXACT cases come back with the wrong rows** — every case that -discriminates on ASCII case. `{ name: { $contains: 'acme' } }` answers `['1','2']` -where the table says `['2']`, and the negated form DROPS a row that belongs in the -result. That is #15684's fold, live on the arm this population lands on, and it is -reported rather than fixed here: closing it is that card's business, not this one's. diff --git a/.changeset/analytics-time-dimension-granularity-buckets.md b/.changeset/analytics-time-dimension-granularity-buckets.md deleted file mode 100644 index 73deae054a..0000000000 --- a/.changeset/analytics-time-dimension-granularity-buckets.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/objectql": minor -"@objectstack/driver-memory": minor ---- - -fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) - - - -**BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in -the launch window as `minor` under the lockstep convention this cluster's -siblings already use: - -- an accepted request now answers **differently**: a time dimension carrying a - `granularity` folds its rows into calendar buckets instead of returning one - group per distinct timestamp. Every affected answer was wrong before; -- a **trend query answers rows where it used to answer one total**: a - `granularity` on a member `dimensions` does not also list is now a group - column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` - — the canonical trend shape — comes back one row per bucket, carrying the - member and a `fields` entry for it, instead of a single ungrouped total with - no such column; -- an accepted request is now **refused**: `granularity: 'second' | 'minute' | - 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. - -## What was wrong - -`AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube -dimension enumerates the granularities it offers (`granularities: ['day']`). -`memory-analytics.ts` read neither. The `$group` stage keyed on the raw field -path, so a time dimension bucketed **one group per distinct timestamp** — one bar -per row in a "new accounts by month" chart, which is the symptom #3588 -catalogued and repaired for `service-analytics`. - -Measured through the public entry against the built package, two rows on one UTC -calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under -`granularity: 'day'`: - -| | before | after | -|:--|--:|--:| -| `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | -| no granularity (control) | 2 groups | 2 groups, unchanged | -| `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | -| same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | -| `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | - -The emitted pipeline was byte-identical across all three, which is the whole -finding: the request was accepted, no warning was emitted, and the key was inert. - -## What it does now - -- **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, - granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and - the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only - statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name - the five granularities that HAVE a canonical key, so a face that must refuse - the other three quotes the accepted set instead of hand-listing it. -- **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and - signature unchanged, answers unchanged — pinned across granularity, timezone - and input form rather than asserted. A driver that pushes the bucket down into - SQL and this in-memory path must label one instant identically or a drill-down - breaks at the seam, and that is now one function rather than an agreement - between two. -- **A granular time dimension is a group column, listed or not.** `dimensions` - no longer decides alone what `$group` keys on: every `timeDimensions` entry - carrying a `granularity` is grouped, projected and named in `fields`, deduped - against `dimensions` on the resolved member so two spellings of one member - stay one column. This is the rule the SQL/ObjectQL face already records - (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping - and field metadata, because rows carrying a bucket under a `fields` list that - never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a - `dateRange` is a predicate and is still **not** projected. -- **`driver-memory` folds by granularity before its `$group`.** The pipeline is - cut at that stage: the `$match` half still runs in the driver, the bucket keys - are written onto the selected rows, and the grouping half runs over those. The - key travels under a synthetic field rather than overwriting the row's own, so a - member that is both a group key and a measure's aggregand still ranks instants - in `max()` while grouping on the label. -- **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, - `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's - `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an - output contract, and a second spelling is what breaks a drill-down across a - backend seam. -- **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone - #16042 threaded through the `dateRange` window resolver, so the window that - selects the rows and the bucket that folds them agree on where a calendar day - starts. The same two rows answer one group in UTC, two in `America/New_York` - and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. - - ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver - reads in the reference zone. An explicit `[start, end]` array is the caller's - own **instant** window and keeps its published reading (#16179), while the - bucket beside it is always a **calendar** label (ADR-0053) — so an array - window and a bucket can still disagree about where a day starts. That - combination is legitimate and is not refused; it is stated here rather than - left to be discovered. -- **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 - envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, - the class `refusePerAggregationFilter` uses for the same reason: the query is - spelled correctly, the spec declares the value, and it is this backend that - compiles nothing for it). The canonical key vocabulary defines no label for a - sub-day bucket, so there is no string another backend's pushed-down SQL would - agree with. Passing it through unbucketed is this card's own defect wearing a - new name. -- **An undeclared granularity is a 400, not a 501.** A 501 says "this backend - cannot", which is only honest about a value the contract declares. - `TimeUpdateInterval` is checked first, so a spelling it never declared — - reachable past the schema door, where `POST /analytics/dataset/query` types - `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` - / 400 rather than a 501 asserting the spec declared it. The same separation - the `dateRange` half of this face already draws (#16322 / #16041). - -## If a caller is refused - -A stored widget or a request asking for a sub-day granularity was never bucketed -by this backend — it received one group per distinct timestamp under an ordinary -200. Nothing that worked stops working. Ask for `day` or coarser and the answer -is a real bucket; keep the raw timestamps deliberately by dropping the key, which -is the behaviour that key used to produce by accident. diff --git a/.changeset/anonymous-get-session-refusal.md b/.changeset/anonymous-get-session-refusal.md deleted file mode 100644 index e9925550a5..0000000000 --- a/.changeset/anonymous-get-session-refusal.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -**BREAKING** — `GET /api/v1/auth/get-session` answers an anonymous caller with the -declared ADR-0112 failure envelope and HTTP 401, instead of HTTP 200 wrapping a JSON `null`. - -Until now an unauthenticated session read answered: - -``` -HTTP 200 -null -``` - -`ObjectStackClient.auth.me()` declares `Promise`, and -`SessionResponseSchema` requires `data.session` and `data.user` — so no value of that type -means "nobody is signed in", and the most ordinary call a logged-out caller can make -resolved to something outside the method's own declared type. Ruled by the director seat -(decision batch #117 item 4) under the charter rule -「spec 与代码不一致默认改代码,改协议单独立卡非选项」: the implementation is corrected to -the published contract. `SessionResponseSchema` is untouched. - -What changes on the wire: - -- **An anonymous or unresolvable credential ⇒ `401` with `error.code: 'UNAUTHENTICATED'`** - and the message `Sign in first`, the same body a raw `/admin/` mount already answers the - same caller with. No error code is minted: `UNAUTHENTICATED` is an existing - `StandardErrorCode` member, derived from the status through ADR-0112's own map, so - `ERROR_CODE_LEDGER` is unchanged. -- **Unchanged:** a signed-in read still answers `200` with `{ user, session }`, - byte-identical. Every other `/auth/*` route is untouched, and so is the `404` that a - method this route does not serve already answered — this change never invents a route. -- **Also unchanged:** better-auth's JS API. `auth.api.getSession()` still returns `null` for - an anonymous caller, so every internal identity read — execution-context resolution, the - platform-admin gates, the SSO bridges — behaves exactly as before. Only the wire moves. - -**`@objectstack/client`:** `client.auth.me()` now **rejects** for an anonymous caller -instead of resolving with `null` — the SDK throws on every non-2xx before unwrapping. Every -value the method resolves with is now inside its declared `SessionResponse`. Callers that -inspected the resolved value must move to a `catch`: - -```ts -try { - const session = await client.auth.me(); - // …signed in -} catch (err: any) { - if (err.code === 'UNAUTHENTICATED') { - // …signed out; err.httpStatus is 401 - } -} -``` - -A caller that branches on the HTTP status directly reads `401` plus -`error.code: 'UNAUTHENTICATED'` where it used to read `200` plus an empty body. - - diff --git a/.changeset/approval-approvers-manager-rung-may-resolve-empty.md b/.changeset/approval-approvers-manager-rung-may-resolve-empty.md deleted file mode 100644 index e4f6b467f8..0000000000 --- a/.changeset/approval-approvers-manager-rung-may-resolve-empty.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`approval-approvers-may-resolve-empty` now covers the `manager` rung, not just the group-routed ones. - -The rule exists for the empty-slate dead-end (#3424): an approver slate that resolves to nobody, with `lockRecord` turning that into a stranded record. It reasoned about `position` / `team` / `department` and said nothing about `{ type: 'manager' }` — which has the same failure shape and a strictly worse cause. A `position` rung resolves empty because the position is unstaffed, and an operator can staff it. A `manager` rung resolves empty because `sys_user.manager_id` is unset, and an operator **cannot** set it: the managed-update whitelist for `sys_user` is exactly `{name, image, locale}` (ADR-0092), the auth admin endpoints do not accept the column, and the Console renders no field for it. So the rule warned about the rung an author can rescue and stayed silent on the one they cannot — and `manager` is the canonical first rung of a tiered approval ladder, so the silent case was also the common one. - -- **What fires.** A node whose approver slate is made up ENTIRELY of `{ type: 'manager' }` rungs now draws one `approval-approvers-may-resolve-empty` finding, at the same `info` tier as its `position` sibling. `manager` resolves through `sys_user.manager_id` of the record's owner and yields nobody when that column is unset; when nothing else is on the node, the request waits forever, and under the default `lockRecord` the record stays locked. -- **What it does not claim.** The message states in as many words that this is a static check which cannot read the column, and that it does not assert the slate IS empty — it reports that nothing else on the node can approve if it is. A lint rule must not claim a runtime fact it did not read. -- **The remedy it prescribes, with the routes graded rather than listed.** An exact diagnosis whose prescription cannot be carried out is worse than no prescription, so the hint separates what this platform provides from what it does not. A **seed, or any other system-context write**, populates the column here — both write guards gate on `isUserContextWrite` (`userId && !isSystem`), so a system-context write bypasses the managed-update whitelist by construction. **SCIM provisioning and directory sync** are named too, because a deployment running a real one may well populate the column through it — but named as a path the deployment itself supplies: this repo declares the SCIM Enterprise `manager` attribute without projecting it onto the column, and the admin bulk import does not write it either (`SYS_USER_IMPORT_UPDATE_FIELDS` is `{name, image, locale}` plus `phone_number` and `role`, and `manager_id` is listed there among the admin-surface-only columns). Editing the user in the Console is explicitly ruled out, since it cannot write the column at all. And the escape that depends on none of this stays on offer: add a fallback approver that cannot resolve empty, such as `{ type: 'org_membership_level', value: 'owner' }`. -- **When it stays quiet — and on which surface.** A stack whose own seed data wires `sys_user.manager_id` on any seeded row has shown the linter that it populates the column, and the advisory is suppressed. Seed rows are the only manager-chain evidence a stack can carry, so that is the whole of what this check reads on the question. ⚠️ That suppression is **CLI-side only**. The runtime publish gate hands rules a `RuntimeStackContext` whose collections are fixed — `objects`, `permissions`, `books`, `datasets`, `pages`, and no `data` — so a Studio publish of a manager-only flow carries no seeds to read and draws the advisory however the tenant's users are wired. That is a surface asymmetry, not a broken suppressor: an `info` finding never blocks a publish, it rides the 2xx `advisories`. Noted here so a reader who seeds correctly and still sees it fire on publish does not go looking for a bug in the rule. - -Existing verdicts are unchanged. The new arm is scoped to slates that are entirely `manager` rungs, which keeps it disjoint from the group-routed arm by construction — no node can draw both findings — and leaves every `position` verdict exactly as it was, mixed slates included: a `[position, manager]` node stays silent, as it is pinned to. - -This is a purely additive widening of a published package's public surface — the rule begins covering a case it was silent on — so it is graded `minor`, the floor that act carries regardless of the commit type. - -No severity moved. The finding is `info`, so it lands in the advisory channel on every consumer: `os lint` renders it as a suggestion and its exit code is unchanged (a suggestion does not fail a run even under `--strict`), and the runtime publish gate returns it on the 2xx `advisories` array rather than refusing the write. What changes is the report, not any verdict. diff --git a/.changeset/approvals-terminal-run-status-exhaustive.md b/.changeset/approvals-terminal-run-status-exhaustive.md deleted file mode 100644 index b1b25106fe..0000000000 --- a/.changeset/approvals-terminal-run-status-exhaustive.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -fix(approvals): the dead-run sweep classifies every `ExecutionStatus` member, so a `refused` run releases its pending approval (#16433) - -`ApprovalService.releaseDeadRunRequests` guarded on a hand-copied four-member subset of `ExecutionStatus` — `completed`, `failed`, `cancelled`, `timed_out` — written when that enum had eight members. #14945 then appended `refused`, documented on the enum as *"Terminal, never resumed"*, and the subset did not grow with it. A run in `refused` was therefore skipped by the sweep, so a still-pending approval on it read as ALIVE, was never released, and kept its record lock forever. - -**Why this is shipped as a fix rather than left alone.** Nothing inside this repo drives a run to `refused` yet — that is #15788 (lane 2 of the #14945 ruling), still open. But `ApprovalService` takes a HOST-supplied automation surface through `attachAutomation`, so a host whose `getRun` already answers with the status the published spec declares sees the corrected behaviour the moment it upgrades, rather than on the day lane 2 lands. That is a real behaviour change in a published package, which is why it carries a bump instead of `skip-changeset`. - -The repair is not "add `refused`" — that yields a five-member hand-copy with the identical trap re-armed for the tenth member — and it is not "derive the terminal set from the enum" either, since `running` and `paused` are plainly not terminal and a wholesale derivation would default every future member to terminal, i.e. to releasing approvals out from under LIVE runs. Instead the file now declares a **total map** over `ExecutionStatus`, classifying each member `terminal` or `live`, from which the terminal set is derived. A tenth member fails to compile until someone classifies it, and fails a test as well. - -No API change: the classification is module-internal and the package barrel is untouched. diff --git a/.changeset/artifact-granted-permissions-load-binding.md b/.changeset/artifact-granted-permissions-load-binding.md deleted file mode 100644 index e39281c242..0000000000 --- a/.changeset/artifact-granted-permissions-load-binding.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes. - -`EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound). - -Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence. - -Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one. - -New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`. - -This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here. diff --git a/.changeset/audit-write-failure-cause-keyed-report.md b/.changeset/audit-write-failure-cause-keyed-report.md deleted file mode 100644 index c10aa8edbc..0000000000 --- a/.changeset/audit-write-failure-cause-keyed-report.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -A lost audit row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy. - -`reportAuditWriteFailure` — the best-effort catch around `persistAuditTrailRow` — deduped on a single process-wide boolean. After the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process, so a long-running server could keep losing compliance rows for hours to a second, unrelated fault with one `error` line at the top of the log describing the first. `persistAuditTrailRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`. - -The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object being audited. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per failed write. Keyed on the code, the reported-cause set is bounded by the boot-declared object registry and the driver's code vocabulary and does not grow with traffic — measured at 65 lines for 6,500 failed writes and the same 65 for 26,000. - -The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for both tables this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working. - -`@objectstack/types` is added as a dependency for that predicate, rather than hand-rolling a second driver-error vocabulary. diff --git a/.changeset/auth-gate-allowlist-anchored.md b/.changeset/auth-gate-allowlist-anchored.md deleted file mode 100644 index 0e112f6168..0000000000 --- a/.changeset/auth-gate-allowlist-anchored.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/core": patch ---- - -`isAuthGateAllowlisted` matches allow-listed routes at a mount boundary, so an object named `auth` or a record whose id is `health` no longer bypasses the ADR-0069 authentication-policy gate. - -The predicate that decides which paths are exempt from the password-expiry / enforced-MFA gate matched with two UNANCHORED tests: `path.includes('/auth/')` matched at any position, and an `endsWith` test over `['/health', '/ready', '/discovery', '/me/apps', '/me/localization']` matched at any depth. A path segment whose VALUE merely spelled one of those tokens therefore carried the exemption — and object names and record ids are tenant-controlled. Both transport seams hand the predicate a data-plane path directly (`HttpDispatcher.enforceAuthGate` passes `cleanPath`, `RestServer.enforceAuth` passes `req.path`), so these were reachable requests. Measured on the built package before the repair: `/data/auth/123`, `/meta/auth/objects`, `/data/x/health` and `/data/xyz/me/apps` were all exempt, while `/auth/me` (exempt) and `/data/contacts/1` (gated) held as controls. - -- **What replaced them.** The path is read as segments and each test is anchored to a mount base — `/api/v1`, `/api`, or the empty base the dispatcher sees (the hono adapter hands `dispatch()` the app prefix already stripped) — plus at most one environment scope immediately after that base (`/environments/`, or ADR-0006's superseded `/projects/`), because the dispatcher evaluates the gate before its scoped-URL strip. `/auth/…` at that position stays exempt; the five bootstrap reads are EXACT routes there instead of suffixes. The scope is only recognised immediately after a base, which is why `/data/environments/x/health` is not a scoped `/health`. -- **This only ever removes exemptions.** Measured, not asserted: over a generated corpus of 111,152 paths, the number that are newly exempt is **0** and 25,979 stopped being exempt. The check is kept as a test, with the pre-anchoring predicate transcribed beside it, so a later widening cannot arrive quietly. -- **Every genuinely-exempt shape still is**, pinned in both directions: `/auth/sign-out`, `/health`, `/ready`, `/discovery` (dispatcher shapes); `/api/auth/sign-in`, `/api/v1/auth/change-password`, `/api/v1/auth/me/permissions`, `/api/v1/health`, `/api/v1/me/apps`, `/api/v1/me/localization`; and the scoped `/api/v1/environments//auth/sign-out`. - -**If you serve the API from a non-default mount,** an allow-listed route reached as `${basePath}/${version}/…` with `basePath`/`version` moved off `/api` and `v1` is no longer named by the allow-list. That price cannot be avoided: `/rest/v2/health` and `/data/xyz/health` are the same shape, so a rule that accepts an arbitrary base is the defect itself. It costs nothing at either live seam — the dispatcher's path arrives base-stripped, and REST registers its control-plane routes without `enforceAuth` at all — but if you gate a custom mount through this predicate, mount the remediation routes under one of the named bases. diff --git a/.changeset/auth-manager-single-flight-instance.md b/.changeset/auth-manager-single-flight-instance.md deleted file mode 100644 index dfa46ec380..0000000000 --- a/.changeset/auth-manager-single-flight-instance.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): build ONE better-auth instance per boot, so the RFC 8707 resource row is seeded once (#17176) - -`AuthManager.getOrCreateAuth()` assigned its `this.auth` memo only after `createAuthInstance()` had resolved, and that function awaits a dynamic `import('better-auth')`, the plugin list, the password hasher and finally better-auth's own `$context`. Every caller arriving inside that window read `this.auth === null` and started its own build, so overlapping callers constructed one better-auth instance each — measured: three concurrent `getAuthInstance()` calls returned three distinct instances. - -The boot has such callers. `AuthPlugin` dispatches `registerOidcDiscoveryRoutes()` with `void` from its route-mounting `kernel:ready` hook, which returns while that call is still pending, and a later `kernel:ready` hook reads the instantiated social providers off the instance for the account-issuer backfill. - -Each duplicate instance re-runs every better-auth plugin's `init`, and `@better-auth/oauth-provider` seeds the RFC 8707 `sys_oauth_resource` row from there. Its seed is already check-then-insert — `findOne` by `identifier`, then `create` only on a miss — so on a warm database every instance finds the row and inserts nothing. On a FRESH one all of them miss together, all of them insert, and the unique index refuses all but the first: the `Insert operation failed {object: sys_oauth_resource}` line on the first boot of a fresh project. - -`getOrCreateAuth()` now holds the in-flight build so concurrent callers share it. The seed runs once per process on every driver, because there is only one plugin `init` to run it. Two consequences of the new in-flight slot: `setRuntimeBaseUrl()` now reports "already created" for a build in flight (it silently no-opped before), and `applyConfigPatch()` discards a build composed from the pre-patch configuration instead of letting it install itself. - -No log level changed, in this package or any other. diff --git a/.changeset/auth-sso-boot-report-gate.md b/.changeset/auth-sso-boot-report-gate.md deleted file mode 100644 index 9235c0ecbc..0000000000 --- a/.changeset/auth-sso-boot-report-gate.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -Gate the `no_sign_in_account_at_boot` boot report on whether the deployment has a delegated sign-in path. - -The report fires on one store shape — human `sys_user` rows, zero `sys_account` rows — and calls it unrecoverable. On a deployment whose sign-in is delegated to an identity provider that shape is the healthy resting state: `ssoOnlyMode` states it in the auth config contract ("managed (IdP-provisioned) users simply hold no local credential") and names cloud-as-IdP. Such a kernel logged the report at `error` on every boot, including boots that had just served a successful SSO sign-in. - -The report now also reads the runtime's sign-in wiring — SSO-only mode declared, a configured social/OIDC provider, or enterprise SSO with at least one registered `sys_sso_provider` — and stays silent at `error` when one of them holds, recording the shape at `debug` under the same grep token with the reason named. - -Unchanged: `probeSignInAccountsPresence` keeps its existence-only predicate, and a deployment with no delegated sign-in path — including one that merely switched the SSO plugin on with no identity provider registered — still reports at `error`. diff --git a/.changeset/automation-run-declaration-truth-residues.md b/.changeset/automation-run-declaration-truth-residues.md deleted file mode 100644 index 58c9695709..0000000000 --- a/.changeset/automation-run-declaration-truth-residues.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -`sys_automation_run.variables_json` states its presence discriminator in ONE direction, and a row-rebuilt snapshot no longer claims its steps are the pause's - -Three corrections to text this package ships. No behaviour changes; every shape -described below is the ruled design, measured as it already is. - -**`variables_json` said `⇔` where only `⇒` holds.** The field description -declared "present on a completed/failed row" and "the row's run had a pause its -resume consumed before a downstream node failed" to be equivalent. The forward -direction holds — nothing but the consumed-suspension path writes that column on -a terminal row. The reverse does not, for one shape: a run that stranded, was -restored and then finished. `recordTerminal` upserts the SAME `run_` row -with all four snapshot columns explicitly `null` — deliberately, so -"restorable" cannot outlive the condition it describes — which leaves that row -equal, across every column the discriminator is read from, to the row of a run -that never paused at all. Absence means "nothing to restore now", never "this -run never had one", and the restore verb already refuses in exactly those terms: -it names the status it observed and declines to say which. The description now -says so. - -**A snapshot rebuilt from a row does not carry the step log as of the pause.** -`deserializeConsumedSuspension`'s docblock said its `steps` are the log "AS OF -THE PAUSE". That is true of the engine's process-local journal copy only, which -slices `run.steps` back to the step count at the pause; the trimmed array is -never persisted. `steps` are the one field the rebuild takes from the row's own -`steps_json`, which is the terminal row's log of the WHOLE run — and both bounds -on that column keep the failure on purpose (history compaction retains every -failure; the byte cap trims the head). A row-rebuilt snapshot therefore carries -steps the pause did not have. It re-arms the same run regardless: the pause is -`nodeId` plus `variables` / `context` / `correlation`, none of which the step log -feeds. - -**`recordTerminal` now names the verb that reads what it writes** — the -restore path in `engine.ts` — and the three properties of the write that are -that verb's inputs rather than local detail. Its summary line also said -"completed / failed" where the terminal vocabulary has had four members since -the fold was removed from both ends of this write. - -Both falsifying shapes are pinned in `suspended-run-store.test.ts`, including the -indistinguishability itself: the restored-then-finished row and a never-paused -row compare equal across those five columns, with the same comparison separating -them while the snapshot is still there. diff --git a/.changeset/basepath-normaliser-consolidation.md b/.changeset/basepath-normaliser-consolidation.md deleted file mode 100644 index b402f663ba..0000000000 --- a/.changeset/basepath-normaliser-consolidation.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): one base-path normalisation chain, and an MCP resource identifier that is always a URL - -`AuthManager` derived its base path in three independent places. `getMcpResourceUrl()` -read `this.config.basePath` directly and added no leading slash, so a `basePath` -configured without one produced a value that is not a URL at all: - - basePath 'api/v1/auth' -> http://localhost:3000api/v1/mcp - -`new URL()` throws on that (`3000api` is not a port), so the RFC 9728 path-inserted -well-known route derived from it throws too, and `@better-auth/oauth-provider` 1.7.2 -refuses to seed the `sys_oauth_resource` row from it at plugin init ("resource -identifier ... must be an absolute URI (RFC 8707 §2)"). With -`enforcePerClientResources` at its `true` default, every MCP client was then refused -for want of a link row. That input class could never mint or match a token, so -repairing it re-selects nothing. - -There is now exactly one read of the configured value and one chain above it: - - configuredBasePath() the configured value VERBATIM — what better-auth is handed - └─ rootedBasePath() + a leading slash when absent (better-auth's own rule) - ├─ getAuthIssuer() = origin + this - └─ getBasePath() = this, trailing slashes stripped - └─ getMcpResourceUrl() = origin + this minus `/auth` + `/mcp` - -`getAuthIssuer()` and `getBasePath()` answer byte-identically to before for every -spelling. Only `getMcpResourceUrl()` moves, and only for a non-canonical `basePath`: -a missing leading slash (was not a URL), repeated trailing slashes, or a configured -`/` (was a `//mcp` path no mount serves). A canonical `basePath` is unchanged on all -three getters. diff --git a/.changeset/better-sqlite3-peer-record-remeasured.md b/.changeset/better-sqlite3-peer-record-remeasured.md deleted file mode 100644 index 52ba213f8a..0000000000 --- a/.changeset/better-sqlite3-peer-record-remeasured.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/cli": patch -"create-objectstack": patch ---- - -fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) - -A tree containing `@objectstack/cli` reports an unmet peer on every fresh -resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares -`^13.0.3` — and the reading that decides what to do about it lived only inside -the scaffold generator's prose. No range moves here and no resolution moves: -what changes is the recorded reason, which had two measured errors in it, plus -a gate that now holds the declaration to that reason. - -**The declaration is correct and stays at `^13`.** Three readings, taken rather -than inherited: - -- The peer is `optional`, and it governs exactly one configuration — a raw - better-sqlite3 `Database` passed to better-auth's `database` option. - `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or - `undefined` for better-auth's in-memory adapter. Never a `Database`. -- better-auth cannot be incompatible with better-sqlite3 13, because it never - touches it: of the 464 files in the published `better-auth@1.7.2` tarball, - exactly one names better-sqlite3 — `package.json`, the peer declaration - itself — and no code file references it (positive control: `kysely` names 9). - It accepts a `Database` the caller constructs; its own sqlite test path uses - node's built-in `node:sqlite`. -- Pinning back to `^12` is not a neutral alternative. Measured on a bare - project depending on `@objectstack/cli@17.3.0`, it clears the report only by - resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that - nothing loads. The scaffold's existing `allowedVersions` entry clears the - same report with the lockfile byte-identical. - -**Two corrections to the record.** It credited `@objectstack/driver-sql` for -the 13.x copy; on the chain that actually reports -(`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the -CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. -And it was measured on better-auth 1.7.1 while the family has been pinned at -1.7.2 since — re-measured, with the empirical reading replaced by a structural -one. - -The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both -producers (`objectstack init` and the `create-objectstack` blank template); the -declarations, the widening entry and the resolution are untouched. diff --git a/.changeset/blank-node-condition-refused-at-registration.md b/.changeset/blank-node-condition-refused-at-registration.md deleted file mode 100644 index 73d480ebf8..0000000000 --- a/.changeset/blank-node-condition-refused-at-registration.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -fix(service-automation)!: a whitespace-only `config.condition` is refused at `registerFlow`, the rule the edge door has carried since #15807 (#17322) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): a flow -node's `config.condition` — a `decision` node's predicate, and on a `start` node -the **trigger gate** — is now refused at `registerFlow` when its source is blank -after trimming, where it used to register clean and answer a **silent `false`** -at every evaluation. - -Two doors, the same authored value, two fates until now. `FlowEdgeSchema.condition` -composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is -refused at `FlowSchema.parse`, by name. A node's `config` is an open -`z.record(z.string(), z.unknown())`, so the same value passed through verbatim, -reached `AutomationEngine.evaluateCondition`'s empty-source arm — `exprStr.trim() -=== ''` — and returned `false`, under a comment that names that arm as being for -an **unauthored** condition. `' '` was authored. The branch never ran, forever, -with nothing said at any layer. - -```yaml -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } # the flow was gated shut - - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } # the same blank, through the envelope key -``` - -> An expression in an evaluated slot needs a non-blank `source`: the expression -> engine evaluates `source` (the canonical persisted form) and -> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` -> that is blank after trimming, would validate and register and then fault at -> run time. Write `{ dialect: 'cel', source: '…' }`. - -- **The rule is imported, not re-derived.** `registerFlow`'s structural pass runs - the condition's source through `EvaluatedExpressionInputSchema` itself, so the - node door and the edge door cannot drift into two notions of "blank" or two - sentences for it — the property the #15662 campaign built the shared refusal - for. Nothing is exported from this package to carry it, and no new export was - added. -- **Applied to the SOURCE, not to the whole value**, deliberately: the union - would also refuse an envelope with no `dialect` or with a dialect outside its - enum, and this slot admits both (`structuralConditionRefusal`'s docblock, - #4336). The narrowing is exactly the blank population and nothing else — a - `cron` envelope with a real source still earns its own pre-existing verdict, - and a bare string with a `{…}` brace trap still earns #1491's. -- **`evaluateCondition` is unchanged and still answers `false`.** It is the - shared evaluator and a public method on an exported class, so its throw - behaviour is itself a contract; and a stored flow reaches it whatever the - producer refuses. This change is at the producer only. -- **`structuralConditionRefusal` is unchanged.** A string is still a well-shaped - condition; the new refusal sits behind the shape one and in front of the CEL - one, and answers the evaluated-slot sentence rather than - `STRUCTURAL_CONDITION_SHAPE_REFUSAL`. - -**What an author does with a refused condition.** A whitespace-only condition was -never a predicate — the engine answered `false`, so the branch never fired, and on -a `start` node the flow never triggered. **Remove the `condition` key** if the node -was meant to be unconditional, or **write the expression** if it was meant to -branch. ⚠️ Those two are not interchangeable: a refused condition never fired, -while an absent `condition` on a decision node is an unconditional branch that -always fires and an absent one on a start node is a gate that always opens. -Deleting the key to clear the refusal inverts the node rather than preserving it. -Every condition with a non-blank source is unchanged, and nothing is renamed or -retired. - -**A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole flow, -not just the branch.** Stored flows are deliberately not canonicalized by -`applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same -skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`); they canonicalize -at `registerFlow`, and each of the three boot paths in -`service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one -`warn` naming the flow, and continues. So a node condition that used to answer a -silent `false` while the rest of the flow ran now takes the flow down with it: it -is never registered, its trigger is never armed, and the announcement is that one -warn line — `[Automation] failed to register flow` at boot, `[Automation] -cold-boot flow bind: failed to register flow` at the kernel:ready bind, -`[Automation] flow re-sync: failed to register flow` on a re-sync. The warn line -is also the locator: the refusal names the node and the slot, e.g. `node 'gate' -(start) condition`. A stack authored in config files has a second door, -`objectstack validate` — see the note below for what that door does **not** yet -say. - -**A repo-wide census on this branch found zero authored `config.condition` values -of this shape**, against a lit control: a textual probe over all 8,123 tracked -source files found **461** non-blank `condition:` string literals and **zero** -blank-after-trim ones in any authored flow (the four blank hits are two prose -examples inside #15807's own changeset and two `packages/lint` test fixtures). -There is nothing in this repository to rewrite. - -⚠️ **Two follow-ups this change does not carry, both outside this card's package.** -(1) The ADR-0087 D3 entry named above, -`flow-edge-condition-evaluated-slot-source-required`, registers the decision this -change is a second face of — an evaluated slot requires a non-blank `source` — but -its `surface` and `acceptanceCriteria` name only `edges[].condition`. They need -widening to `config.condition` so a consumer replaying the chain is told to sweep -the node key too; that file is in `packages/spec`. -(2) `@objectstack/lint`'s `validate-expressions` applies only -`structuralConditionRefusal` to a structural condition, so `objectstack validate` -still reports nothing for a blank `config.condition` that `registerFlow` now -refuses — the two doors disagree until that rule is rebound as well. diff --git a/.changeset/chilled-eagles-arrive.md b/.changeset/chilled-eagles-arrive.md deleted file mode 100644 index 45c23504a2..0000000000 --- a/.changeset/chilled-eagles-arrive.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/client": patch ---- - -`auth.login` and `auth.register` now deliver the `SessionResponse` envelope they declare. - -Both methods annotate their return as `SessionResponse`, whose base `BaseResponseSchema` declares -`success` as a required boolean. Both carried an inline lift that filled `data` and never wrote -`success`, so neither delivered the type it advertises and every consumer keying on the envelope -flag — `ObjectStackClient.unwrapResponse` keys on exactly this — read `undefined` rather than -`true` or `false`. They now run the same lift `auth.me` / `auth.refreshToken` use, so the family -cannot deliver two different envelopes again. - -The credential is unchanged: `data.token` is still the token the route puts in the response body, -byte-identical, and `login` / `register` still arm the client's bearer token from it. - -Known residue, unchanged by this release: `data.session` is still absent from what these two -methods return. `POST /sign-in/email` and `POST /sign-up/email` serve no session object, id or -expiry in the body or in any header, so the member is not obtainable without a second -`GET /get-session` call — read it from `auth.me()`. Nothing is synthesized in its place. diff --git a/.changeset/cli-register-requires-name.md b/.changeset/cli-register-requires-name.md deleted file mode 100644 index 0a72c1b94c..0000000000 --- a/.changeset/cli-register-requires-name.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os register` requires a name, and the request-side `as any` that hid the mismatch is gone (#16932) - -`os register` prompted **"Name (optional)"**, typed its own payload with `name?`, and guarded `email` and `password` but not `name` — three places agreeing the field was optional. The route it actually posts to does not agree: on a fresh environment (no human user yet, so the audience gate's bootstrap bypass admits the request and the route's own validation is the only judge left), `POST /api/v1/auth/sign-up/email` answers `400 VALIDATION_ERROR` — `[body.name] Invalid input: expected string, received undefined`. The same run with a name supplied answers `200` and creates the account. - -So the first-use path failed on exactly the answer the prompt invited, and `RegisterRequestSchema`'s required `name` was right all along. - -- the prompt now reads `Name: `; -- an empty answer is refused by the CLI itself (`Name is required`), beside the existing `Email is required` / `Password is required` guards, before any request goes out; -- the payload is annotated with the declared `RegisterRequest` instead of a hand-written twin; -- the `as any` at the call site is removed, so the next divergence between this command and the declared request type is a compile error rather than a `400` a user meets on their first command. - -No behaviour change for anyone already passing a name, by flag or at the prompt. diff --git a/.changeset/client-adopts-rotated-session-token.md b/.changeset/client-adopts-rotated-session-token.md deleted file mode 100644 index 7347744748..0000000000 --- a/.changeset/client-adopts-rotated-session-token.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534) - -Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in `Set-Cookie` (and, through `bearer()`, in the `set-auth-token` response header), and DELETE the row the caller was presenting: - -| route | where the new credential is | -| --- | --- | -| `auth.twoFactor.verifyTotp()` on the enrolment lane | body — `token`, and it is the LIVE one (plugin-auth's `two-factor-rotated-token-echo` repairs the vendor's stale echo) | -| `auth.changePassword({ revokeOtherSessions: true })` | body — `token` | -| `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` | - -A browser is carried across all three by its own cookie. A bearer client — this SDK's own mode — kept presenting the DELETED session's token, so its very next call answered `401 UNAUTHORIZED`. Measured against a real `AuthManager` (better-auth 1.7.2) over a real driver, driven through the real `ObjectStackClient`, `login → enable → verifyTotp → disable → deleteUser` could not run to the end without the caller re-seating `client.token` by hand between the steps. - -The three methods now adopt the rotated credential themselves, the way `login()` already adopts the token it is handed. The `token` members stay on the wire and stay declared, so a caller that keeps its own credential store is unaffected; what changes is that it no longer has to. - -**No public surface moves.** No new export, no new option or flag, no new key on any declared request or response type — the SDK stores a token the server already sends and this package already declares. Graded `minor` rather than `patch` because the published runtime behaviour of three methods moves for existing callers. - -## What does NOT change, deliberately - -The adoption is on those three routes only, never in the shared `fetch` wrapper. `set-auth-token` rides **every** response that stages a session cookie — `POST /update-user` stages one to carry the updated user without rotating anything — and it carries the SIGNED `.` spelling while every JSON `token` echo carries the UNSIGNED one. A wrapper-level read would therefore rewrite the stored credential into a different spelling of the SAME session on ordinary traffic. `auth.me()`, `auth.sessions.list()`, `auth.updateUser()` and `auth.twoFactor.verifyBackupCode()` (which does not rotate — the vendor echoes the session it resolved at entry) all leave the stored credential byte-identical, and that is pinned. - -A cookie-only deployment sends no `set-auth-token`; there is then nothing to adopt and `twoFactor.disable()` leaves the stored credential exactly as it was. `changePassword` without `revokeOtherSessions` answers `token: null` and likewise stores nothing. - -The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change. diff --git a/.changeset/client-environments-delete-purge.md b/.changeset/client-environments-delete-purge.md deleted file mode 100644 index 2799547d29..0000000000 --- a/.changeset/client-environments-delete-purge.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): `environments.delete` gains `purge` and documents the hosted control plane's two-step delete (#17636) - -The hosted control plane's `DELETE /api/v1/cloud/environments/:id` follows cloud ADR-0014: a live environment is **archived**, and only a second call with `?purge=1` on the now-archived environment tears it down. `?force=1` confirms a production environment and is never a purge. The SDK sent `force` only, so an SDK caller could archive an environment but never purge one. - -- `opts.purge?: boolean` sends `?purge=1`. It combines with `force`: a production environment is torn down with `{ force: true }`, then `{ force: true, purge: true }`. Calls that pass no options, or `force` alone, build exactly the URL they built before. -- The return type declares the two answers the route actually sends, discriminated by `deleted`: - - archive: `{ environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings, message }` - - teardown: `{ environmentId, deleted: true, purged: true, warnings }` - - Both members carry every key the old declaration named (`deleted`, `environmentId`, `warnings`), so existing reads still compile. -- The JSDoc no longer describes a one-call cascade delete: a live environment is archived, `purge` acts only on an archived environment, `force` is the production confirmation, and a `failed` environment is torn down in one call. -- `organizations.delete`'s JSDoc no longer claims that server-side hooks tear down the organization's environments. No hook does; delete each environment first. - -Graded `minor`: a purely additive widening of a published method's accepted options and declared answer (the "WHICH LEVEL" rule in `.github/workflows/pr-automation.yml`). Nothing is removed or renamed. diff --git a/.changeset/client-get-active-member-names-the-organisation.md b/.changeset/client-get-active-member-names-the-organisation.md deleted file mode 100644 index 245794df04..0000000000 --- a/.changeset/client-get-active-member-names-the-organisation.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568) - -**BREAKING** — the answer this published method gives moves for existing inputs. The signature, the declared return type and the export are byte-identical; what changes is the response an existing call observes, stated below as a before/after pair per input. - -The method built `GET /organization/get-active-member?organizationId=…`, and better-auth 1.7.2's handler for that path reads `session.session.activeOrganizationId` and never looks at `ctx.query`. The query string was dead on arrival: a client doing a permission check for organisation B while A was active got **A's** membership row back, with a 200 and no diagnostic — the wrong-but-plausible answer, silently. The SDK's own JSDoc promised "the calling user's membership row in the given organisation", so this was a declared capability the runtime did not deliver. - -It now asks the question honestly, in two requests: - -1. `GET /get-session` — the caller's own user id; -2. `GET /organization/list-members?organizationId=…&filterField=userId&filterValue=&limit=1` — the row, unwrapped from the one-entry page. - -`list-members` reads `ctx.query.organizationId`, and its rows carry the identical shape (`OrganizationMemberWithUserWire`, user projection included), so the signature and the declared return type are unchanged and no caller's types move. - -## What an existing call observes, before and after - -Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`. Each bullet is one input, with the response it drew before and the response it draws now. - -- **An organisation id other than the session's active one.** Before: a 200 carrying the **active** organisation's membership row, whatever id was named. After: a 200 carrying the **named** organisation's row. An input that named the active organisation's own id drew that organisation's row before and draws the same row after — `auth.me()` is where that id is readable, on `session.activeOrganizationId`. -- **An organisation the caller is not a member of.** Before: the named organisation was never consulted, so the answer was about the **active** one — a 200 carrying the active organisation's row, or `400 MEMBER_NOT_FOUND` when the caller had no row there either. After: `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`, the server's own refusal, about the organisation that was actually named. -- **Any id, on a session with no active organisation.** Before: `400 NO_ACTIVE_ORGANIZATION`. After: a 200 carrying the caller's row in the named organisation. `setActive` has stopped being a precondition, which is the point of naming the organisation. -- **An empty `organizationId`.** Before: a 200 carrying the **active** organisation's row — better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`, so an empty string fell through to session state and the wrong-but-plausible answer survived on that one input. After: the SDK refuses it before the wire, with a thrown `[ObjectStack] organizations.getActiveMember: organizationId is required`. - -Two things do not move: an anonymous caller still draws `401 UNAUTHORIZED`, thrown by the same session middleware that guarded the old route; and the row's shape is the same on both sides. The method now makes two HTTP requests where it made one. - -Graded `minor` rather than `patch`: the method's published behaviour moves for existing callers, which is the same clause-② judgement this PR declares, and the maintainer's ruling of 2026-09-04 (decision batch #35) holds that a change to a published package's public surface takes at least `minor` — a commit type may raise a bump, never lower it below what the act requires. The banner above carries the breaking-ness that the level cannot, per the ruling recorded on #16568 on 2026-09-08. - -The auth route ledger's `GET /api/v1/auth/organization/get-active-member` row is rebooked from `sdk` to `server-only` in the same change: `sdk` means "expressed by the SDK", and no SDK method builds that URL any more. The `get-session` and `list-members` rows gain the method in their notes, since it now builds both. Ledger-internal, nothing published moves with it. - - diff --git a/.changeset/client-get-session-envelope-and-refresh-read.md b/.changeset/client-get-session-envelope-and-refresh-read.md deleted file mode 100644 index ba6704d205..0000000000 --- a/.changeset/client-get-session-envelope-and-refresh-read.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/client": patch ---- - -fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760) - -Both methods annotate their return as `SessionResponse` — ObjectStack's REST -`{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth -owns those bytes and answers **bare**. Measured against a real `AuthManager` -(better-auth 1.7.2, organization plugin) over a real driver: - -``` -GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} -GET /api/v1/auth/get-session (anonymous) -> 200 null -``` - -So `(await client.auth.me()).data.user` type-checked and was `undefined` at -runtime, while `.user` — the real payload — did not type-check. The annotation -pointed every caller at the wrong key. - -## What changed - -- The bare answer is now lifted into the declared envelope, the same lift - `auth.login` has always carried for `/sign-in/email`. `SessionResponse` is - **unchanged** and so is each method's published return annotation: the fix is - in what the methods produce, not in what they promise. -- The lift fills `success` as well as `data`. `SessionResponseSchema` is - `BaseResponseSchema.extend(…)` and that base declares `success` as a required - boolean, so a body carrying `data` alone still would not parse as the declared - type. -- The raw `.user` / `.session` keys are **kept** alongside `data`. They are what - callers were pushed onto while the declared shape was unreachable; dropping - them would trade one silent breakage for another. -- `auth.refreshToken` now reads `data.session.token`. It used to read - `data.data?.token` — a field this route does not produce at any nesting, so - the method returned successfully having captured nothing. A bearer-mode client - calling it to refresh kept whatever credential it already had, silently. - -## The read was not a consequence of the envelope - -Worth stating because the reverse is the natural assumption: enveloping the body -does **not** put a token at `data.token`, because the route serves no top-level -`token` to lift. The only credential in the body is `session.token`, and that is -now the read. Fixing the shape alone would have left `refreshToken` exactly as -inert as it was. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `(await client.auth.me()).user` | still works — kept deliberately | -| `(await client.auth.me()).data.user` | now populated (was `undefined`) | -| `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` | - -`refreshToken` stores the **unsigned** session token, which is the spelling -`/get-session` serves; `bearer()` accepts it and the signed -`token.signature` form interchangeably, so a client that held the signed form -stays signed in across the call. - -Two answers stay outside the declared type and are **not** addressed here: the -anonymous `null`, which would need the published return annotation to widen, and -`SessionUser.image`, declared `z.string().optional()` against a route that -serves `null` (#17235). The sibling `auth.login` / `auth.register`, which -normalize into `data` but set no `success`, are #17234. diff --git a/.changeset/client-invite-role-default-member.md b/.changeset/client-invite-role-default-member.md deleted file mode 100644 index eb6d7ef46f..0000000000 --- a/.changeset/client-invite-role-default-member.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/client": patch ---- - -fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582) - -`organizations.invite` declares `role?` as **optional** and forwarded the caller's object to better-auth verbatim. better-auth 1.7.2's body schema for `POST /organization/invite-member` makes `role` **required**, so the documented-looking minimal call was refused before it reached any ObjectStack code: - -``` -client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR) -``` - -Omitting `role` now sends `'member'`. **No published type moves** — `role` stays optional, and a caller who names a role still gets exactly that role on the wire (including `role: undefined`, which is treated as omission rather than dropped). - -The default is `'member'` because the sibling `organizations.invitations.resend` has always substituted exactly that over the **same** vendor endpoint. That asymmetry is why the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. It is also the least-privileged name in the closed membership vocabulary (ADR-0108 D1 — `orgRoleGrade` floors at `member` and rises only for `owner`/`admin`), and an invitation is a pending row the invitee must still accept, so the implicit choice cannot confer reach the caller did not ask for. - -Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after: - -``` -before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"} -after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...} -``` - -No caller had to change: the census found no in-repo or Console caller using the two-argument form, so this repairs a path that was declared and unreachable rather than one that was in use. diff --git a/.changeset/client-packages-get-single-true-type.md b/.changeset/client-packages-get-single-true-type.md deleted file mode 100644 index c7791d29f7..0000000000 --- a/.changeset/client-packages-get-single-true-type.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `packages.get` binds the bare `InstalledPackage` row on both the global and the environment-scoped client, replacing a `{ package }` envelope no surface emits (#12034) - -`client.packages.get(id)` and `ScopedEnvironmentClient.packages.get(id)` now resolve to **`InstalledPackage`** — the row itself — instead of an object wrapping it. - -**Migration — read the row directly, not `.package`:** - -```ts -// before -const { package: pkg } = await client.packages.get('com.acme.crm'); -const pkg2 = (await scoped.packages.get('com.acme.crm')).package; - -// after -const pkg = await client.packages.get('com.acme.crm'); -const pkg2 = await scoped.packages.get('com.acme.crm'); -``` - -FROM `{ package: any }` (global) and `{ package: InstalledPackage }` (scoped) TO `InstalledPackage` on both. - -This is a **narrowing**: a `.package` read compiles today and stops compiling after this change. That is the point of the change rather than a side effect of it — the wrapper was never what the wire sent, so every one of those reads was already `undefined` at runtime, and on the global method the `any` member is what kept the falsehood invisible. Nothing about the request or the wire changes; only the declaration moves to match what the server has been sending. - -Why it can be bound now, when #11925 deliberately left it erased: this route used to be served by two implementations that disagreed — the runtime dispatcher sent the bare row, the `@objectstack/rest` registrar sent `{ package }` — so no declaration was true on both. The registrar's read routes were removed in #16628, leaving the dispatcher's `/packages` domain as the single implementation. It builds the detail body with the same expression it maps over every `list` row, which is why this type now agrees with the `InstalledPackage[]` that `packages.list` has already declared, and with `GetInstalledPackageResponseSchema` in `@objectstack/spec`, which has declared `data: InstalledPackageSchema` all along. - -The environment-scoped method is the sharper half of the change: its member was a real `InstalledPackage`, not `any`, so `.package` reads there looked type-safe while returning `undefined` against every surface that has served that path since #16628. diff --git a/.changeset/config-refusal-throws-so-json-faces-emit.md b/.changeset/config-refusal-throws-so-json-faces-emit.md deleted file mode 100644 index 6466423d5e..0000000000 --- a/.changeset/config-refusal-throws-so-json-faces-emit.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `resolveConfigPath` throws its two refusals so the ten `--json` faces emit their envelopes, and `os verify` gains the catch-all it never had (#15547) - -Every `--json` face in this CLI declares that it answers an error path with a -payload. `resolveConfigPath()` was the one path that bypassed that declaration: -it wrote its refusal and then called `process.exit(1)` **directly**, so nothing -was thrown and the catch-all each command already carries — all of which sit -downstream of a throw — never ran. Ten published faces answered a missing config -file with an empty stdout. - -Measured before this change on the published entry `packages/cli/bin/run.js`, -`NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces -(`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` · -`migrate meta` · `validate` · `verify`) across both branches of the helper, 19 -runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B -(auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the -same 19 runs answer **exit 1 with a parseable document on stdout**, stderr -unchanged byte for byte. - -The refusals now throw `ConfigRefusalError`. That is not a new contract — it is -this path being pulled back onto the one its callers had already published, so -it adds **zero** accept-set members and **zero** error codes. - -Three properties hold it in place: - -- **No face becomes a crash dump.** `os verify` had no `try` at all — measured, - a throw through it produced an oclif error line and no payload where every - sibling emitted an envelope — so it gains the catch-all its nine siblings - already had, in this same change rather than after it. -- **The text face does not narrow.** The refusal and both hint lines are still - written by the helper, to stderr, byte-identical: all 19 non-`--json` runs - compare equal before and after on stdout, on stderr and on exit status. The - catch-alls skip re-rendering the sentence a second time on stdout. -- **No error code is minted.** The thrown error carries neither `code` nor - `httpStatus`, so `errorCodeFields()` contributes nothing and each face emits - its own bare `{ error }`. Whether that shape is right is **#15549**'s open - question, and this change deliberately does not answer it. - -The `--json` stdout-purity instrument is widened with the fix rather than after -it: the pre-boot family's discovery moves into a shared module, the pin that -drives it now demands a document (empty stdout no longer passes) and compares -the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts` -— whose own discovery is `bootSchemaStack`-based and cannot see a command that -fails above the kernel — reconciles against that population so neither half can -be lost silently. diff --git a/.changeset/cron-typed-positions-retired.md b/.changeset/cron-typed-positions-retired.md deleted file mode 100644 index 5634313061..0000000000 --- a/.changeset/cron-typed-positions-retired.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: delete the seven cron-typed positions nothing evaluated — export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules (ADR-0049) - - - -**BREAKING** — seven authorable positions across five schemas are DELETED. Executes the -maintainer ruling of 2026-09-06 (director decision batch #56, 「其他同意」 on the per-family -recommendation: option A — retire — per family) under ADR-0049 enforce-or-remove, by the -route the maintainer ruled on 2026-09-10: **直接删** — a bare deletion, with no -`retiredKey()` tombstone, no ADR-0087 D2 conversion and no D3 semantic entry. - -Seven positions declared a `CronExpressionInputSchema` slot that the parse normalized into -the `{ dialect: 'cron', source }` envelope and that NOTHING evaluated — the ADR-0058 D7 -ledger row `cron-declared-unwired` had every one of them `unevaluated`. - -| family | schema | deleted position | reachable from a stack manifest | -|:--|:--|:--|:--| -| export schedules | `ScheduledExport`, `ScheduleExportRequest` (`api/export.zod.ts`) | `schedule.cronExpression` (both) | no — API contract nothing serves | -| flow schedule state | `ScheduleState` (`automation/execution.zod.ts`) | `cronExpression` (was REQUIRED) | no — runtime state | -| connector sync | `DataSyncConfig` (`integration/connector.zod.ts`) | `schedule` | **yes** — `Connector.syncConfig`, `defineStack({ connectors })` | -| cache warmup | `CacheWarmup` (`system/cache.zod.ts`) | `schedule` | no | -| backup / DR testing | `BackupConfig`, `DisasterRecoveryPlan.testing` (`system/disaster-recovery.zod.ts`) | `schedule` (both) | no | - -**What an upgrading author actually observes.** None of the five schemas is `.strict()`, so -a bare deletion means Zod DROPS the key at the PARSE: an existing document still parses and -still loads, and the value is discarded there without a word. There is nothing for -`objectstack migrate meta` to list and nothing for the ADR-0087 chain to replay — the value -was already inert before this change, and it is inert after. - -The parse is not the only channel, and the two that speak are worth stating exactly, -because a reader who stops at "non-strict schema" will conclude the opposite: - -- **`os validate` / `os build` NAME the dropped key**, for the one deleted position a stack - manifest reaches (`connectors[].syncConfig.schedule`). `os validate` exits 0 and reports - `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its - value is dropped at load.` — in the text face and in `--json`'s `warnings`; `os build` - prints the same line under `Undeclared authoring keys — dropped at load (#3786)`. The - channel is `lintUnknownAuthoringKeys`, which walks every stack collection whose entry - schema is strip-mode, and `connectors` is one. **`os validate --strict` treats that warning - as an error and EXITS 1**, so a pipeline running `--strict` over an otherwise-clean stack - refuses the upgraded manifest until the key is deleted. `os migrate meta` still lists - nothing, in either direction. -- **`tsc`**: a TypeScript author annotating with `Connector`, `ScheduledExport`, - `ScheduleState`, `CacheWarmup`, `BackupConfig` or `DisasterRecoveryPlan` gets an - excess-property error at the key and deletes it. - -The other six positions are not reachable from a stack manifest, so no CLI walk visits them: -for those the parse-level strip really is the whole of it. - -**What stays, byte-identical:** every other key of the five schemas and every export — no def -leaves the public surface. `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep -their `timezone` (still defaulting to `UTC`); `ScheduleState` keeps `timezone`, `status` and -`nextRunAt`, and a state without `cronExpression` now parses (the requiredness left with the -key); `CacheWarmup.strategy` keeps its `scheduled` member — a value, not a position the -ruling names, and exactly as inert as before. - -**One published TS MEMBER does leave, and "no def leaves" does not cover it.** The required -`cronExpression: string` member is deleted from `ScheduleExportInput` in -`contracts/export-service.ts` — the input type of `IExportService.scheduleExport`, a -published runtime TS interface (both names are in `api-surface/contracts.json`). It follows -the two spec positions it mirrored: with `ScheduledExport.schedule.cronExpression` gone, an -input demanding the key would ask a provider for a cadence it cannot store. The interface, -the method and every other member stay. Measured blast radius: no source outside -`packages/spec` names `ScheduleExportInput` or `IExportService` — 0 hits in this repo -(positive control: a symbol of the same class resolves outside `packages/spec` in the same -sweep) and 0 in `objectui` (control: 1326 files there import `@objectstack/spec`). An -implementor that *does* exist off-tree drops the member from its object literal; a caller -constructing a `ScheduleExportInput` drops it from the literal it passes. - -**Not in scope, deliberately:** `CronSchedule.expression` (`system/job.zod.ts`, read by -`croner` — the ONE cron slot the platform evaluates), `KnowledgeRefreshPolicy.cron` -(experimental by design), `Object.titleFormat`, and the `PromptTemplate` pair (marked, not -retired, on its sibling card). - -## This change states no before/after rewrite, because there is none - -A breaking changeset in this repo normally states the old spelling beside the new one. -This one has no such pair to state: the same document PARSES before and after, the value -was inert in both, and no conversion can be written for it — so a metadata upgrader has no -edit to make and `os migrate meta` has nothing to list. That is a statement about the -migration chain, not about silence: `os validate` / `os build` do name the dropped -connector key and `os validate --strict` refuses on it (above), and `tsc` names the key and -the line for a TypeScript author. What follows is guidance for authoring a cadence going -forward, not a rewrite of an existing document. - -## What to write instead - -There is no replacement on any of the five schemas: no export scheduler, flow-state -scheduler, connector-sync scheduler, cache-warmup engine, backup engine or DR-test runner -exists to declare a cadence to. The one cron slot the platform evaluates is -`Job.schedule.expression` (`system/job.zod.ts`) — work on a cadence is a `job` whose handler -you write: - -```ts -// A connector that used to carry `syncConfig.schedule: '*/15 * * * *'` declares -// the cadence as a job instead; the handler drives the connector. -defineStack({ - connectors: [{ name: 'sap_erp', label: 'SAP ERP', type: 'saas', syncConfig: { strategy: 'incremental' } }], - jobs: [{ name: 'sap_erp_sync', schedule: { expression: '*/15 * * * *' }, handler: 'syncSapErp' }], -}); -``` - -The retirement kit, in the shape the 2026-09-10 ruling prescribes: - -- the key is DELETED at all seven sites (`api/export.zod.ts` ×2, - `automation/execution.zod.ts`, `integration/connector.zod.ts`, `system/cache.zod.ts`, - `system/disaster-recovery.zod.ts` ×2). Each site keeps a source comment recording what - left, why nothing ever read it, and what does work instead -- **no ADR-0087 registration at all** — no `RETIRED_KEYS_BY_MAJOR[18]` entry, no D2 - conversion, no D3 semantic entry, and nothing added to the protocol-18 chain step. That is - the ruling: 「直接删」, taken over the seat's written recommendation to keep the connector - family's D2, on the reading 「我们的客户也不会按照你的设想的版本按顺序升级」 -- the four baseline rows that existed (`automation/ScheduleState:cronExpression`, - `integration/DataSyncConfig:schedule`, `system/BackupConfig:schedule`, - `system/CacheWarmup:schedule`) are deleted from `authorable-surface/` in this same commit, - each carrying the #4650 proof the build computes for itself: the def is not reachable from - the 26 metadata-type roots. The three nested positions never had a row of their own -- no liveness-ledger row: none of the five schemas is an enrolled ledger type -- the ADR-0058 D7 expression-conformance ledger loses its `cron-declared-unwired` row (every - position it covered is gone, so discovery by roster name no longer sees them); the cron - dialect is now exactly the one evaluated slot plus the one experimental-by-design slot -- pin tests (`cron-typed-positions-retirement.test.ts`): per site, the authored value is - accepted and stripped and the enclosing block still parses, on the base schema and through - every nesting carrier (`Connector.syncConfig`, `stack.connectors[]`, the `/meta/connector` - door, `DisasterRecoveryPlan.backup`, `DistributedCacheConfig.warmup`); the `tsc` channel; - and — with lit and dark controls — that no `RETIRED_KEYS_BY_MAJOR` entry, no D2 conversion - and no D3 semantic entry names any of the seven -- generated baselines and docs follow the schema: the five reference pages are regenerated, - the published `objectstack-formula` skill's `cron` row drops the retired carriers and keeps - `Job.schedule.expression`, and `packages/spec/docs/SYNC_ARCHITECTURE.md` stops teaching - `syncConfig.schedule` -- `json-schema.manifest/` and `api-surface/` are unchanged, and correctly so: the first - ratchets def *names* and the second export *existence*; deleting keys removes neither diff --git a/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md b/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md deleted file mode 100644 index 933d4cdf19..0000000000 --- a/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the `dashboard.widgets[].chartConfig` liveness row is re-anchored to the current objectui pin — 12 of 14 keys reach the renderer, not 9 (#17385) - -`packages/spec/liveness/dashboard.json` ships inside this package, so its rows are part of what an author reads. The `widgets.children.chartConfig` row was measured on 2026-08-09 against `objectui @230ffd875` and both halves of that reading are now superseded — re-measured by hand against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. - -**The citation moved repos-internally.** `chartConfigPresentation` was lifted out of `plugin-dashboard` into `@object-ui/core`'s `chart-presentation` module, so the old pointer at `packages/plugin-dashboard/src/DatasetWidget.tsx:380-429` — still byte-exact at the commit it names — lands on the re-export block at that range in the pinned tree, while the nine `if`s it describes are in another package. A foreign path is counted and never resolved by `check:liveness`, deliberately, so nothing mechanical could have caught this: only a hand re-measurement does. - -**The count changed.** `xAxis` / `yAxis` / `series` were recorded as unforwarded on the grounds that they are derived from the dataset selection. They are forwarded today: the dataset keeps series MEMBERSHIP and the column each binding reads (`ChartSeries.name` and `ChartAxis.field`, dropped on the way through) while every other key on those objects merges onto the derived binding with the explicit binding winning. `type` and `aria` remain the two keys that do not reach this face. - -Evidence text only — no verdict moves, no schema key changes, and the row still carries no per-key `children`. The per-key drill, the `type` / `aria` dispositions and the authored-versus-derived precedence the protocol does not yet state stay open on #17385. diff --git a/.changeset/dashboard-item-level-property-names.md b/.changeset/dashboard-item-level-property-names.md deleted file mode 100644 index adc9e3e349..0000000000 --- a/.changeset/dashboard-item-level-property-names.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/rest": patch -"@objectstack/platform-objects": patch ---- - -feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) - -## What was wrong - -The Studio property panel renders `dashboard.header.actions[]` as a table whose -column headers read `items.properties[k].title ?? k` from the JSON Schema -derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields -(`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback -arm ran for every locale, English included, and the maker saw machine keys. -Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, -decorates the `FormFieldSpec` tree, which the table never reads. And the platform -catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared -no children under the composite, so `os i18n extract` emitted no -`header.showTitle` / `header.showDescription` / `header.actions` key and the -console shipped a private overlay for exactly those three. - -## What changed - -- **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author - `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the - derived JSON Schema names each column. New export - `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in - `@objectstack/spec/system`: every `metadataForms..fields..label` - at any locale of the chain becomes the `title` of the node the path addresses, - stepping through an array's `items` so a repeater ROW property is addressed - as `.` (`header.actions.label`) — the same path the - extractor emits. Pure; returns the input object itself when nothing applies. - `dashboardForm` enumerates the `header` composite's children - (`showTitle`, `showDescription`, `actions` with its four row properties) with - labels equal to the schema titles, pinned equal in `dashboard.test.ts`. - The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` - → "Metadata authoring forms". -- **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived - `schema` beside its `form`, through that overlay. -- **`@objectstack/platform-objects`** — the four generated `metadata-forms` - catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, - `ja-JP` and `es-ES`. - -Additive: no key removed, no accept set changed, no parsed output moved. - -`DashboardSchema.columns` deliberately still declares no `.default(12)`, and -the reason is stronger than the one #16458 assumed. The card reasoned that the -renderer already falls back to 12, which would make `.default(12)` -behaviour-preserving. Measured at objectui `origin/main` -(`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a -`columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` -yields 12 and everything else yields **4** — and the next line switches the -whole layout on that value (`hasExplicitColumns = schema.columns != null || -inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the -default would therefore both retire the inference and flip every auto-flow -dashboard into the positioned grid. A default that silently materialises a key -is expensive to take back, so the round stopped at the declared condition and -left the key alone; see #16458. diff --git a/.changeset/dashboard-stageorder-doc-names-only-funnel.md b/.changeset/dashboard-stageorder-doc-names-only-funnel.md deleted file mode 100644 index 2bffef7541..0000000000 --- a/.changeset/dashboard-stageorder-doc-names-only-funnel.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `options.stageOrder` no longer documents a chart type that cannot be built, and says plainly that only `funnel` reads it (#17344) - -`DashboardWidgetOptionsSchema.stageOrder` is an ungated member of the open widget `options` bag, so its one sentence of prose is the whole author-time surface: nothing warns, nothing refuses, and a widget carrying the key renders with the authored order simply absent. That sentence said *"Explicit category order for ordered-sequence charts — `funnel` / `pyramid` stages above all"*, and it was wrong twice over. - -- **`pyramid` is not a widget type.** It was removed from `ChartTypeSchema` as a variant that only ever rendered as `funnel`, and `chart.test.ts` pins that refusal alongside its fallback-only siblings — so the headline example in the option's own documentation could not be authored at all. -- **The plural framing promised more than the renderer delivers.** "ordered-sequence charts" and "stages above all" read as a statement about ordered marks generally. It is not one: `funnel` is the only type whose branch consults the forwarded order, measured against this repo's pinned objectui renderer. - -The corrected JSDoc and `.describe()` name `funnel` only, state outright that no other widget type reads the key, and send the other types to `sortBy` / `sortOrder`, which lower into the dataset query itself. The generated reference page (`content/docs/references/ui/dashboard.mdx`) is regenerated from the new `.describe()`. - -No schema shape changes: `stageOrder` still parses exactly as before, on every widget type. Whether the key should be *gated* to the type that honours it is ADR-0049 enforce-or-remove on an accepted key — a published-surface narrowing, and deliberately not this change; it stays open on #17344 together with the locale-dependent order/colour drop, which lives in the objectui renderer rather than here. diff --git a/.changeset/dashboard-stageorder-gated-to-funnel.md b/.changeset/dashboard-stageorder-gated-to-funnel.md deleted file mode 100644 index 72ba949edb..0000000000 --- a/.changeset/dashboard-stageorder-gated-to-funnel.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. `options.stageOrder` was an ungated member of the widget `options` bag and parsed on every widget `type`; it is now refused at parse on every type except `funnel`. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying `stageOrder` on a non-`funnel` widget now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `dashboard-widget-stage-order-non-funnel-refused`. - -## What was wrong - -The key never failed. It failed to *order*. - -`options` is the open renderer-extras bag, so nothing closed over `stageOrder`: a `horizontal-bar` widget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. - -Measured at this repo's `.objectui-sha` pin `53ded82b`: the forwarded `categoryOrder` prop has exactly **one** read in the charts plugin — `buildCategoryRank(categoryOrder)` at `AdvancedChartImpl.tsx:1514` — and it sits inside the `chartType === 'funnel'` guard opened at line 1473. The prop's only other occurrences in that file are its declaration (247) and its destructure (850). The producer has no gate either: `DatasetWidget.tsx:1468` builds the explicit order for **any** widget and forwards it whenever non-empty. - -So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there with nothing anywhere to say so. A chart rendered in an order the author did not ask for, and did not ask for it *visibly* — it just looked deliberate. That is ADR-0049's enforce-or-remove shape, and a doc sentence saying "only `funnel` reads this" is not enforcement: it is prose the author has to read first. - -## What it does now - -`DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`. - -It has to be object-level: `stageOrder` lives inside `DashboardWidgetOptionsSchema` while the `type` that decides whether it means anything is that object's **sibling one level up**, so a per-field refinement on `stageOrder` cannot see it. The check is a named function chained on with `.superRefine(…)` — the idiom this file already uses for `GlobalFilterSchema`'s date-default rule, rather than a second shape invented for one key. - -The refusal lands at `options.stageOrder` and names three things, because the defect was silence and a bare "unrecognized key" answers silence with a shrug: the key, the `type` this widget carries, and the one `type` that honours it — plus where ordering lives for everything else. - -## FROM → TO - -| you wrote | write instead | -| --- | --- | -| `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` | -| `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it | -| `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant | - -⚠️ Deleting the key changes nothing about what renders — the widget was already ignoring it. `sortBy` / `sortOrder` are what change it, and unlike a category order they lower into the dataset query as `order: { : 'asc' | 'desc' }` rather than re-sorting what it returned. - -## What the gate does NOT cover - -Stated so the change is not read as complete: - -- ⚠️ **objectui's client-side authoring door.** This refusal is the **publish** door's, not the editor's. `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check — measured here: `z.strictObject(DashboardWidgetSchema.shape)` accepts a `horizontal-bar` carrying `stageOrder` and reports zero checks, while `.extend({})` keeps the refusal. At the pinned `.objectui-sha` that package re-attaches none of this spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard editor keeps accepting the key on a `bar`. That mirror also redeclares `type` as optional with no default, so a typeless widget would reach a re-attached check as `undefined` rather than as `metric`; the exported check defaults it itself for exactly that caller, so re-attaching is sufficient. -- **A widget whose `type` is outside `ChartTypeSchema`.** zod treats that `invalid_value` as aborting and skips object-level checks for the input, so `type: 'ziggurat'` plus a `stageOrder` reports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then; the two are never seen together. Pinned. -- **A widget that declares no `type`.** `type` carries `.default('metric')` and zod applies defaults before object-level checks, so an omitted `type` is indistinguishable here from an authored `metric`. The verdict is right either way — `metric` reads the key no more than `horizontal-bar` does — and that one case carries an extra sentence pointing at the missing `type` rather than a wrong one. -- **The array's contents.** Still unconstrained `string | number | boolean` members, unmatched against the dimension's picklist. A `funnel` carrying a misspelled stage parses and renders that stage in the sentinel position; whether a stored value exists is a fact about the dataset, not about the widget. -- **Consumers that derive this schema with `.omit()` / `.pick()` / `.partial()`.** zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and `.extend()` is unaffected. - -## The siblings, measured and deliberately not touched - -`stageOrder` was the only member of that bag with this shape. `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443–455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. - -## The other arm, deliberately not taken - -The card offered either/or: gate the key, **or** teach the ordered marks (`bar` / `column` / `horizontal-bar` / `line` / `area`) to honour it. The second is a renderer change in `objectstack-ai/objectui` and not this repo's to make. The asymmetry also favours gating: a narrowing that is later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author. diff --git a/.changeset/data-migration-flag-columns-moved-at.md b/.changeset/data-migration-flag-columns-moved-at.md deleted file mode 100644 index 81d599537d..0000000000 --- a/.changeset/data-migration-flag-columns-moved-at.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": minor ---- - -`DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. - -**What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. - -**Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. - -**Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. - -This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. diff --git a/.changeset/dataset-measure-aggregate-field-type-refused.md b/.changeset/dataset-measure-aggregate-field-type-refused.md deleted file mode 100644 index 9896df7e07..0000000000 --- a/.changeset/dataset-measure-aggregate-field-type-refused.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/spec": minor ---- - -feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. A dataset -measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to -`AVG(col)` and reach the backend; it is now refused by `compileDataset` before any -query is built. Shipped as `minor` under the repo's launch-window convention for -accept-set narrowings; the hand-migration prescription is registered under protocol -major 18 as `dataset-measure-aggregate-field-type-refused`. - -The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table -`@objectstack/spec` declared in #16353 under the director ruling of decision batch -#59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and -restates none: the refusal reads the shipped predicate, so the contract has exactly -one statement. - -## What was wrong - -The answer to `AVG` over a temporal column was decided by the SQL dialect rather -than by the data. Both halves measured on this card: - -``` --- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) -select typeof(submitted_at), submitted_at from clm_contract limit 1; - text|2026-05-19T00:00:00.000Z -select avg(submitted_at) from clm_contract; - 2025.5 <- text->numeric coercion: the average YEAR - --- PostgreSQL 16.13 -select avg(submitted_at) from t; - ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 -``` - -The silent half is the dangerous one, and SQLite is the default dev datasource: -`derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned -`-0.85` and rendered on a tile labelled "average cycle time delta" — a number -indistinguishable from a correct one. Nothing refused it at any layer: not the -schema, not `os validate` / `os lint`, not the analytics service, not the renderer. - -## What it does now - -- `compileDataset` refuses an incompatible `aggregate` × `field` pair with - `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and - the accepted set (read off the table, never restated). Nothing reaches the driver. -- It reads the declared type from the `sourceFieldMeta` a host already wires, via a - new optional `DatasetCompileOptions.declaredFieldType` probe. -- **`derived` is covered by construction.** A derived measure's `of` operands are - base measures of the same dataset, so a dataset carrying a refused base measure - never finishes compiling and no `derived` op can be handed its output — including - when the selection names only the derived measure. -- Tiered "cannot answer, do not block" like every sibling probe: no - `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose - column lives on a joined object) leaves the pair unjudged. - -## ⚠️ Scope: the compile leg executes the TEMPORAL rows only - -> ⚠️ **Superseded within the same release window.** This section was accurate when it was -> written and is kept as the record of where the compile leg stopped. Two later cards -> widened it before any of the three entries shipped, so at the version that compiles this -> entry the scope below is no longer the platform's: **#16099** judged `sum` / `avg` over -> every remaining field class (including `sum` over a `percent`), and **#17560** (director -> ruling, decision batch #127, 2026-09-13) judged `min` / `max` over every class the table -> refuses. ⇒ Three sentences in this section are false at that version and are corrected -> where they stand: the string rows are **not** awaiting a table amendment, `sum` over a -> `percent` does **not** compile as it did before, and `avg` / `sum` over a temporal field -> are **not** the only pairs whose behaviour changes. Read all three entries together. - -The gate judges only a measure whose field is declared `date` / `datetime` / -`time`; a field of any other class is never handed to the predicate. The -verdict for the pairs it does judge is the table's — no row is restated — but -which FIELDS are judged is narrower than the table, on purpose: - -- **String rows** (`min` / `max` over `text`, `select`, `lookup`, - `autonumber`, …) are **not enforced here**. ⚠️ This card recorded them as - 「under #16785, **ruled C** — the table itself is to be amended to accept - them」, because `measureResultType` (#15768) already typed those results as - `'string'` and pinned them end to end, so enforcing them from here would - pre-empt that ruling. **Both halves of that sentence turned out to be - wrong.** `16785` resolves to no issue, and decision batch #127 (#17560, - 2026-09-13) found no ruling C anywhere behind the citation — the one recorded - ruling on this table, decision batch #59, refuses the string rows. ⛔ The - table is **not** amended; #17560 enforces those rows and retires the - `measureResultType` opinion that disagreed with them. -- **Boolean rows** are not a refusal at all any more: #16685 was ruled A and - #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the - table ACCEPTS them and this gate never judged them. -- The table's `sum` × `percent` row is likewise **not** executed by this leg; - `sum` over a `percent` compiles exactly as it did before. ⚠️ True of this - card only — #16099 executes that row in the same release. - -⇒ The only pairs whose behaviour changes **because of this card** are `avg` / -`sum` over a `date` / `datetime` / `time` field. ⚠️ ⛔ Not a statement about the -release: the full-table leg is #16099's and landed, and the `min` / `max` leg is -#17560's and landed, so at the shipping version every pair the table refuses is -refused at the compile door. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | -| `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | -| `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | - -⭐ A duration is not recoverable from an aggregate over instants on any backend. -Where an "average cycle time" is wanted, the cycle length has to exist as a number -before it can be averaged. - -## What is deliberately untouched - -`date` / `datetime` used as a **dimension** — grouping, bucketing, date-range -filtering — is unchanged; this is about aggregation only. `avg` over a genuine -numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` -over anything all behave exactly as before. - -⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in -`compileDataset` and reads a `declaredFieldType` probe, so it applies only where -a host wires one: `/analytics/query` — the non-dataset face, whose measures a -Cube infers rather than an author declaring them — is NOT covered, and neither -is any other `compileDataset` caller that passes no probe (those stand down -unjudged rather than guessing). Closing those is #16099's, not this card's. - -Alongside the refusal, `service-analytics`' contradictory annotations about what a -SQLite `Field.datetime` column physically holds are reconciled to one statement — -**seven** source sites plus two test narratives, not the four the card quoted. Some -said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it -IS an INTEGER epoch. Neither is current: since #3912 the column has ONE -storage form, canonical UTC text, with the epoch surviving only in a database not -yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on -`AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. -No behaviour changes from that half. diff --git a/.changeset/dataset-select-dimension-option-i18n.md b/.changeset/dataset-select-dimension-option-i18n.md deleted file mode 100644 index 8f14f660ed..0000000000 --- a/.changeset/dataset-select-dimension-option-i18n.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -**The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains -one new optional key, `translateSelectOptions`** — the surface the level is graded against, -per the same "a new key on a published exported type is the mechanical floor for clause ②" -rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key, -no wire-shape change), so `minor` rather than `major`. - -A dataset's `select`-field dimension now renders its option label in the request's locale on -a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list -grid) already renders for the identical field. - -`dimension-labels.ts` resolved a select dimension's category label straight out of field -metadata's authored `options[].label` — always the author's own-language text, since -`SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted -cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field -name never matches a key in the BASE object's own field map, so `resolveDimensionLabels` -skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on -that path, and a regression test now pins that it is never even consulted. - -`DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the -plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`) -— the SAME translator the object-metadata REST endpoint already uses — against the -deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec -key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before, -and a kernel with no i18n service configured (or nothing for the requested locale) falls back -to exactly today's authored-label text. - -A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup / -master_detail labels resolve through the separate `fetchRecordLabels` capability (a related -RECORD's display name, not a field's authored `options[]`), which this change does not touch. -It does lower the cost of adding translated lookup-record labels later, though — the i18n -service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already -wired into this package and is a `ctx.getService('i18n')` away from reuse. diff --git a/.changeset/date-range-preset-window-extent.md b/.changeset/date-range-preset-window-extent.md deleted file mode 100644 index 571b7bef76..0000000000 --- a/.changeset/date-range-preset-window-extent.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the date-range preset prescriptions now name a one-day window for the one-day presets (#17014) - -`DATE_RANGE_PRESET_MACRO_WINDOWS` maps each dashboard date-range preset to the `{date-macro}` window a refusal PRESCRIBES to an author who wrote the preset name as a bare filter comparand (`bareDateRangePresetComparandMessage`). Two of its thirteen entries prescribed a window wider than the preset they name — a filter that parses, runs and returns rows over the wrong range, with no second error to correct against. - -- **`yesterday`** was `['{yesterday}', '{today}']` — an end naming the day AFTER the window. The pair is written for `$between`, which is `$gte min` and `$lte max`, and a bare-day upper bound means "through that whole day", compiled half-open to `< nextUtcCalendarDay(max)` (ADR-0053 D-D). So the prescription resolved to `>= yesterday 00:00 AND < tomorrow 00:00`: yesterday **and** today. It is now `['{yesterday}', '{yesterday}']`. -- **`today`** was `['{today}', null]`, the open `$gte`-only arm, so the prescribed filter had no upper bound at all and also selected every day after today on a column carrying future dates. It is now `['{today}', '{today}']`. - -Both entries now name their own last day, matching the convention the other eight closed entries already used and matching both executable mappings — objectui's `PRESET_RANGES` and `@objectstack/core`'s analytics date-range resolver, which independently spell `today` and `yesterday` as one-day windows. - -The convention that decides an end token was nowhere written down, which is what let one table carry two readings. It is now stated as a rule on the table: **`start` names the window's first calendar day and `end` names its last, inclusive — never the day the window stops before**, and `end: null` is the open arm reserved for exactly the three rolling `last_N_days` windows. Tests pin the resolved extent of every window against a frozen reference day and require a stated extent for every declared preset, so a preset added later cannot silently pick the other reading. - -No schema, type or export changes: the refused shapes and the vocabulary are exactly as before, and only the window text a refusal quotes back moves. diff --git a/.changeset/declared-refusal-relay.md b/.changeset/declared-refusal-relay.md deleted file mode 100644 index c8860b58f6..0000000000 --- a/.changeset/declared-refusal-relay.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/rest': patch -'@objectstack/runtime': patch -'@objectstack/metadata-protocol': patch ---- - -A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. - -`ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. - -The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. - -**What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. - -**What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. - -**For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. diff --git a/.changeset/deriving-aggregate-nonnumeric-field-refused.md b/.changeset/deriving-aggregate-nonnumeric-field-refused.md deleted file mode 100644 index 97a13913b7..0000000000 --- a/.changeset/deriving-aggregate-nonnumeric-field-refused.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -feat(service-analytics)!: a dataset measure applying `sum` or `avg` to a field whose declared type cannot carry it is refused at compile time, for every field type and not only the temporal class (#16099) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, continuing the -one #16778 began. A dataset measure pairing `aggregate: 'sum'` with a `text` field (or -`avg` with a `select`, `json`, `lookup`, `formula`, … field) used to compile and reach -the backend; it is now refused by `compileDataset` with `DATASET_INVALID` / **400** -before any query is built. Shipped as `minor` under the repo's launch-window convention -for accept-set narrowings. - -⛔ This changeset adds no rows to any table and restates none. The verdict is -`AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in -#16353 under the director ruling of decision batch #59 ("both legs, table in spec") — -read through `isAggregateCompatibleWithFieldType`. - -## What was wrong - -#16778 landed the compile leg SCOPED to temporal source fields, leaving "every other -non-temporal pair the table refuses" as a stated residual that had never been driven. -Driven on this card, through the real service door: - -``` -sum × text the table refuses the pair the compile leg does NOT throw SQL IS emitted -sweep 6 aggregates × 49 field types = 294 pairs; 155 refused by the table; - minus 6 temporal (#16778's) minus 42 `min`/`max` × the string classes; - residual 107 — and 107 of 107 were ACCEPTED by the compile leg -control avg × datetime / date / time → DATASET_INVALID / 400, no SQL emitted -``` - -The control is what makes that a reading of the tree rather than of a blind harness: the -same service, door and `sourceFieldMeta` hook sees the pairs #16778 enforces refused. - -So `sum` over a `text` column reached whichever backend the object is bound to, and the -answer was a property of the dialect rather than of the data — the shape Prime Directive -#12 exists to remove, and the same shape #16778 closed for one field class. - -## What it does now - -- `compileDataset` judges a measure whose aggregate DERIVES a number (`sum` / `avg`) - against the table for **every** declared field type, and refuses an unaccepted pair - with `DATASET_INVALID` / **400** — naming the measure, the field, its declared type - and the accepted set read off the table. Nothing reaches the driver. -- `sum` × `percent` is refused at last: the row `analytics-service.ts` has called - "incoherent" in a comment since before the table existed. `avg` × `percent` is still - ACCEPTED by the same table, which is what makes it a row and not a class. -- The refusal's closing prescription is now chosen by the source field's class: the - temporal sentence #16778 measured is kept verbatim for temporal fields, and a - non-numeric field is pointed at `count` / `count_distinct`, which accept every type - because they read no arithmetic off a value. -- Unchanged: `derived` is covered by construction (a dataset carrying a refused base - measure never finishes compiling), and the three "cannot answer, do not block" tiers — - no `sourceFieldMeta`, an unresolvable field, a `relationship.field` path. - -## ⚠️ Scope: the DERIVING aggregates — and see #17560, which closed the other half - -> ⚠️ **Superseded within the same release window.** This section was accurate when it was -> written and is kept as the record of why this change stopped where it did. #17560 -> (director ruling, decision batch #127, 2026-09-13) then judged `min` / `max` too, so at -> the version that ships this entry **every** pair the table refuses is refused at the -> compile door. Read that entry beside this one. - -`min` / `max` SELECT one of the stored values; `sum` / `avg` DERIVE a number. This is the -line this package already draws — `measureResultType` branches on exactly that pair of -aggregates — and the defect is about a derived number, so the deriving aggregates are its -population. - -The `min` / `max` rows stayed with the table-amendment card (then **#17513**, since closed -as a duplicate of **#17560**, which ruled and landed them), and that is measured rather -than assumed. -Enforcing the residual whole was tried on this card: with `min` / `max` × the string -classes subtracted, **15** cases in `measure-result-type.test.ts` still went red, every -one of them on `min` × `json` — a pair the table refuses, in no ruling's scope, driven -end to end by the same shared fixture as the string rows. One dataset compiles every -measure in that fixture, so one refused pair reds the whole section. ⇒ `min` / `max` is -one question, and it is the table-amendment card's. - -## Upgrading — FROM → TO - -Nothing an author writes is removed or renamed: `DatasetMeasure.aggregate` and -`DatasetMeasure.field` keep their spellings and their types. What narrows is which PAIRS of -values are accepted. The one-line fix, per shape: - -| FROM (compiled before, refused now) | TO | -|---|---| -| `{ aggregate: 'sum', field: }` | `{ aggregate: 'count_distinct', field: }` — counting reads no arithmetic off the value | -| `{ aggregate: 'sum' | 'avg', field: }` | store the quantity you meant as its own numeric field and aggregate that | -| `{ aggregate: 'sum', field: }` | aggregate the formula's numeric INPUT column; a `formula` is virtual in SQL storage, so no arithmetic aggregate can be lowered to it | -| `{ aggregate: 'sum', field: }` | `{ aggregate: 'avg', field: }` — a rate averages, it does not add | -| `{ aggregate: 'sum' | 'avg', field: }` | unchanged from #16778: use `min` / `max` for a real instant, or store a duration as a number and aggregate that | - -`min` / `max` are **not** affected by this change at all, over any field type. - -No shipped dataset in this repository declares a newly-refused pair — every one of the -eleven shipped dataset measures resolves to `number`, `currency`, `summary` or `progress`. -The refusal names the accepted set for the aggregate, read off the table. diff --git a/.changeset/discovery-services-route-follows-mount.md b/.changeset/discovery-services-route-follows-mount.md deleted file mode 100644 index 90c2d96de6..0000000000 --- a/.changeset/discovery-services-route-follows-mount.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `/discovery` no longer contradicts itself — `services.*.route` follows the mounted paths, like `routes.*` already did (#16674) - -The `/discovery` document states each service's address twice: once in `routes.X` (the flat convenience map) and once in `services.Y.route` (the per-slot entry). The REST discovery handler rewrote only the first half to the paths this server actually mounts, so any deployment that moved a prefix received a document that disagreed with itself — and the `services` half pointed at a path with nothing mounted on it. - -Measured on a boot with `crud: { dataPrefix: '/objects' }`, reading `GET /api/v1/discovery`: - -- before — `routes.data` = `/api/v1/objects` (the mounted path), `services.data.route` = `/api/v1/data` (unmounted) -- after — both answer `/api/v1/objects` - -The same split opened on four keys at once for an `apiPath` deployment: `data`, `metadata`, `ui` and `auth`. All four now follow the mount. `services.*.route` is written as a projection of the finished `routes` map, so the two halves cannot state different answers whatever a future substitution does to `routes`. - -**A default deployment's document does not move by a byte.** With `crud.dataPrefix` at its `/data` default and `metadata.prefix` at `/meta`, the values the correction writes are the values that were already there; only a deployment that had moved a prefix sees a change, and there the old value addressed nothing. Route-less slots (`cache`, `queue`, `job`, and an in-process `realtime` bus) never gain a route, and no advertisement is withdrawn. - -If you have been reading `services.data.route` on a moved-prefix deployment and compensating for it — by re-deriving the path from `routes.data`, or by hard-coding the prefix — that workaround can go: the field now answers the mounted path directly. diff --git a/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md b/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md deleted file mode 100644 index 704dca4544..0000000000 --- a/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the driver-config registry refuses an off-vocabulary id instead of answering with a truthy non-schema - -`DRIVER_CONFIG_JSON_SCHEMAS`, `DRIVER_ID_ALIASES` and `DATABASE_DRIVER_ALIASES` -are plain object literals, so all three inherit `Object.prototype`, and every -lookup into them was a bare index. Measured against the built artifact -(`dist/data/index.mjs`) on the repo's Node 22 baseline (v22.22.2), an id that -names an inherited member resolved that member and was handed onward as if it -were a driver: - -| call | before | after | -|:--|:--|:--| -| `getDriverConfigJsonSchemaById('memory')` | the JSON Schema | the JSON Schema — unmoved | -| `getDriverConfigJsonSchemaById('constructor')` | `{}` — an EMPTY JSON Schema that accepts every config | `TypeError` naming the id and the legal vocabulary | -| `getDriverConfigJsonSchemaById('toString')` | `'[object Object]'` — a **string**, where the signature promises an object | `TypeError` | -| `getDriverConfigJsonSchemaById('valueOf')` | the registry object itself | `TypeError` | -| `getDriverConfigJsonSchemaById('__proto__')` | `TypeError: … is not a function` | `TypeError`, now naming the id | -| `getDriverConfigJsonSchemaById('nope')` | `TypeError: … is not a function` | `TypeError`, now naming the id | -| `resolveDriverId('constructor')` | the `Object` **function** — truthy, not a driver id | `undefined` | -| `resolveDriverId('__proto__')` | `Object.prototype` — a truthy object | `undefined` | -| `resolveDatabaseDriverId('constructor')` | the `Object` **function** | `undefined` | -| `driverHasLocalDefault('constructor')` | `undefined`, out of a function declared `boolean` | `true`, as its doc promises for an unknown id | -| `resolveDriverId('pg')` / `resolveDriverId(' PostgreSQL ')` | `'postgres'` | `'postgres'` — unmoved | - -`getDriverConfigJsonSchemaById` handing back `{}` is the worst of these: an -empty JSON Schema validates anything, so a Studio connection form or a -`DriverDefinitionSchema.configSchema` consumer that asked "what shape must this -config have" was told "any shape at all" and reported success. - -The resolvers' half is reachable without a plain-JS consumer. The CLI refuses an -unclaimed operator selection with `if (driverType && !kind)` after calling -`resolveDatabaseDriverId`, so `OS_DATABASE_DRIVER=constructor` produced a truthy -`kind` that is not a driver id and walked past the refusal. - -All three lookups now go through an `Object.prototype.hasOwnProperty.call` check. -This narrows and widens nothing: every legal spelling is an own key of its table, -so no value accepted before is refused now, and only answers that were never -inside the declared return types move. The declared signatures are unchanged — -`getDriverConfigJsonSchemaById` stays `(id: BuiltinDriverId) => Record` -and both resolvers stay `(driver: unknown) => BuiltinDriverId | undefined`. - -A null-prototype table was the other available shape and was measured rather than -assumed: a `__proto__: null` object literal does not type-check against the -`Readonly>` annotation at all (TS2353), and the -`Object.assign(Object.create(null), …)` spelling that does compile silently costs -that annotation — a table missing a driver stopped failing to compile (TS2741). diff --git a/.changeset/driver-sql-aggregate-declared-type.md b/.changeset/driver-sql-aggregate-declared-type.md deleted file mode 100644 index bd8537ca01..0000000000 --- a/.changeset/driver-sql-aggregate-declared-type.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -feat(driver-sql): `aggregate()` publishes its declared return type — the contract's own, not `any` (#17277) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for this class of change on `@objectstack/driver-memory`, and PRs #15280 and #15267 followed on this very class). `SqlDriver.aggregate()` carried an EXPLICIT `Promise` over a door `IDataDriver` had already declared narrower: `aggregate?(object, query, options?): Promise[]>`. An explicit `any` satisfies that structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that an aggregate row is whatever they like. - -The door is now declared as the contract declares it. A caller that read a cell straight off an aggregate row through the `any` now types what it reads — an aggregate cell arrives as `unknown` — and a caller that indexed the result array, or took `.find()` on it, now narrows the absent arm first. No runtime behaviour changes. - -`aggregate()` is OPTIONAL on the contract (`aggregate?`) where the five doors #15267 moved are required. That governs whether the member EXISTS, not what it returns once it does: a consumer that has already guarded `typeof driver.aggregate === 'function'` — the engine's own dispatch — holds a function whose published return was `any` and is now the contract's record array. The narrowing reaches it either way. - -`@objectstack/driver-sqlite-wasm` does not override this door and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides it and carries its own entry. - - diff --git a/.changeset/driver-sql-doors-declared-types.md b/.changeset/driver-sql-doors-declared-types.md deleted file mode 100644 index 58454d1397..0000000000 --- a/.changeset/driver-sql-doors-declared-types.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for the same class of change on `@objectstack/driver-memory`, and PR #15280 followed for `update()` on this very class). `SqlDriver` carried an EXPLICIT `Promise` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record | null` — it has always answered `results[0] || null`), `create()` (`Record`), `bulkCreate()` (`Record[]`), `execute()` (`unknown`) and `explain()` (`unknown`). An explicit `any` satisfies all five structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that `findOne()` never returns `null` and that `create()` returns whatever they like. #15280 un-masked `update()` and filed the census of what was left; this is that remainder. - -Each door is now declared as the contract declares it. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members off `create()` / `bulkCreate()`, or to dereference a raw `execute()` / `explain()` result, now types what it reads. No runtime behaviour changes. - -`@objectstack/driver-sqlite-wasm` overrides none of these five and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides four of the five and carries its own entry. - -Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations. - - diff --git a/.changeset/driver-turso-aggregate-declared-type.md b/.changeset/driver-turso-aggregate-declared-type.md deleted file mode 100644 index d54cbaa394..0000000000 --- a/.changeset/driver-turso-aggregate-declared-type.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -feat(driver-turso): the `aggregate()` override publishes its declared return type, not `any` (#17277) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit this door from `SqlDriver` — it OVERRIDES `aggregate()`, and the override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared the door as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()` and PR #15267 for four more doors. - -Both branches already answered the contract's type: the remote branch passes `RemoteTransport.aggregate()`, already declared `Promise[]>`, and the local branch forwards to `SqlDriver.aggregate()`, narrowed alongside (#17277). The override now declares what it has always answered. A caller that read a cell straight off an aggregate row through the `any` now types what it reads. No runtime behaviour changes. - -Out of scope and deliberately unmoved: `upsert()` and `beginTransaction()` keep their annotations. - - diff --git a/.changeset/driver-turso-doors-declared-types.md b/.changeset/driver-turso-doors-declared-types.md deleted file mode 100644 index 921554eee0..0000000000 --- a/.changeset/driver-turso-doors-declared-types.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit these doors from `SqlDriver` — it OVERRIDES `findOne()`, `create()`, `bulkCreate()` and `execute()`, and each override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared four of the five doors as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()`. - -Both branches of every one of the four already answered the contract's type: the local branch forwards to `SqlDriver`'s door (narrowed alongside, #15267) and the remote branch passes `RemoteTransport`'s result — already declared `Record | null`, `Record`, `Record[]` and `unknown` respectively — through the generic `formatRemoteRow` / `formatRemoteRows`. Each override now declares what it has always answered. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first. No runtime behaviour changes. - -`explain()` is not overridden here and reaches these consumers through `@objectstack/driver-sql`. Out of scope and deliberately unmoved: `upsert()`, `aggregate()` and `beginTransaction()` keep their annotations. - - diff --git a/.changeset/driver-turso-remote-declared-indexes.md b/.changeset/driver-turso-remote-declared-indexes.md deleted file mode 100644 index e4fb50a88b..0000000000 --- a/.changeset/driver-turso-remote-declared-indexes.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/driver-turso': patch ---- - -fix(driver-turso): remote mode materializes every declared object-level index, not only field-level `unique` (#17609) - -## What was wrong - -In remote mode (`libsql://` / `https://`), `TursoDriver` provisions tables through `RemoteTransport`, and the only index DDL that path could emit came from field-level `unique`. An object's declared `indexes: [...]` — unique or not — had no consumer there, so no remote database ever carried one. The local face (`SqlDriver`) created all of them, so nothing failed and no local test noticed: on a remote tenant database `sys_notification_delivery` (five declared indexes) and `sys_job_queue` (three) held only their primary-key autoindex, and the delivery claim query answered every poll with a full table scan (`SCAN sys_notification_delivery` + `USE TEMP B-TREE FOR ORDER BY`). - -## What changes - -- Remote mode now creates **every** declared index: field-level `unique` plus the object's own `indexes`, unique and non-unique, including `unique: 'organization'` with its NULL-safe `COALESCE(, '__global__')` key part. Names and keys come from the same shared normalizers `SqlDriver` and the drift differ use (`uniqueIndexesFromFields`, `normalizeDeclaredIndex`, `buildIndexName`), so both faces land the same index set — pinned by a new local/remote parity suite that compares `sqlite_master` on both. -- New tables get their indexes in the same batch as `CREATE TABLE`. -- **Existing tables are retrofitted on the next schema sync** with `CREATE [UNIQUE] INDEX IF NOT EXISTS`. No row is read-modified or rewritten. -- An index the retrofit cannot create is reported once at `error`, naming the index, the table and the database's own cause. A declared `unique` index over rows that already violate it is **not** forced and no data is repaired: de-duplicate the key's values and re-run schema sync. -- Steady-state cost goes down: a sync now reads the existing index names once (one statement, folded into the column-probe batch it already sends) and issues no index DDL when every declared index exists. Before, every boot re-sent one `CREATE UNIQUE INDEX IF NOT EXISTS` per field-level unique index on an existing table. - -## Upgrading - -Nothing to change in metadata or configuration. The first kernel build after upgrading creates the missing indexes on each existing remote database — on a large table that one build pays the index build time. Watch the boot log for `could not create the declared` lines at `error`: each names an index that is still absent and why. diff --git a/.changeset/email-template-locale-floor.md b/.changeset/email-template-locale-floor.md deleted file mode 100644 index 13881152ee..0000000000 --- a/.changeset/email-template-locale-floor.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Email templates: say where the `en-US` fallback floor is, and report a bundle that has none. - -`IEmailService.sendTemplate` matches `(name, locale)` exactly and retries exactly one rung — -the literal `en-US`. There is no language-subtag folding, so a bundle whose English row is -tagged `en` is unreachable from `en-US` and from every other tag it does not itself carry; -each such delivery raises `TEMPLATE_NOT_FOUND`, which classifies permanent, so it dead-letters -with no retry. An app declaring `i18n.defaultLocale: 'en'` and authoring `locale: 'en'` has -done the consistent thing throughout and still shipped a bundle with no floor — and it -validated, built and installed clean. - -- `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the - single literal `en-US` rung, the absence of folding, and that the stack's own declared default - locale is the wrong tag whenever it is not spelled `en-US`. -- New exported `EMAIL_TEMPLATE_FLOOR_LOCALE` names that tag once: it is both the schema default - and the resolver's sole retry rung. -- `defineStack` now reports (advisory `console.warn`, warn-once per bundle) an `emailTemplates` - bundle that carries rows for the stack's own `i18n.supportedLocales` but none tagged `en-US`. - -Advisory only — no accept set moves. The stack still parses and is returned unchanged; the -resolver's ladder is unchanged. diff --git a/.changeset/engine-text-operator-declared-type-door.md b/.changeset/engine-text-operator-declared-type-door.md deleted file mode 100644 index f5962643db..0000000000 --- a/.changeset/engine-text-operator-declared-type-door.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/spec": patch ---- - -feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) - - - -**BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). - -## What was wrong - -Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: - -| filter | before | after | -|:--|:--|:--| -| `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | -| `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | - -What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. - -## What it does now - -- **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. -- **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. -- **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. -- **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. -- **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. - -## Deliberately unjudged - -- **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. -- **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. -- **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. -- **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. - -## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` - -`@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. - -It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: - -| stored surface | how it reaches the door | -|:--|:--| -| `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | -| `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | -| `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | -| dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | - -**Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. - -No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | -| `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | -| `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | -| `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | -| `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | diff --git a/.changeset/engine-verb-result-declarations.md b/.changeset/engine-verb-result-declarations.md deleted file mode 100644 index 300876eb9b..0000000000 --- a/.changeset/engine-verb-result-declarations.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/metadata": minor -"@objectstack/metadata-protocol": minor -"@objectstack/plugin-auth": minor ---- - -feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) - - - -**BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: - -- `findOne` → `Promise | null>` -- `update` → `Promise | number | null>` -- `delete` → `Promise` - -`any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. - -**Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). - -The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. - -**What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. - -**Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. - -**What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: - -- `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. -- `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. -- `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. - -The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. diff --git a/.changeset/engine-write-failure-log-level-warn.md b/.changeset/engine-write-failure-log-level-warn.md deleted file mode 100644 index 0e7f502f7d..0000000000 --- a/.changeset/engine-write-failure-log-level-warn.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a refused write reports at `warn`, not `error` — the caller was already told (#17052) - -`insert`, `update` and `delete` each end their `catch` with `throw e`, then -logged the failure at ERROR one statement earlier. AGENTS.md → *Degradation log -levels* names that exact shape and forbids it: "a failure handed to the CALLER -is not a degradation at all … Do not bolt a `logger.error` onto such a site." - -**This moves published behaviour**, which is why it is a changeset rather than a -`skip-changeset`: the level is what an operator greps, and at least one consumer -reads it structurally. `scripts/publish-smoke.sh` fails a boot on any -error-level line (`SMOKE_ERROR_LOG_PATTERN`), and that is how the defect was -found — `@better-auth/oauth-provider` seeds `sys_oauth_resource` in `insertOnly` -mode and documents its `identifier` UNIQUE constraint AS its race-safety -mechanism, catching the collision and continuing at `debug`. Our line was -emitted before that catch ever ran, so a healthy first boot of every fresh -`create-objectstack` project printed `ERROR Insert operation failed` and red-lit -`publish-smoke / packed-tarballs` for six consecutive runs on a candidate whose -auth and CRUD probes were all green. - -**Nothing else about the entry moved.** Same message, same `object` meta, same -redaction (#8682: the bound statement and its values stay cut from `message` -and `stack`), same subject (#14095: the entry carries the driver's own error — -a `DuplicateRecordError`'s `cause` — never the envelope, so the failing column, -MySQL's index name and the driver's frames survive). The `Logger` contract gives -an `Error` slot to `error`/`fatal` only, so the engine now builds the -`{ error: { message, stack } }` bag that slot used to build; handing the Error -to `warn` as meta would have serialised `{}`, because those two fields are -non-enumerable. The rendered line is byte-identical apart from the level word, -and that equivalence is pinned rather than asserted. - -If you grep your logs for these three messages, keep the message and drop the -level from the pattern. If you alert on error-level lines from `@objectstack/objectql`, -a refused write no longer raises one — the write's exception still does. diff --git a/.changeset/error-code-ledger-boot-refusal-prose.md b/.changeset/error-code-ledger-boot-refusal-prose.md deleted file mode 100644 index 5c9d05e3fa..0000000000 --- a/.changeset/error-code-ledger-boot-refusal-prose.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The error-code ledger's TSDoc stops naming a retired verdict as a live mechanism, and states the published-face rule it is actually held to. - -`packages/spec` ships `src/**/*.zod.ts`, so `api/error-code-ledger.zod.ts`'s header is published prose — a consumer reads these sentences out of the tarball. Two of them stopped being true when `check-dispatcher-error-vocabulary`'s face refusal widened from `packages/spec/src/**` to every published package's `src/` and the dispatcher vocabulary's `boot-refusal` verdict retired with it (#16649). - -The first said the `boot-refusal` verdict **records** reachability for codes not yet registered, and pointed at the module the verdict was being deleted from. That is a claim about where a live mechanism lives, not about a case that can no longer arise, so a reader following the pointer would have found nothing. It now records the retirement and names what replaced it: a `door: 'none'` code has no resting place short of a row in the ledger. - -The second opened `packages/spec/src/** is held to this mechanically`. True before the widening and an understatement after it — a reader would conclude only the spec tree is guarded, which is the "guarded a part" / "guarded it" confusion this whole class of gate exists to remove. It now states the published face, the stricter spec sub-face where `pending-registration` has no allowance, and the named, dated allowance outside it owed to #8846, with both finding kinds named. - -No schema, accept set, default or refusal moves. `ERROR_CODE_LEDGER` holds the same members before and after, and the generated reference page is regenerated from this prose rather than hand-edited. diff --git a/.changeset/example-caption-fence-assertion.md b/.changeset/example-caption-fence-assertion.md deleted file mode 100644 index c9daf17ef5..0000000000 --- a/.changeset/example-caption-fence-assertion.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The reference-docs renderer now refuses an `@example CAPTION` with no code block beneath it, -instead of publishing an orphaned caption. - -`@example CAPTION` is declared to be *the caption of the fence beneath it*, and the renderer -acts on that reading: it promotes the tag into a bold lead-in on the assumption that a fence -follows. Nothing asserted that one did. When a module header captioned a listing and wrote its -rows as bare prose, the promotion still fired and the rows below collapsed into a single run-on -paragraph — consecutive non-blank lines are one markdown paragraph, and the docs site loads no -`remark-breaks`. Two customer-facing reference pages shipped that way. - -The assumption is now a precondition the generator checks before it emits anything. A module -description whose caption has no block under it fails the docs build with a message naming the -caption and the source-side fix, the way the renderer already refuses a heading it cannot -renumber. Deliberately a refusal in the generator rather than a separate gate: it makes the -wrong page impossible instead of detecting it afterwards, and it is scoped to the population -the renderer actually renders — module doc blocks — rather than to every `@example` line in the -package. - -⛔ The check never asks whether a run of prose is "really" a table. Shape-sniffing is exactly -what this renderer refuses to do, and what an author writes instead of a fence is not knowable -from the text. It asks only the question the contract already states: is there a block beneath -the caption? An author who wants those words as ordinary prose writes them without the tag. - -Both code kinds satisfy it. An indented block reaches the page as a fence — the render loop -re-emits it as one — so a caption above one captions a fence by the time a reader sees it. All -twelve captions in the corpus are fenced today and are unaffected; no schema behavior changes. diff --git a/.changeset/field-notnull-prescribes-storage-not-required.md b/.changeset/field-notnull-prescribes-storage-not-required.md deleted file mode 100644 index 30bd42c35e..0000000000 --- a/.changeset/field-notnull-prescribes-storage-not-required.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): `FieldSchema` no longer prescribes `required` for `notNull` / `not_null` — the flattened column-constraint spellings now name `storage: { notNull: true }` (#16867) - -Writing `notNull: true` (or `not_null: true`) on a field was refused — correctly — and then told to write `required` instead, via a rename row in `FieldSchema`'s alias table. `required` is the one key ADR-0113 exists to say is **not** the column constraint. `required`'s own description in the same file states the opposite of what the rename prescribed: *"NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`)"*. - -The failure mode was not the refusal — that fired, loudly, and did its job. It was the **remedy**: an author reaching for a NOT NULL column complied, wrote `required: true`, and received a nullable column plus a write-time gate, with nothing downstream to refuse it. The refusal read as though it had been satisfied. - -All three flattened spellings — `notNull`, `not_null`, and `storageNotNull`, which already carried the correct sentence — now get one prescription naming the real key: - -> physical column constraints live under `storage` — write `storage: { notNull: true }` (ADR-0113). There is no flat spelling of it: post-17 a column is NOT NULL because its author wrote that nested key, and for no other reason. It is NOT `required`, which is the WRITE contract (an insert must provide a value; an update may not null it out) and deliberately does NOT imply the column constraint — `required: true` alone leaves the column nullable. Write whichever of the two you meant, or both. - -Both halves are named on purpose: the defect being repaired is that the author cannot tell which of the two axes they are getting, so a prescription naming only the column half would have fixed the measured direction and opened the mirror-image one. - -**No accepted key moves.** `notNull` and `not_null` were refused before this change and are refused after it — a `guidance` / `guidanceSets` table decorates a rejection and never admits a key. Only the sentence attached to the refusal changed. `storage: { notNull: true }` parsed before and parses now; `isRequired` and `mandatory` are genuine spellings of the write contract, ADR-0113 moved neither, and both still rename onto `required`. - -One mechanical note for anyone repairing a table like this: the entry moved from `aliases` to `guidanceSets`, not to exact `guidance`. `aliases` is indexed by `aliasProbe` (case- and separator-folded, so one row covered `not_null` too) while exact `guidance` is matched case-sensitively on the authored spelling — a lone `guidance.notNull` row would have quietly dropped `not_null` onto the edit-distance fallback. The two spellings are pinned separately for exactly that reason. diff --git a/.changeset/field-type-refused-at-registration-door.md b/.changeset/field-type-refused-at-registration-door.md deleted file mode 100644 index 805fbb0093..0000000000 --- a/.changeset/field-type-refused-at-registration-door.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/objectql": minor -"@objectstack/metadata-protocol": minor -"@objectstack/driver-sql": minor -"@objectstack/cli": minor ---- - -fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) - - - -**BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. - -**What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. - -## What was wrong - -One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: - -| declaration | driver | `os generate migration --format sql` | `--format ts` | -|:---|:---|:---|:---| -| `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | -| `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | - -`SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. - -## What it does now - -- **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. -- **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. -- **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. - -## Scope, stated rather than left to be inferred - -`SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. - -ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. diff --git a/.changeset/file-family-bare-id-column.md b/.changeset/file-family-bare-id-column.md deleted file mode 100644 index 538b39f1fb..0000000000 --- a/.changeset/file-family-bare-id-column.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -feat(driver-sql)!: the file family's physical column holds the bare `sys_file` id, per deployment (#15989) - - - -**BREAKING** on the published storage behaviour of `@objectstack/driver-sql`, under the maintainer ruling on #15041 (decision batch #49 item 1), verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」. The physical column for the file family — `file` / `image` / `avatar` / `video` / `audio` — holds the **actual `sys_file` id**, a bare id string in a string column, rather than a JSON-quoted id in a JSON column. The SQL generator already emitted `VARCHAR(2048)` for the family and does not move; the driver is the side that moves. - -Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. - -**The switch is per DEPLOYMENT, and its default is today's encoding.** The ADR-0104 addendum forbids keying it on the `adr-0104-file-references` flag alone: every creation-attested store since 17.0 and every deployment that ran `os migrate files-to-references --apply` before a column step existed holds that flag *and* JSON-quoted ids in a JSON column. The evidence is `sys_migration.columns_moved_at`, which reaches this driver as the new published option `SqlDriverConfig.fileColumnsMoved` — a boolean or an async resolver, resolved once at `initObjects` and memoized. **Every way of not knowing answers "not moved"**: the option omitted, a resolver that throws, a resolver that never runs, a host that never calls `initObjects`. Absence is the JSON arm because every flag row that exists in the world today lacks the field, and a driver that guessed the other way would write bare ids into a JSON column. - -**What an UNMOVED deployment gets** — which is every deployment until something supplies that option — is today's driver, with exactly one answer changed: - -- the column is still `json` / `jsonb` / SQLite `TEXT`, the write still JSON-encodes, and `isJsonField` still answers `true` for the family; -- a media cell whose bytes are a JSON-quoted id **sitting in a character column** now reads back as the id instead of as the id with its quotes. That population is not hypothetical: a database built by `os generate migration --format sql` has a `VARCHAR(2048)` media column, and MEASURED on live PostgreSQL 16.13, the driver wrote `"file_01HXYZ"` into it and handed it back verbatim — every consumer that matches the raw stored form (file resolution, ownership claims) refused it. SQLite never had this defect: its read arm parses the cell and keeps the raw string when the parse fails, which is why the gap was a server-dialect one. - -**What a MOVED deployment gets**: the family leaves `JSON_COLUMN_TYPES`, so `isJsonField` / `formatInput` / `formatOutput` stop treating a single-value media field as JSON; `createColumn` builds `varchar(2048)` — the generator's own width, mirrored by `varcharColumnChars` so the drift detector reads the column the emitter actually builds; and the id on disk is the id. Throughout the window the read path accepts **both** encodings on every dialect, so a cell a column step has not converted still reads correctly. The decode is deliberately narrow — it engages only on a leading `"`, `{` or `[`, none of which can begin a `sys_file` id, a resolver URL or a `data:` URI — because an all-digit id would otherwise parse to a number. - -`multiple: true` media is unaffected on both arms: its value is a list of ids, it is a JSON column on every deployment, and `createColumn` decides `multiple` above its type switch. - -**The drift detector moves with the writer.** `JSON_COLUMN_FIELD_TYPES` no longer names the family, because the family is no longer a constant on either side; `diffManagedTable` takes a `fileColumnsMoved` input instead, and OMITTING it reproduces this module's previous verdicts exactly — an unthreaded caller keeps reporting a `varchar` media column as the corruption it still is on an unmoved deployment. Without this, a deployment that moved its columns would be told by its own tooling to convert them back to `json`, i.e. to undo the ruling. - -**Not shipped here, and named rather than implied:** the column step itself. `os migrate files-to-references --apply` does not yet retype or rewrite media columns, and nothing in this diff moves any deployment's storage. A deployment moves only when it runs that step and its host supplies the arm, and the two must be one act — MEASURED on SQLite: after the columns are converted, a driver still on the JSON arm reads the migrated column correctly but its next write re-quotes. diff --git a/.changeset/filter-operator-schema-projection.md b/.changeset/filter-operator-schema-projection.md deleted file mode 100644 index 015ca262e1..0000000000 --- a/.changeset/filter-operator-schema-projection.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): project a union branch-by-branch, so five filter operators reach a published reference page - -`z.toJSONSchema()` refuses a whole schema the moment ONE node in it has no JSON -form, and `build-schemas.ts` applied that refusal per SCHEMA. `orderingComparandSchema` -is `z.union([z.number(), z.date(), z.string(), FieldReferenceSchema])`, so four -`data/filter.zod.ts` exports emitted nothing at all — and `$gt`, `$gte`, `$lt`, -`$lte` and `$between` reached no reference row. Not a blank Description cell: no -section. The ~2000 characters of `.describe()` on those slots — the #5685 comparand -contract, the #6571 endpoint contract, and the `{ "$gte": "2026-01-01" }` shape the -platform's own date-macro resolver produces — reached no reader. - -The generator now makes a third attempt when both strict directions refuse: it -projects with Zod's `unrepresentable: 'any'`, marks every node that came back with -no structural keyword, and DROPS the marked ones that are direct members of an -`anyOf` / `oneOf`. That is not a narrowing. These artifacts describe JSON -documents, a JSON document cannot carry a `Date` INSTANCE, so the set of JSON -documents that union accepts is unchanged by the drop. - -⛔ A marked node anywhere else — an object property, a record value, an array item -— refuses the projection and the export is skipped with the message Zod threw, so -this cannot change WHY anything is skipped. Five exports leave -`unemitted-schemas.baseline.json` (23 → 18): the four filter exports, plus -`data/Hook`, whose only unprojectable member was the deprecated inline-function -handler branch — that puts 22 `data/Hook:` authorable keys under the key ratchet -for the first time. - -Published artifacts gain `json-schema/data/{ComparisonOperator,FieldOperators, -NormalizedFilter,RangeOperator,Hook}.json`, each carrying an -`x-unprojectable-branches` record naming exactly which branch the projection -dropped and where. diff --git a/.changeset/filter-orthography-binding-and-object-blocks.md b/.changeset/filter-orthography-binding-and-object-blocks.md deleted file mode 100644 index a9f2068f7a..0000000000 --- a/.changeset/filter-orthography-binding-and-object-blocks.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A) - - - -**BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter` -(the `dataSource` binding every data-bound page component carries) and -`ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | -'object-calendar'].filter` — shipped as `minor` under the repo's launch-window -convention for breaking changes; the migration prescription is registered under -protocol major 18 as ONE entry for the family. - -One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, -verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55, -verbatim 「同意」, option A: converge family-wide). Until this release the -binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it -refused the array the consumer's own pins author at that key, and -`element:record_picker` carried two orthographies at two keys resolved through -one `??` in the renderer — while the four `object-*` doors declared `z.unknown()` -and took the record, the ObjectQL AST tuple array and the rule array alike, -silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every -other `filter` door in the map already carried; the `FilterConditionSchema` -import that existed in `page.zod.ts` for this one site leaves with it. - -Sequenced measurement-first, as the family had to be: at the objectui pin -`a472b07` the `object-metric` aggregate path posted an array `where` that -`POST /analytics/query` refused (400 on every array form, #15828), so the -converge was parked behind the pin bump #16626. At the pin this repo builds -against (`53ded82b`, objectui#7754) the adapter lowers an authored array through -`translateFilterArray` and the spec's own `parseFilterAST` sink before the -wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` / -`ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams` -lowers it; the binding's composition seam AND-combines it with the named view's -rules through `mergeFilterNodes`. Nothing on those paths parses the value -against the installed spec. - -**Migration** (`element-data-source-and-object-block-filter-rule-array` — -listed by `os migrate meta --from 17` once the protocol major is 18): a -record-form `filter: { status: 'active' }` becomes -`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an -operator object `{ status: { $ne: 'done' } }` becomes -`[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys -become several rules (they AND); an AST tuple array -`[['owner_id', '=', '{current_user_id}']]` becomes -`[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` — -placeholders and date macros are unchanged. The record form is refused at -`filter` (`invalid_type`, expected array); the tuple array is refused at -`filter.0` (expected object). The dashboard widget `filter` -(`dashboard.zod.ts`) is a different family and is unchanged by this release -(#15829); `object-grid.defaultFilters` is a different key, not named by the -ruling, and is unchanged. - -In-repo authors migrated in the same change: four spec test fixtures at the -binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint -fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`, -`ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122: -`operator` normalizes on parse, so input ≠ infer at these five schemas now). diff --git a/.changeset/flow-edge-condition-evaluated-slot.md b/.changeset/flow-edge-condition-evaluated-slot.md deleted file mode 100644 index 37edaaed6f..0000000000 --- a/.changeset/flow-edge-condition-evaluated-slot.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FlowEdgeSchema.condition` is an evaluated slot — it composes the new `EvaluatedExpressionInputSchema`, and `structuralConditionRefusal` no longer admits an `ast`-only envelope (#15807) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): the -edge condition of a flow — `FlowEdgeSchema.condition`, the branch predicate -`AutomationEngine.evaluateCondition` runs at every traversal — now refuses at -authoring an envelope the engine cannot evaluate, where it used to parse, -register, pass `objectstack validate`, and then answer a **silent `false`**: a -branch that quietly never fired. - -Two spellings of one seam, refused by ONE rule with one sentence -(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`, the rule #15430 introduced for the -`assignment` value envelope): - -```yaml -edges: - - { id: e1, source: check, target: approve, condition: { dialect: cel, ast: { kind: const, value: true } } } # `ast` only — the engine never reads it - - { id: e2, source: check, target: reject, condition: { dialect: cel, source: ' ' } } # blank after trimming - - { id: e3, source: check, target: escalate, condition: ' ' } # the shorthand for the same blank source -``` - -> An expression in an evaluated slot needs a non-blank `source`: the expression -> engine evaluates `source` (the canonical persisted form) and -> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` -> that is blank after trimming, would validate and register and then fault at -> run time. Write `{ dialect: 'cel', source: '…' }`. - -- **New export `EvaluatedExpressionInputSchema`** (type `EvaluatedExpressionInput`), - the sibling of `ExpressionInputSchema` for an evaluated slot: the bare-string - shorthand still normalizes to `{ dialect: 'cel', source }`, but the string - must be non-blank after trimming, and the envelope arm composes - `EvaluatedExpressionSchema` (`source` required and non-blank) instead of - `ExpressionSchema`. `FlowEdgeSchema.condition` is the first slot to compose - it. An `ast`-only envelope and a blank bare string surface as one - `invalid_union` issue at the slot carrying the sentence above; a blank - `source` inside an envelope surfaces as one `custom` issue at `source`. -- **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain - the persistence contract (`source` OR `ast`), where `ast` is accepted as an - optional opaque structured value and carries no promise of becoming required. - If AST-only evaluation is ever chartered, `EvaluatedExpressionSchema` is the - one place to relax, and every evaluated slot follows. -- **`structuralConditionRefusal` no longer admits an `ast`-only envelope** on - either structural condition slot (`config.condition` on a node, - `edge.condition`). #15662's refusal admitted it on purpose through a - `rec.ast !== undefined` clause, because the spec still admitted the shape at - `edge.condition` and refusing it from the consumer side would have decided - #15430's question there; with the edge schema closed, that admission kept the - refusal deliberately holed for a shape the engine cannot run on either slot. - `STRUCTURAL_CONDITION_SHAPE_REFUSAL` now reads "an expression envelope - carrying a string `source`" and says why. Consequence on `config.condition` - (a start node's trigger gate, a decision node's predicate — an open record - with no schema in front of it): an `ast`-only envelope there is refused at - `registerFlow`, reported as a located `error` by `objectstack validate`, and - refused by `evaluateCondition` with the same sentence, instead of answering a - silent `false`. An `ast` BESIDE a string `source` is still admitted - everywhere. The whitespace-only STRING ruling on `config.condition` (#15662: - consistent `false` on both sides) is untouched. -- **Three doors agree, through the spec.** `registerFlow` refuses the flow at - `FlowSchema.parse` (edge) or at its structural pass (`config.condition`); - `objectstack validate` refuses it at its `ObjectStackDefinitionSchema` parse - (edge) or reports the structural refusal (`config.condition`); - `evaluateCondition` refuses the shape a stored flow or a direct caller hands - it. None of them grew a rule of its own. - -**What an author does with a refused edge condition.** An edge condition that -carried only `ast` has no evaluable form: author its `source`. A -whitespace-only condition — envelope or bare string — was never a predicate -(the engine answered `false`, so that edge never fired): remove the -`condition` key if the edge was meant to be unconditional, or write the -expression if it was meant to branch. Every edge condition with a -non-blank `source` is unchanged, and nothing is renamed, retired or rewritten — -the refusal itself carries the prescription. - -**A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole -flow, not just the edge.** The paragraph above is the author's remedy, at -`objectstack validate` / `POST /flows`; a stored row has no author in front of -it. Stored flows are deliberately NOT canonicalized by -`applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same -skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`) — flow-node -conversions need the automation engine's live executor registry, so flows -canonicalize at `registerFlow` instead, which parses through -`canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in -`service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one -`warn` naming the flow, and continues. So an edge that used to answer a silent -`false` while the rest of the flow ran now takes the flow down with it: it is -never registered, its trigger is never armed, and the only announcement is that -one warn line — `[Automation] failed to register flow` at boot, -`[Automation] cold-boot flow bind: failed to register flow` at the kernel:ready -bind, `[Automation] flow re-sync: failed to register flow` on a re-sync. That -warn line is also the locator: its `issues[].path` names the offending edge — -`edges[N].condition` — beside the sentence above, so nothing has to be exported -to find it. Author the `source` — or remove the key, if the edge was meant to -be unconditional — and republish. A stack authored in config files has a second -door, `objectstack validate`, which locates the same edge at -`flows.N.edges.N.condition`. Registered as the ADR-0087 D3 semantic entry -`flow-edge-condition-evaluated-slot-source-required`, which carries the same -judgment for a consumer replaying the chain. - -Not touched here: `start.config.condition` has no Zod schema to narrow (the -start node's `config` is an open record); its producer-side gate is the -structural refusal above, which this change tightens but does not type. diff --git a/.changeset/fold-admission-tenancy-posture-classification.md b/.changeset/fold-admission-tenancy-posture-classification.md deleted file mode 100644 index 8a3dbb01c4..0000000000 --- a/.changeset/fold-admission-tenancy-posture-classification.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/rest': patch -'@objectstack/cloud-connection': patch -'@objectstack/plugin-sharing': patch -'@objectstack/service-datasource': patch -'@objectstack/service-settings': patch -'@objectstack/service-storage': patch ---- - -refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) - -Six admission doors each hand-wrote the same try/catch on the `tenancy` read that -feeds `resolveAuthzContext`: the registry's branded "never registered" rejection -(`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the -supported no-tenancy composition, where no posture-conditional refusal runs at -all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` -(ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization -INPUT and admission was therefore never DECIDED. That is #13906 decision 1 -option A, and it is the part nobody may get wrong: a quiet `catch` at any one of -the six re-opens the defect, where a failure reads as "this check does not apply" -and an ex-member's org-stamped API key is admitted. - -Nothing is broken today — every copy was correct — so this removes a standing -hazard rather than fixing a defect. **No admission verdict changes**, on any -wiring: the classification is byte-for-byte the decision the six copies made, -now made once. - -- **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the - `TenancyServiceResolver` type), exported from the package index beside - `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. - The thunk is not a style choice: the REJECTION is what gets classified, so the - resolution has to happen inside the helper's `try` — a caller that awaited the - service first would need a `catch` of its own, which is the thing being - deleted. -- **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on - kernel-vs-provider, and asking twice would let a provider bound to the local - kernel answer for a request that resolved to another environment; four seams - read `ctx.getKernel()`; `service-storage` reads an already-normalised gate - registry; and each seam's reason why a MISSING async accessor must stay quiet - is its own argument (the storage door's is its declared degrade-to-ungated - contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper - that also owned how the service is reached would be wrong for one of them or - grow a flag per seam — the copies again, with an extra step. Every one of - those reasons stays written at its seam. -- **Folded**: `packages/rest/src/rest-server.ts` (both wirings), - `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, - `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, - `packages/services/service-datasource/src/admin-routes.ts`, - `packages/services/service-settings/src/settings-service-plugin.ts`, - `packages/services/service-storage/src/storage-service-plugin.ts`. -- **Pinned where the decision now lives**: - `packages/core/src/security/admission-tenancy-posture.test.ts` drives both - rejections at the production seam — a real `ObjectKernel` that never - registered `tenancy`, and one whose `tenancy` factory throws — each beside the - brand predicate's own answer on that same rejection, so "the outage throws" is - distinguishable from a helper that throws at everything. It also holds the - constraint mechanically: the helper's source may not name an accessor, a - kernel or a plugin context, and it takes exactly one parameter. diff --git a/.changeset/generate-migration-emits-declared-unique-index.md b/.changeset/generate-migration-emits-declared-unique-index.md deleted file mode 100644 index 07676f0bb3..0000000000 --- a/.changeset/generate-migration-emits-declared-unique-index.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os generate migration` emits the field-level unique index the driver creates (#16317) - -## What was wrong - -Both migration formats emitted the table and none of the object's declared -uniqueness. Measured on live PostgreSQL 16.13 — one object driven through all -three producers into three schemas, `pg_indexes` read back per schema: - -```ts -{ name: 'probe', fields: { keyed_unique: { type: 'text', unique: true, maxLength: 100 } } } -``` - -| producer | before | after | -|:--|:--|:--| -| `driver-sql` via `initObjects` | `probe_pkey`, `uniq_probe_keyed_unique` | unchanged | -| `--format sql` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | -| `--format ts` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | - -Two rows with the same `keyed_unique` value were refused by the platform's table -(`23505 ... violates unique constraint "uniq_probe_keyed_unique"`) and accepted -by both generated ones, with nothing reporting it: a scaffold that creates the -table for an object silently dropped a uniqueness guarantee the object declares. -After the change the duplicate is refused by all three, each naming the same -constraint. - -The key set was not missing — it was already computed here to size the keyed -text family's columns; only the index it implies was never emitted. - -## What it does now - -- **`--format sql`** emits an inline `CONSTRAINT "" UNIQUE ()`. - That is what knex's `table.unique(columns, { indexName })` — the driver's own - call — compiles to on PostgreSQL, so a generated table and a platform-created - one agree in `pg_constraint` as well as in `pg_indexes`; and it stays inside - the statement's `IF NOT EXISTS`, which a following `ALTER TABLE ... ADD - CONSTRAINT` has no spelling for. -- **`--format ts`** emits that knex call itself, `indexName` included — which is - what makes the driver recognise the constraint as already present on its first - boot against a generated table, instead of adding a second one under its own - name and then reporting the generated one as an orphan to drop. -- Names come from a transcription of `driver-sql`'s `buildIndexName`, pinned - against the driver's own export (a CLI production module may not statically - value-import a driver package). - -## What it deliberately still does not emit — and now says so - -Both formats print a `NOT EMITTED:` line naming the index, its key parts and the -reason, instead of dropping it silently: - -- the **organization-scoped composite** (`unique: true` / `'organization'` on an - object with an organization column), whose key part is - `COALESCE(, '__global__')`. Emitting the bare composite - instead would be worse than emitting nothing: under SQL's NULL-distinct - `UNIQUE` it constrains no row that has no organization, which on a - single-tenant deployment is every row. -- an index over a column no field materialises (a virtual `formula` field) — - the same skip the driver performs, where the driver logs a warning. - -Object-level `indexes[]` remains unemitted by both formats; it is normalized by -a different driver-side rule and is not covered by this change. diff --git a/.changeset/generate-name-charset-gate.md b/.changeset/generate-name-charset-gate.md deleted file mode 100644 index 025e61854b..0000000000 --- a/.changeset/generate-name-charset-gate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli)!: `os generate` refuses a metadata name outside the charset `packages/spec` declares for an object `name`, before it derives anything from it (#16726) - -Maintainer ruling, decision batch #82 (2026-09-08), option A — **a gate, not a sanitiser**. `os generate ` used to accept any name at all; since #16724 it has refused names whose emitted TypeScript does not parse. It now also refuses, ahead of that check and ahead of every derivation, any name the object-`name` declaration in `@objectstack/spec` rejects. The refusal names the value and quotes the schema's own rule, and writes nothing. - -⛔ Nothing is rewritten. The rejected alternative was to derive a legal identifier the way `os create` does, which decouples the name the author wrote from the name that gets emitted with nothing announcing it — the failure mode that multiplies silently when metadata is written in bulk. So the name you author and the name that lands in the file are always the same string. - -**What this narrows:** kebab-case (`order-line`), uppercase (`Order`), dotted (`foo.bar`) and digit-initial (`2fast`) names were accepted before and are refused now — `order-line` used to generate `order_line.object.ts` binding `orderLine`. Write the snake_case name directly (`os g object order_line`). ⛔ No new charset was minted and no flag bypasses the gate; #16724's parse check is unchanged and stays as the backstop behind it (`class` passes the charset and is still refused for `object`, because `const class:` is not a declaration). - - diff --git a/.changeset/generator-declared-column-default.md b/.changeset/generator-declared-column-default.md deleted file mode 100644 index 831508a53d..0000000000 --- a/.changeset/generator-declared-column-default.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): a generated migration carries the column DEFAULT `driver-sql` puts on the same field (#16294) - -## What was wrong - -Neither `os generate migration` format read a field's `defaultValue`, so a table -created from a generated migration had no column DEFAULT where the platform's -own table has one. A row inserted out of band — by a database client, a seed -script, anything that does not go through the engine — got NULL where the -declared value belonged. - -Driven on live PostgreSQL 16.13: one object, three schemas, one producer each -(`driver-sql` through `initObjects`, `--format sql` through `db.raw`, -`--format ts` by importing the emitted module and calling `up(db)`), with -`information_schema.columns` read back per schema. - -``` -field driver sqlgen verdict -f_default null=YES default='hello'::text null=YES default=- DIVERGED -f_default_required null=YES default='hello'::text null=YES default=- DIVERGED -``` - -After: `diverged: 0 of 6` on the card's probe, and 22 of 23 on a wider one -covering every `defaultValue` shape. - -## What changed - -Both formats now render one shared verdict, taken from -`SqlDriver.applyDeclaredColumnDefault` — the single place a `defaultValue` -becomes DDL on the platform side: - -- a **literal** is emitted, quoted the way knex binds it (`DEFAULT '42'`, not - `DEFAULT 42` — PostgreSQL keeps those two textually apart forever in - `column_default`, and the driver's column carries the quoted form); -- **`'NOW()'`** becomes the driver's own translation, which is type-branched: - `CURRENT_TIMESTAMP` on a timestamp column, and a UTC-pinned expression on - `date` / `time`, because a bare `CURRENT_TIMESTAMP` resolves those in the - server's timezone; -- **any other runtime token** (`current_user`), an **Expression envelope** and - an **option-level `default: true`** emit nothing, each because the driver - emits nothing — the engine owns those, and a column DEFAULT would override a - decision it makes deliberately; -- a **`multiple: true`** field gets neither, because `createColumn` returns - before both questions. - -No authorable key, export or accepted-input set changes: `defaultValue` was -already declared, already parsed and already honoured by the driver. The -generators simply now read it. diff --git a/.changeset/great-pugs-attack.md b/.changeset/great-pugs-attack.md deleted file mode 100644 index 477e065152..0000000000 --- a/.changeset/great-pugs-attack.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -Re-run the seed-ownership claim when the seed settles, and report whether each pass was final. - -`claimSeedOwnership` was reached exactly once per database lifetime, on the pass that promotes the first platform admin, while the platform's own seeder was still writing in the background — an app bundle that overruns `OS_INLINE_SEED_BUDGET_MS` (default 8 s) continues past kernel start rather than block it. Registry order and seed order are unrelated, so every object whose rows landed after that walk stayed `owner_id IS NULL` permanently: nothing re-ran the claim. Ownerless rows are invisible to every `readScope: 'own'` grant, and under `public_read` they read fine and answer 403 on every write at `modifyAllRecords: false` — a granted permission that can never be exercised. - -The claim now also runs on `app:seeded`, the published settle signal for that background continuation, against the same admin and with the same predicates — so it moves ownership for exactly the rows the promotion-time pass missed, and never for a row a human already owns. - -Two additive keys support it, both optional: `bootstrapPlatformAdmin` reports `adminUserId` on the promotion path and on the `already_have_admin` short-circuit (so the re-run reads the one existing holder scan instead of a second copy of it), and both `bootstrapPlatformAdmin` and `claimSeedOwnership` accept a `seedSettlement` snapshot read through the `seed-settlement` contract. No existing key, argument or return shape changed. - -Every claim pass now logs one line whether or not it claimed anything, and says whether its reading was final: a pass taken while a seed source is still writing is reported at `warn` as PROVISIONAL. Previously a pass that matched nothing logged nothing at all, so a boot that permanently orphaned rows and a boot with nothing to do produced identical evidence. diff --git a/.changeset/grouping-field-non-padded.md b/.changeset/grouping-field-non-padded.md deleted file mode 100644 index e23b459755..0000000000 --- a/.changeset/grouping-field-non-padded.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: `grouping.fields[].field` refuses a padded field name instead of handing three renderers a lookup that always misses (#17360, ruling C on objectui#7347) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata; it is now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying a padded grouping name now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-grouping-field-padded-refused`. - -## What was wrong - -The padded name never failed anywhere. It failed to *group*. - -Measured on objectui (M1–M11, with live controls): the projection harvester `collectGroupingFieldRefs` **trims** the name when it builds `$select`, while **three** renderers bucket rows by the **raw** name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. So the server answers under `business_unit`, every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into one `(empty)` group (grid, gallery) or one `Uncategorized` lane (kanban) holding every record. - -That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant `(empty)` group has no way to tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. - -## What it does now - -`grouping.fields[].field` carries a **non-padded** pattern — no leading and no trailing whitespace. The refusal lands at `grouping.fields[N].field` (the offending element's own key, not the view or the array) and names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, together with the trimmed name to write instead. - -⛔ **Not a `.trim()`.** A trimming schema makes `' a '` and `'a'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. objectui's harvester trim stays as defence-in-depth; nothing is removed there. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `grouping: { fields: [{ field: ' business_unit ' }] }` | `grouping: { fields: [{ field: 'business_unit' }] }` | -| `grouping: { fields: [{ field: 'status\n' }] }` | `grouping: { fields: [{ field: 'status' }] }` | - -The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a view has been silently showing one `(empty)` group, re-authoring the name is also the fix for that. - -## Scope — what is deliberately NOT narrowed - -- **The blank name is unchanged.** It is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank` (`400`, path `['grouping','fields',N,'field']`). This narrowing exists for the **silent** case; the empty string still parses here exactly as before. -- **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and this key deliberately does not take it: a grouping level is authored as a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The ruling asked for a non-padded pattern and this is exactly that — nothing wider, nothing narrower. -- **The sibling `groupByField` axis** (kanban / gantt / timeline) is symmetric and is **not** touched by this change. - -## Who is affected, measured - -Every `grouping.fields[].field` spelling in this repo parses unchanged: 50 literal occurrences under a `grouping:` key across 19 files, harvested with the TypeScript parser and cross-checked against a deliberately over-approximating second pass over 906 shape-exact `{ field, order?, collapsed? }` literals in `packages/**`. The single harvested spelling this refuses is `' '` in `view-grouping-query.test.ts` — a **negative** fixture handed straight to `compileListViewGroupQuery` with no parse on its path, pinning that same `grouping_field_blank` refusal. Nothing in the tree reddens. - -Outside the repo, only metadata that was already grouping wrongly is affected: a padded name has never produced a correct grouped view on any renderer. - -## Consumer - -**objectui#7347 unblocks on the INSTALLABLE RELEASE of this package, not on merge.** Its side of the work — a pin bump plus a regression test that a padded name is refused before it reaches any renderer — needs a published `@objectstack/spec` to depend on, so it stays `pm:blocked` until this ships in a release a consumer can install. The gallery and kanban sites are covered by this one producer fix and get no cards of their own. diff --git a/.changeset/hono-adapter-declared-envelope-render.md b/.changeset/hono-adapter-declared-envelope-render.md deleted file mode 100644 index f6927fc8a5..0000000000 --- a/.changeset/hono-adapter-declared-envelope-render.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/plugin-hono-server': minor ---- - -fix(plugin-hono-server): an escaped throw that declares an ADR-0112 envelope is answered as that envelope, not as a bare `500 INTERNAL_ERROR "No response from handler"` (#16545) - -`HonoHttpServer.wrap()` is the seam **every direct-mount route passes** — `get` / -`post` / `put` / `delete` / `patch` each register `this.wrap(handler)`, and -`IHttpServer` is how `service-datasource`, `packages/rest` and the dispatcher -bridge all mount. Until now a throw that escaped a route handler was answered -there as `500 { code: 'INTERNAL_ERROR', message: 'No response from handler' }`, -with the thrown value discarded — so a producer that had *declared* its refusal -lost both halves of the declaration on the way to the caller. - -The measured case: `service-datasource`'s `requireDatasourceAdmin` re-raises -`AuthzStoreUnavailableError` (declared `status: 503`, declared `code: -SERVICE_UNAVAILABLE`) when the authorization store cannot be read — deliberately, -per the #13279 ruling that an unreadable store licenses no verdict. The operator's -outage reached the caller as a generic fault naming the wrong component: the -declared code never arrived, and the message said "No response from handler". - -**What changed.** An escaped throw carrying **both** a declared ADR-0112 status -(a key of `HttpStatusErrorCodeMap`) **and** a code registered in `ErrorCode` -(`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) is now rendered as that envelope, -with the producer's `details` and `userMessage` channels forwarded. The status -and code are read through `resolveThrownHttpError` — the one rule the REST -registrar and the dispatcher already share — so this seam agrees with the other -doors by construction rather than by a second ladder. - -**What did NOT change**, pinned in the same PR: - -- an escaped throw that is **not** such an envelope answers exactly the bytes it - answered before — 500, no cause in the body. A partial declaration (status but - no code, code but no status), an unregistered code, and a status ADR-0112 does - not declare all take that arm; -- a handler that simply wrote nothing is untouched; -- a handler that **wrote and then threw** keeps what it wrote; -- the `notFound` fallback seam still answers `Fallback handler failed` — a - fallback that threw is a broken consumer, not a refusal it declared; -- ⛔ no error code is minted and no ledger row is added. A code on this path that - is not registered is a ledger gap under the #16404 ruling, and takes the - unchanged 500 arm rather than being registered in passing. - -The 5xx disclosure filter every door emitting a thrown message already runs -(`looksLikeInternalErrorLeak`, #3867 / #8086) is applied here from this seam's -first day: a driver dump on a declared 5xx is withheld, where the old bare 500 -disclosed nothing at all. The escaped-throw diagnosis (#5848) still fires exactly -once at `error`, and now names the answer that was really sent instead of -claiming an opaque 500. - -⚠️ **Known-unreached door, stated rather than left silent.** A route mounted -through `getRawApp()` funnels through neither `wrap()` nor any registrar wrapper, -so it is **not** repaired by this change and still answers a non-envelope -`text/plain` 500. That is out of this card's scope by the `domain:cli` seat's -ruling and is filed separately. diff --git a/.changeset/hook-input-is-the-persist-image.md b/.changeset/hook-input-is-the-persist-image.md deleted file mode 100644 index 52bcdb6727..0000000000 --- a/.changeset/hook-input-is-the-persist-image.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/plugin-auth": patch ---- - -fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) - - - -**BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. - -## The defect - -On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. - -Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: - -``` -read back: target_value 400 weight 10 ← the strip worked - score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" -``` - -The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. - -## What changed - -**`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. - -**The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. - -Two things deliberately did **not** move: - -- **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. -- **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. - -`@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. - -Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. - -## Who is affected - -A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: - -- **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. -- **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. -- **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. - -⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. - -A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. - -⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. - -An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. diff --git a/.changeset/hook-previous-row-invariant-rewrite.md b/.changeset/hook-previous-row-invariant-rewrite.md deleted file mode 100644 index 8b354b95ab..0000000000 --- a/.changeset/hook-previous-row-invariant-rewrite.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `HookContext` admits a row-invariant-in-effect rewrite by per-row `previous` on a predicate write, kept safe by the engine's key-divergence refusal (#16074) - -The `hook.zod.ts` contract said that on a predicate (`multi: true`) write the per-row `previous` is supplied *so a guard can REFUSE (throw), not so a rewrite can be aimed*. Three shipped `beforeUpdate` provenance stamps (`sys_email_template`, `sys_sharing_rule`, `sys_webhook`) read `ctx.previous` per row and write `customized: true` conditioned on it — inside the letter of what the engine allows, outside the stated purpose of the input they use. Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06), option 1: **the contract admits the shape.** - -The amended D3 clause (`HookContextSchema.input` TSDoc, mirrored in `bulk-write-hook-conformance.ts`) now states: - -- Per-row `previous` is supplied so a guard can REFUSE, **and** so a `before*` hook can make a **row-invariant-in-effect rewrite** — one whose written KEY SET is the same on every matched row **and is assigned in place** (`ctx.input.data.customized = true`, not a wholesale replacement of `ctx.input.data`). -- What makes that shape safe is the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned **in place**, and if any two rows disagree the whole batch is refused **before any write**. In place is the condition the refusal rests on: a hook that REPLACES `ctx.input.data` leaves the dispatch unable to attribute keys, so the comparison is skipped and the batch is not judged at all. -- What an operator sees when it fires: an ADR-0112 envelope with `status: 400`, `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, `keys` (the sorted keys some rows' hooks wrote and others did not, e.g. `['customized']`), `rows` (how many rows the predicate matched), `object`, and a message that says "Nothing was written" before naming the remedy. A bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that is the engine working, not the hooks misbehaving, and the remedy is the caller's — write those rows by id, or from inside the handler through `ctx.api`. -- Three shapes the rule does **not** admit: a rewrite whose written key set differs across rows (that is the refusal itself); the same key written with a per-row VALUE — the engine judges key sets, never values, so that shape clears the check and applies the last dispatch's value to every row; and a row-conditioned REPLACEMENT of `ctx.input.data`, which silences the recording above so that shape is judged by nothing at all. All three stay out of contract. - -Purely additive at the contract: no schema key, type or accept set of `HookContextSchema` itself changes, and the engine's behaviour is unchanged — the three stamps become conforming by amendment, and the rule for the next hook author is written down where the contract lives. Option 2 (change the hooks to stop aiming by `previous`) was not adopted: #15302 measured that declining on a predicate write leaves unstamped exactly the rows the next boot overwrites, turning a visible 400 into silent loss of an admin edit. diff --git a/.changeset/hook-register-undispatched-lifecycle-event-refused.md b/.changeset/hook-register-undispatched-lifecycle-event-refused.md deleted file mode 100644 index 2db4150a33..0000000000 --- a/.changeset/hook-register-undispatched-lifecycle-event-refused.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/spec': minor ---- - -**BREAKING** `engine.registerHook` refuses an engine lifecycle event the engine never dispatches (#17713) - -`registerHook(event, handler)` took `event: string`. For a name outside the dispatched set it logged a warning and then **registered the handler anyway**, so the declaration succeeded and the handler never ran — ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam. - -The measured cost is a data-visibility one. A consumer registered **read filters** on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals. They sat inert through every boot behind ~40 warning lines: `findOne` was still filtered (`beforeFind` covers it, so the mistake gave no signal), `count` was not — a `limit`ed list answered a `total` counting rows the caller could not see — and `aggregate` was not either, so a `groupBy` was not narrowed at all. - -Six event names now throw at registration instead of registering inert. They are the engine's own lifecycle namespace — `before`/`after` × `OperationContext['operation']` — minus the eight the engine dispatches, derived in code rather than typed out. - -FROM → TO: - -| was | now | fix | -| --- | --- | --- | -| `registerHook('beforeFindOne', h)` | throws | register on `'beforeFind'` — it already fires for `findOne` | -| `registerHook('afterFindOne', h)` | throws | register on `'afterFind'` — same reason | -| `registerHook('beforeCount', h)` | throws | `count()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'count'` | -| `registerHook('afterCount', h)` | throws | same as `beforeCount` | -| `registerHook('beforeAggregate', h)` | throws | `aggregate()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'aggregate'` | -| `registerHook('afterAggregate', h)` | throws | same as `beforeAggregate` | - -One-line fix for a read filter that was on `beforeCount` or `beforeAggregate`: move it into `engine.registerMiddleware(async (ctx, next) => { if (ctx.operation === 'count' || ctx.operation === 'aggregate') ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, scope] } : scope; await next(); })` — the same seam RLS and sharing already use, so the predicate reaches the driver call. - -What is **not** affected: an event name outside the engine's lifecycle namespace (`'myPlugin:flush'`) still warns and still registers, so a plugin that dispatches its own events through `triggerHooks` keeps working. Metadata-authored hooks were never exposed — `HookSchema.events` is `z.array(HookEvent)` and `HookEvent` enumerates exactly the eight dispatched names, so the gap only ever existed on the code door. - - diff --git a/.changeset/hook-withheld-readonly-key-diagnostic.md b/.changeset/hook-withheld-readonly-key-diagnostic.md deleted file mode 100644 index bd3a4ae012..0000000000 --- a/.changeset/hook-withheld-readonly-key-diagnostic.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a hook that faults reaching through a withheld read-only key now names the key, says the platform withheld it, and points at `ctx.previous` (#17219) - -Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook body that reaches **through** such a key — `ctx.input.locked_meta.who = 'hook'`, where `locked_meta` is a caller-supplied read-only `json` column — therefore dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write. - -**The refusal is correct and is unchanged.** What it replaced is a write that succeeded while persisting a value derived from the caller's forgery, and #16344 exists to close exactly that route. What this fixes is the diagnostic. Measured before this change, at both doors: - -``` -direct SandboxError: hook 'guard_task_body' threw: - TypeError: cannot set property 'who' of undefined -REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} -``` - -The REST reading is the one that matters, and it is the worse of the two: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so an author was told nothing at all — not which key, not that the platform had taken it away, not what to read instead. - -### Who is affected - -Anyone whose `beforeUpdate` hook reads a read-only field that the caller may also send. The write was already being refused; only the message changes. A hook that needs the stored value reads it from **`ctx.previous.`** — the same remedy PR #17195's changeset documents. - -### What the message says now - -``` -A `beforeUpdate` hook faulted while `locked_meta` was withheld from it. That field is -`readonly: true`, and the engine withholds a caller-supplied value for a read-only field -from `beforeUpdate` hooks, so `ctx.input.locked_meta` reads `undefined` — withheld by the -platform, not missing by accident. Read the stored value from `ctx.previous.locked_meta` -instead. Original fault: TypeError: cannot set property 'who' of undefined -``` - -The error declares **HTTP 400**, which is what carries it past the script-fault sanitiser onto the same "message verbatim" channel a body's own authored refusal already rides; REST callers who previously saw `500 INTERNAL_ERROR` for this case now see 400 with the text above. The original fault is carried inside the message rather than replaced. - -### Deliberate limits - -No new error code is registered and no key is added to any published payload — a dedicated `ERROR_CODE_LEDGER` entry for this refusal is a separate decision. The explanation claims only what is knowable at the seam: *faulted while these keys were withheld*, never a proven cause. An **authored** refusal (`throw new Error('…')`) is never rewritten, and a crash on an operation where nothing was withheld passes through untouched. diff --git a/.changeset/hook-write-set-finding-path-lowered-handler.md b/.changeset/hook-write-set-finding-path-lowered-handler.md deleted file mode 100644 index 6b483879bb..0000000000 --- a/.changeset/hook-write-set-finding-path-lowered-handler.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/lint": patch -"@objectstack/cli": patch ---- - -fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) - -`hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` -(`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / -`hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` -(`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, -because that is the shape they parse. For a hook authored as an inline -`handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), -`hooks[i].body` is not something the author wrote at all — `lowerCallables` -mints it from the handler before `os build` / `os lint` hand the stack to -these rules (#16095). The reported `path` therefore named a key that does not -exist in the author's own source file; grepping for `body.source` there finds -nothing. - -**What changed.** `lowerCallables` now records, per `lowerCallables()` call, -which `hooks[*].handler` ref strings got their `body` minted this way (as -opposed to a `body` the author wrote directly). The CLI's four lowering doors -(`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) -pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two -hook write-set rules use it to redirect a finding on a lowered hook to -`path: hooks[i].handler` — the key that replaced the function the author -wrote — with a message suffix ("judged on the metadata body lowered from the -inline handler") explaining why. A hook whose `body` the author wrote directly -is unaffected: `path` stays `hooks[i].body.source`, unchanged. - -**No verdict changed.** Which hooks are flagged, at what severity, and why is -untouched — #13653 and #4271 are unmoved by a word. Only the location a -finding points at, and the wording explaining it, are different. `os build` -and `os lint` continue to report the identical `path` and message for the -same hook (#16095's "one implementation, both commands agree" — now including -this). - -No `--json` field was added or removed: `path` and `message` keep their -existing shape (string), and this is a within-type value correction for the -one subclass whose old value could never be resolved against the author's -source in the first place. diff --git a/.changeset/host-importer-location-install-diagnostic.md b/.changeset/host-importer-location-install-diagnostic.md deleted file mode 100644 index e5c358a01d..0000000000 --- a/.changeset/host-importer-location-install-diagnostic.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/types": patch ---- - -`createHostImporter` stops prescribing an install repair for a `link:` / `file:` install that is already correct. The refusal is unchanged; only its wording is. - -A host app declaring `{"foo": "link:../bar"}` links `node_modules/foo` to a directory whose manifest may be named anything. `link:`, `file:` and git or tarball URLs name a LOCATION or a remote artefact, never a package, so the specifier carries no name for the ESM-only fallback finder to expect and the KEY stays the expectation — kept deliberately, because widening it would accept any directory sitting at the key and trade a wrong REMEDY for a wrong LOAD. When the linked manifest names something else the finder therefore refuses, and it was reporting that refusal with the `declared-unresolvable` INSTALL wording: run `pnpm install`, check a production prune did not drop it, check the dist was built. Driven on a real symlinked install, all three are measurably false — the finder had just read the manifest at `node_modules/foo`, so the package is on disk, was not pruned, and its `import` target exists. The operator reinstalls, nothing changes, and they go looking for a build that is not broken. - -That sub-case now states what was actually measured: the directory it consulted, the name the manifest there carries, the name it expected, and why a location specifier leaves it with only the key. It says outright that this is neither an install nor a declaration problem, and closes with the remedy that does work — make the two names agree, by declaring the linked package under its own name or by renaming the linked manifest to the key. Both ends are pinned as loading. - -Unchanged: the refusal itself, its `declared-unresolvable` kind, its `MODULE_NOT_FOUND` code and every consumer branch that reads them; the finder's accept set, which is byte-for-byte what it was — a `link:` install whose manifest matches the key still loads silently, and a plain range or an `npm:` alias whose directory holds a different package still gets the INSTALL wording, because there the install really is the fault. The second verification axis that would make these installs LOAD (comparing `realpath(node_modules/)` against the declared location) is deliberately not built here. diff --git a/.changeset/host-resolution-control-fixture-name.md b/.changeset/host-resolution-control-fixture-name.md deleted file mode 100644 index 29e8e6ef94..0000000000 --- a/.changeset/host-resolution-control-fixture-name.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -verify: let `bootStack` be told which package `multiTenant: true` resolves, so the -`declared-unresolvable` control can name a subject the workspace can never supply - -`BootOptions` gains an optional `organizationsPackage`. It defaults to -`@objectstack/organizations` and production callers never pass it — the -operator-facing error still names that package literally, because in every -production boot it is the subject. Only the specifier moves. - -Why it exists: a fixture whose whole content is "this host root DECLARED the -package and does not have it" cannot state the second half with a name the -workspace owns. Since ADR-0132 the multi-org runtime is a tracked workspace -package, pnpm's hoisted store carries it, and a `pnpm exec`-launched runner -exports a `NODE_PATH` that reaches that store — so such a fixture resolved the -package out of the ambient workspace the moment it had been built, and its -verdict became a function of an unrelated package's build state rather than of -its own directory. The harness's own host-resolution control now hands in a -`@fixture/*` name and proves the absence instead of assuming it, the repair -already landed for `packages/qa/dogfood` and `packages/types`. diff --git a/.changeset/i18n-check-platform-bucket-and-app-gating.md b/.changeset/i18n-check-platform-bucket-and-app-gating.md deleted file mode 100644 index bd1f096b9c..0000000000 --- a/.changeset/i18n-check-platform-bucket-and-app-gating.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os i18n check` counts the coverage an app actually owns, so `--strict` / `--threshold` can gate an app package (#16681) - -## What was wrong - -`collectExpectedEntries` walks the Studio metadata-form registries -unconditionally — identically for every config, an empty one included — so -every stack's expected set carries ~773 `metadataForms.*` keys that -`@objectstack/platform-objects` translates and the runtime already serves. - -Two of the three commands that see that family already knew it is not the -author's. `os lint` hides it and says so ("platform built-ins: 773 i18n -issue(s) hidden — rerun with `--include-platform`"); `os i18n extract` has -`--no-metadata-forms`. `os i18n check` is the one command that publishes a -**percentage**, and it carried the baseline in its denominator: - -``` -Coverage by locale - en ████████████████████████ 100.0% (1265/1265, missing 0) - zh-CN █████████░░░░░░░░░░░░░░░ 38.9% (492/1265, missing 773) -``` - -That is an application with every key it owns translated. `--strict` and -`--threshold` — the two flags whose entire purpose is CI gating — therefore -could not gate an app package at all, and the only way to move the number was -to ship a copy of the platform's bundle, which would *override* the platform's -own and go stale at the next upgrade. The workaround was worse than the defect. - -## What it does now - -**Ownership is observed, not assumed.** The baseline counts toward coverage -when the stack under examination ships those translations itself, and does not -when it does not — read from the config's own `translations` bundles, requiring -a non-empty string leaf so an `--fill=empty` scaffold is not mistaken for a -claim of ownership. An app gets a number about its own surface with no flag; -`platform-objects`, which does ship the family, stays gated on it with no flag -either. An unconditional exclusion would have turned the app side green by -deleting the platform's own gate, and is what the negative-control tests forbid. - -**The flag is `os lint`'s, spelling and all.** `--include-platform` forces the -baseline in; `--no-include-platform` forces it out, for a package that ships a -partial baseline and does not intend to own the rest. Absent, the decision is -the observed one — three states, not two. - -**Both output faces carry the decision.** `--json` gains -`platformMetadataForms: { mode, excludedKeys }`, and the console prints -`platform built-ins: N key(s) not counted — rerun with --include-platform to -gate them here` under the coverage table, rendered from those same two numbers. - -`os lint` is unchanged. The shared `computeI18nCoverage` seam still counts the -baseline by default, because lint folds it away one seam later and counts what -it folded for its own hint line. - -## Compatibility - -Additive on the command surface; an invocation that was refused is now -accepted, and no flag is removed or renamed. The behaviour that changes is the -**default coverage number for a stack that ships no `metadataForms` bundle** — -it stops reporting a debt that stack must not pay. A run that wants the old -numbers back asks for them with `--include-platform`, on the same argv. diff --git a/.changeset/i18n-inline-locale-map-population-count.md b/.changeset/i18n-inline-locale-map-population-count.md deleted file mode 100644 index a625886cf4..0000000000 --- a/.changeset/i18n-inline-locale-map-population-count.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`i18n.zod.ts` stops asserting a stale size for the inline-locale-map population. - -Two docblocks in this file each stated that the repo authors 31 inline locale maps — the -`INLINE_LOCALE_KEY` rationale ("Every inline map authored in this repo (31 of them, across -three platform pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no real -authoring surface") and the `I18nLabelSchema` form-2 note ("Three published platform pages -author 31 of these"). The measured population is 45: 33 in `sys-user.page.ts`, 6 in -`sys-organization.page.ts`, 6 in `sys-position.page.ts`. - -The number is **dropped** at both sites rather than corrected to 45. Neither sentence's -argument needs a magnitude. The first turns on the universal — *every* authored map uses those -four tags — so the accept set is what makes the constraint free, not the size of the set. The -second turns on the map being authored on published platform pages *and* resolved by -`pickLocalized`; one authored-and-resolved map already refutes "a convention the runtime -ignores", so the count was never load-bearing there either. Writing 45 would buy one release of -accuracy in prose that is cited as evidence for a schema constraint, and the figure has already -drifted once with nothing noticing; deriving it would mean a permanent gate whose only job is -keeping a number in a comment true. - -The measured half survives untouched at both sites: three platform pages author these maps, and -that is still exactly three. No schema arm, bound, default, `.describe()` string or export -changes; nothing an author can write is affected. diff --git a/.changeset/i18n-slotted-pages-and-global-filters.md b/.changeset/i18n-slotted-pages-and-global-filters.md deleted file mode 100644 index 261148b5c3..0000000000 --- a/.changeset/i18n-slotted-pages-and-global-filters.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": minor -"@objectstack/platform-objects": minor ---- - -Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). - -**BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. - -**`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. - -- Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. -- `translatePage` carries the rebuilt `slots` back onto the document. - -**`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. - -**`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. - -**`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. - -**Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. - - diff --git a/.changeset/id-field-retirement-declared.md b/.changeset/id-field-retirement-declared.md deleted file mode 100644 index 503f9c965e..0000000000 --- a/.changeset/id-field-retirement-declared.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`id_field` now gets a named answer instead of a bare refusal: `FIELD_KEY_GUIDANCE` declares it a retirement with **no successor**, which is the spec-side fact objectui's ingestion choke point needs before it can canonicalise the key (objectui#7650 ruling A — retired spellings are folded once, at ingestion, never at the consumer). - -The direction was a factual finding, not a preference, and it went the way the cheaper branch happens to point — so here is the evidence rather than the verdict alone. A lookup stores the referenced record's id, and which field holds that value is not an authored per-field choice: the picker resolves record identity itself. Nothing on `FieldSchema` names it, nothing in `objectql` / `runtime` / `metadata-protocol` reads a per-field id key, and the two places the platform does let a reference be stored by something other than an id are declared elsewhere — `APPROVER_VALUE_BINDINGS.valueField` (per approver type, e.g. `position` routing by `sys_position.name`) and a seed dataset's `externalId`, the channel lookup references already resolve through. So there is no member to fold onto, and the prescription says what to reach for instead: `displayField` for the candidate's label, a dataset `externalId` for a portable natural key. - -**The entry is keyed `id_field`, in snake_case, and that is deliberate.** The two channels this table feeds disagree about the key face. A `to` becomes a `strictObject` alias, matched through `aliasProbe` — case folded, separators stripped — so one camelCase row covers every spelling. A `why` becomes strict guidance, matched exactly and case-sensitively on the authored spelling. A camelCase row would therefore never be reached by the key authors write, and every existing test in the file would still pass, because none of them asks whether an entry is ever consulted. - -That gap is closed too. Three assertions read the channel that actually answers an authored field key — `FieldSchema.safeParse`, since the schema is strict and the authoring-key walker stays silent on a strict surface by its own posture rule — and pin that the refusal carries this table's sentence verbatim, that a retirement suppresses the rename channel, and that the same-named `idField` on the `inlineColumns` GridColumn mirror is a different schema that stays live. diff --git a/.changeset/import-protocol-implementor-typed.md b/.changeset/import-protocol-implementor-typed.md deleted file mode 100644 index 4cb5cf8fe0..0000000000 --- a/.changeset/import-protocol-implementor-typed.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): let `ImportProtocolLike` type the admin import protocol's members (#17422) - -`admin-import-users.ts` is the only hand-written in-repo implementor of the runner's `ImportProtocolLike`, and it annotated all three required members `args: any`. An explicit parameter annotation wins over the contextual type, so #16952's newly declared request dialect held every implementor except this one — the one with a demonstrated history: before #16950 this file read `args?.query?.$filter ?? {}`, the runner moved to the canonical spelling, the read went `undefined`, and the `?? {}` default degraded the import's duplicate probe into match-everything, so `POST /api/v1/auth/admin/import-users` updated the wrong users without a sound. - -The three annotations are deleted, so `findData` / `createData` / `updateData` are typed by the contract they implement. Measured: with the annotations gone, reading a retired wire alias (`args.query?.$filter`) is `TS2339 Property '$filter' does not exist on type 'QueryInput'`; with `args: any` restored the identical probe type-checks at exit 0. - -`FindDataRequest` declares `query` optional, so `findData` now states its refusal in code — a thrown `Error` carrying the already-registered `INVALID_REQUEST` code — instead of relying on an incidental `TypeError` from a property read on `undefined`. No `??` fallback and no optional chaining were added: both spell match-everything, which is the defect this closes. - -No API, request body, response shape or exported signature changes. A caller that reaches `findData` through `runImport` always supplies `query`, so no supported call moves; only a protocol call that was already failing now fails with a code attached. diff --git a/.changeset/import-protocol-typed-args.md b/.changeset/import-protocol-typed-args.md deleted file mode 100644 index ba2c88d924..0000000000 --- a/.changeset/import-protocol-typed-args.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -refactor(rest)!: `ImportProtocolLike` declares the request each of its three required members receives, instead of `args: any` (#16952) - -The exported extension point `runImport` accepts a protocol through now states its own contract. - -**FROM** — every required member erased its parameter, so the interface declared nothing about the request it would hand an implementor: - -```ts -export interface ImportProtocolLike { - findData(args: any): Promise; - createData(args: any): Promise; - updateData(args: any): Promise; -} -``` - -**TO** — each member names the declared spec request, wrapped in the server-scoped envelope the runner adds (`ImportProtocolRequest`, exported alongside): - -```ts -export type ImportProtocolRequest = R & { context?: any; environmentId?: string }; - -export interface ImportProtocolLike { - findData(args: ImportProtocolRequest): Promise; - createData(args: ImportProtocolRequest): Promise; - updateData(args: ImportProtocolRequest): Promise; -} -``` - -**Why this is breaking-ish, and released as `minor`.** This is a narrowing of a published surface: an implementor that compiles today may stop compiling. Nothing about the values the runner sends changes — the request objects are byte-for-byte the ones #16638 already made canonical — so no runtime behaviour moves. What changes is that the compiler now holds an implementor to the same `QuerySchema` the runner is held to: `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` are declared, and the wire spellings `$filter` / `$top` are not. - -**Migration for implementors.** If your `findData` / `createData` / `updateData` reads a wire alias, it will now fail to compile — that diagnostic is the point of this change, and the fix is to read the canonical key: - -```ts -// before — compiles, and silently degrades to match-everything when `$filter` is absent -async findData(args: any) { - const where = args?.query?.$filter ?? {}; - const limit = args?.query?.$top ?? 2; -} - -// after — drop your own annotation and let the declaration type the parameter -async findData(args) { - const where = args.query!.where; - const limit = args.query!.limit; -} -``` - -⛔ An implementor that keeps an explicit `args: any` annotation of its own opts back out: the annotation wins over the contextual type, and the contract reaches nothing. Leave the parameter unannotated, or name `ImportProtocolRequest` explicitly. - -⚠️ The `?? {}` shape in the "before" is the mechanism that made a dialect mismatch silent rather than loud: an unrecognised query does not throw, it degrades into a filter that constrains nothing, so a duplicate probe stops discriminating and an upsert updates the wrong record. Prefer a read that throws. - - diff --git a/.changeset/import-runner-canonical-query-ast.md b/.changeset/import-runner-canonical-query-ast.md deleted file mode 100644 index 06b036abfd..0000000000 --- a/.changeset/import-runner-canonical-query-ast.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -`import-runner.ts` builds its three server-side `findData` requests in the CANONICAL QueryAST, and the helper that carried them is typed against the declared contract instead of `any`. - -`FindDataRequestSchema` declares `query: QuerySchema.optional()`, and `QuerySchema` declares `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` — it declares neither `$filter` nor `$top`. The normalizer's own table calls those two "the wire-only spellings no schema declares". The reference resolver, the duplicate probe and the id recheck each built a literal in that undeclared dialect, and nothing reddened because the helper they went through took `query: any`: the literals were type-checked by nothing at all, so the undeclared keys cost no diagnostic. Reverting one of them to `$filter` now costs `TS2353 … '$filter' does not exist in type 'QueryInput'`; on the pre-change file the identical revert cost zero errors. - -- **The three literals.** `$filter` → `where`, `$top` → `limit`, plus the `object` the declared query requires. No behaviour change on the two `rest-server.ts` call paths (`POST /data/:object/import` and the async import-job worker), which hand `runImport` the real `ObjectStackProtocolImplementation`: that normalizer folds `$filter` onto `where` and `$top` onto `limit` by the spec's own `RPC_QUERY_ALIAS_SLOTS`, moving the value verbatim, so both dialects reach `engine.find` as the same option bag. -- **The erasure vehicle.** `findArgsBase` now takes a `FindDataRequest` rather than a bare `any` query, so the request-level `object` is compiled too and the `object: ''` placeholder every caller had to override is gone. This is the durable half: rewriting the literals while leaving the parameter `any` would leave the next author in this file with no diagnostic at all. -- **The pin.** `rest-server-canonical-query-ast.test.ts` censuses the PACKAGE rather than one file. `import-runner.ts` has no HTTP door — every query in it is server-built — so its census rejects a wire spelling anywhere in the file, not only inside a `query:` slot. That whole-file rule is the one that finds this class: these three literals were arguments to a helper and were never in a `query:` slot to begin with. - -⚠️ Implementor-visible: `ImportProtocolLike` is exported, its `findData(args: any)` never declared which dialect the runner sends, and the runner now sends the canonical one. An implementation that reads `args.query.$filter` / `args.query.$top` directly — rather than through the protocol normalizer — receives `undefined` and must be updated to read `where` / `limit`. diff --git a/.changeset/insert-check-post-image.md b/.changeset/insert-check-post-image.md deleted file mode 100644 index 33fd97c8e4..0000000000 --- a/.changeset/insert-check-post-image.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/objectql": minor ---- - -fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) - - - -**BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. - -`check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. - -A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: - -- **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. -- **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. - -Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. - -**What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; `update` is unchanged. The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. - -**Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. - -**Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: - -- a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. -- an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. - -**The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. - -⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. diff --git a/.changeset/iso-from-valid-date-family-collapse.md b/.changeset/iso-from-valid-date-family-collapse.md deleted file mode 100644 index e5ecf7c10a..0000000000 --- a/.changeset/iso-from-valid-date-family-collapse.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -"@objectstack/metadata": minor -"@objectstack/metadata-protocol": patch ---- - -fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) - -## What was wrong - -`#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside -the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a -valid JS `Date` becomes ISO text — and handed **every other input back -untouched**. Four adapter boundaries used it, and each fed a field declared -`z.string()` or `z.string().datetime()`: - -| site | declared as | -|:--|:--| -| `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | -| `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | -| `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | -| `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | - -So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a -field declared `string`, each wearing an `as string` / `as string | undefined` -cast that asserted the opposite. Measured over the seven inputs that -distinguish the two helpers, the declared schemas refused **21 of 35** produced -values. - -`recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which -none of the three available answers was legal — the visible text -`"Invalid Date"` fails the refinement, `undefined` fails the required field, and -the pass-through fed it the `Date` object, which fails both. - -## What it does now - -Those four sites read `canonicalIsoInstant`, whose return type **is** -`string | undefined`, so all four casts are deleted rather than restated. Both -sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen -per site, from the site's own declared schema: - -- `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the - branch an absent column already took. ⛔ No default is invented for a field the - schema lets be absent. -- `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named - `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a - `now` stamp is a plausible-looking recording instant nobody measured, and it - sorts a version recorded years ago to the top of a newest-first timeline. The - epoch invents no fact and sorts to the oldest end. It is also the answer the - sibling reader of this same `sys_metadata_history.recorded_at` column already - gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). - -Schema refusals over the same seven inputs: **21 → 8**. The eight that remain -are a `number` and an opaque object at four sites — shapes no driver is measured -to materialise for these columns. They now arrive as the declared *type* (a -string) that simply is not a valid datetime, so the producer's bug stays visible -instead of being papered over. - -## One behaviour change worth reading twice — and it is why this is `minor` - -`DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An -Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — -so a row with an unreadable `updated_at` and a good `created_at` published -`new Date()` as its `mtime`. It now folds to `undefined` one step earlier and -loses the `??`, so the row publishes its `created_at`: a stored instant in place -of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column -takes" that `#14078`'s own ruling text prescribes for the shape. - -⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies -`MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the -pre-existing pin asserted exactly that. So this one site is **not** the repair of -a violation — it is one legal published answer replaced by a different legal -published answer on a published read verb. Nothing was refused before and is -permitted now; a consumer simply receives a different instant. - -## Why the two levels differ - -- **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, - are the "repairing an implementation that silently violated its own already - published declared type" case: the values that changed there are ones - `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and - nothing a consumer legitimately received has moved. But this package also - carries `stat()`, and that site changes a **legal** published answer, which the - paragraph above measures. The level is per package, so the four repaired sites - ride along at `minor`. -- **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves - a legal published answer. `rowToEvent` only stops emitting values - `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a - field declared `z.string()`), and `listCommits` is byte-identical on all seven - probe inputs. - -⛔ No declared type narrowed, no export was added or removed (neither helper was -ever exported), and no envelope or accept set moved — so this is `minor` by the -changed-answer row, not a breaking change, and it carries no ADR-0087 -disposition. - -## What deliberately did NOT collapse - -`listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock -promises callers the RAW value back for a non-`Date`, and the shared spelling -rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the -response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently -drops a value that is on disk) and hand a `number` or an opaque object to the -commit-timeline sort as `String(value)` rather than verbatim. Measured, that site -is byte-identical on all seven inputs before and after this change. - -`SqlDriver`'s same-named helper is not part of this family at all: it takes -`Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, -and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. diff --git a/.changeset/issue-17400-text-door-formula-prose.md b/.changeset/issue-17400-text-door-formula-prose.md deleted file mode 100644 index 25a7e57806..0000000000 --- a/.changeset/issue-17400-text-door-formula-prose.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Scope the text-operator declared-type door's `formula` prose to the judgement it -actually states. The module declared that a `formula` with a readable -`returnType` is judged as the field type its return type names, but at the -door's only consumer — the engine's field-aware seam — a filter over a formula -field never arrives: the earlier materializability door refuses every one of -them with `INVALID_FIELD` 400, whatever the `returnType`. The verdict function, -its sets, the class table and every case are unchanged; only the prose now says -the formula rows are a contract answer no consumer currently reaches, and why -they are kept rather than retired. diff --git a/.changeset/issue-17461-manifest-version-example.md b/.changeset/issue-17461-manifest-version-example.md deleted file mode 100644 index 10b4cf9993..0000000000 --- a/.changeset/issue-17461-manifest-version-example.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ManifestSchema.version`'s TSDoc no longer documents an `@example` its own regex refuses - -The key documented two examples and accepted only one: - -``` -@example "1.0.0" -> /^\d+\.\d+\.\d+$/ accepts -@example "2.1.0-beta.1" -> /^\d+\.\d+\.\d+$/ REFUSES -``` - -An author who copied the second example verbatim got a `ZodError` out of -`ManifestSchema.parse`. The prerelease example is corrected to `"2.1.0"`, a -value the regex accepts. - -**Nothing published moves except the comment.** The regex, the -`.describe('Package version (semantic versioning)')` string and the prose -`(major.minor.patch)` are byte-identical; no accept set, authorable key or -runtime behaviour changes. `@objectstack/spec` ships `src/**/*.zod.ts` in its -`files[]`, so this TSDoc line is itself published — which is why it carries a -changeset rather than `skip-changeset`. - -**The refusal was already the settled reading, which is why this is a comment -fix and not a schema change.** Three artifacts agreed before this change and -still agree: the regex, the prose `(major.minor.patch)`, and -`manifest.test.ts`, which pins `'1.0.0-beta'` in `invalidVersions` on purpose. -Only the `@example` line dissented, so it was the artifact in error. Widening -the accept set to admit prerelease or build metadata would contradict that pin -and is deliberately NOT done here. - -`PluginSchema.version` accepts a different grammar today; the two keys are -deliberately different and are not reconciled by this change. diff --git a/.changeset/issue-17574-search-fields-docblock.md b/.changeset/issue-17574-search-fields-docblock.md deleted file mode 100644 index a9beb15817..0000000000 --- a/.changeset/issue-17574-search-fields-docblock.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct the `search-fields.ts` module docblock's ENGINE bullet: the `$search` expansion is not closed over the resolved set, and its clauses are not all `$icontains`. - -The bullet claimed `expandSearchToFilter` expands a `$search` term into a `$or` of `$icontains` clauses "over exactly this set". Since the pinyin-recall companion column landed, an object whose deployment provisioned the hidden `__search` companion gets one additional clause per latin term on that companion — a field `resolveSearchFields` never returns and no `$searchFields` override can name, so it sits outside the set the sentence called exact. That one clause is `$contains`, deliberately: the companion is already lowercase on both sides, so a case-sensitive operator over two folded values is exact rather than a case bug, and the engine carries an explicit instruction at the site not to align the two operators. The docblock now states both facts and cites that instruction, so a reader does not "repair" the deliberate split. - -Documentation only — no behaviour, schema or exported surface changes. diff --git a/.changeset/issue-17595-retired-component-type-report.md b/.changeset/issue-17595-retired-component-type-report.md deleted file mode 100644 index c64aded76d..0000000000 --- a/.changeset/issue-17595-retired-component-type-report.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -fix(lint): `component-type-unknown` reports an EXACT retired component type, relaying the spec's own prescription - -A retired component type is `isKnownComponentType` on purpose — its -`ComponentPropsMap` row is kept so the props door can dispatch the retirement -prescription — and this rule read that as "accepted". So a caller linting a -**raw stack** got silence on a name `PageComponentSchema.type` refuses at the -parse: the author's earliest feedback channel was the one that stayed quiet, -and the refusal landed later, at the parse door, or in front of an end user. - -``` -FROM validateComponentTypes({ pages: [{ … components: [{ type: 'element:filter' }] }] }) - -> [] // silence, on a name the parser refuses - -TO -> [{ rule: 'component-type-unknown', severity: 'error', - path: 'pages[0].regions[0].components[0].type', - message: '`element:filter` was removed in @objectstack/spec 17 (ADR-0049) …' }] -``` - -**No new prose.** The finding's `message` is the `RETIRED_PAGE_COMPONENT_TYPES` -entry **verbatim** — the same string the enum error map and the kept props row -already carry — pinned by byte equality in the rule's test, so the three doors -cannot drift and a type retired tomorrow arrives reported on the day it lands. - -Two things deliberately unchanged: `isKnownComponentType` still answers `true` -for a retired type (flipping it would MOVE the refusal out of the props door -rather than add a report), and the typo suggester still never proposes a retired -name. - -The new arm is judged **before** the reserved-namespace guard, because a -retirement can take its namespace with it: `user:profile` was the `user:` -namespace's only member, so `hasReservedComponentNamespace('user:profile')` is -`false` and a check placed after that guard would have stayed silent on the -member that has been refused longest. - -Measured before landing: **zero** authored instances of any retirement-map -member across the in-repo page sources, with live component types as the lit -control in the same query — so no existing authored stack turns red. diff --git a/.changeset/link-finder-declared-location-axis.md b/.changeset/link-finder-declared-location-axis.md deleted file mode 100644 index adf64377a0..0000000000 --- a/.changeset/link-finder-declared-location-axis.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/types': minor ---- - -Host importer: a `link:` / `file:` install is now verified by the LOCATION the app declared, so a correctly linked package loads instead of being refused. - -The ESM fallback finder (`createHostImporter`) verifies the one directory it consults — `/node_modules/` — against what the host's own `package.json` declares. Until now it could only do that by NAME, and a `link:` / `file:` value promises no name, so the KEY stood in for one: a package linked exactly as the app asked, whose own manifest happens to be named something else, was refused with `declared-unresolvable` / `MODULE_NOT_FOUND`. Nothing was broken, and the only way out was to stop using a supported linking mode. - -Such a declaration does name something checkable — a directory — so the finder now checks that too: `realpath(node_modules/)` against `realpath(resolve(hostRoot, ))`, both sides canonicalised, compared exactly (no basename matching, no case folding). If they are the same directory, the host declared it and it loads. - -This is a second verification axis, not a looser first one. A directory the app declared neither by name nor by path is refused exactly as before, and the finder stays strictly tighter than the CommonJS resolution it backs up, which asks neither question. Unchanged: a plain version range licenses no path; an `npm:` alias is still checked by name; `github:` / tarball URLs and the bare `owner/repo` shorthand name no on-disk location, so they gain nothing; a package that publishes a `require` condition never reaches this fallback at all, so no load that succeeds today changes. - -Measured on pnpm 10.33: `link:` symlinks the key at the declared directory and verifies; a `file:` directory install routes through pnpm's virtual store (a copy), so it does not, and keeps today's refusal. The refusal's text now states what the location check compared instead of asserting a limit the finder no longer has. diff --git a/.changeset/lint-injected-temporal-column-types.md b/.changeset/lint-injected-temporal-column-types.md deleted file mode 100644 index 3176ab8d5b..0000000000 --- a/.changeset/lint-injected-temporal-column-types.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint): a field-typed rule reads the registry's own type for an injected column, so `created_at` / `updated_at` stop escaping the preset-comparand refusal (#16340) - -`@objectstack/lint`'s object graph recorded the registry-injected system columns by NAME only. A path resolving to one came back `{ kind: 'ok', injected: true }` with no `meta`, so every rule asking a SECOND question about the leaf — "is it temporal?" — had to treat it as unanswerable and stay silent. That silence landed on the two most-filtered columns in the platform. - -Measured on `origin/main` `d57611dfd3`, one dashboard widget over one object declaring `close_date: date` and authoring no `created_at`: - -| authored filter | before | after | -|:--|:--|:--| -| `close_date: 'last_30_days'` (authored `date`) | refused | refused | -| `created_at: { $gte: 'last_30_days' }` (ordering — arm 1) | refused | refused | -| `created_at: 'last_30_days'` | **silent** | refused | -| `created_at: { $eq: 'last_30_days' }` | **silent** | refused | -| `updated_at: { $in: ['last_30_days'] }` | **silent** | refused | -| `stage: 'this_quarter'` (a `select` column) | silent | silent | - -The engine already refused all three of those at query time (`INVALID_FILTER` / 400, the registry's field map in hand), so the gap was purely author-time: `objectstack lint` and the runtime publish gate passed a filter the runtime then refused with a 400 on first render — and an AI author's correction loop only sees what fails the build. - -## What changed - -`GraphObject.injected` is now a `ReadonlyMap` rather than a `ReadonlySet`: each injected column carries the registry's own definition. Both halves are DERIVED from one plan — membership from `resolveInjectedSystemColumns`, the slice from `injectedSystemColumnDefs` (`@objectstack/spec/data`, the same tables `applySystemFields` spreads at registration) — so lint never hand-copies "`created_at` is a datetime" and cannot drift from the runtime that provisions it. `resolveFieldPath` populates `meta` for an injected leaf accordingly, and `filter-preset-comparand`'s field-type oracle lost its `verdict.injected` bail: the marker says WHO wrote the column, and the ruling turns on what the column IS. - -`id` is the one addressable column with no definition behind it — the DRIVER provisions the primary key — so its slice is empty and a second question about it is still unanswered, truthfully and only there. The `select`-column reading arm 2 exists to protect is untouched: no injected column is a picklist. - -**Behaviour change for authors**: a stack that filtered an injected `date` / `datetime` column against one of the thirteen dashboard date-range preset names in an equality or membership position now fails `objectstack lint` and the runtime publish gate where it previously passed. Every such filter was already refused by the engine at query time; the error simply moves to where the filter is written. Write the `{date-macro}` window the message names, or an ISO date. - -**Type change for direct consumers of the seam**: `GraphObject.injected` changed from `ReadonlySet` to `ReadonlyMap`. `.has(name)` answers exactly as before; code that iterated the set or spread it into one needs `.keys()`. Shipped as `minor` under the repo's launch-window convention. - -## Two more rules inherit it, in the same edit - -The type reaches every rule that asks a second question about a resolved leaf, which is the whole reason it was fixed at the seam rather than inside `filter-preset-comparand`: - -- **`list-view-field-dotted`** now refuses a dotted list-view filter key whose head is an injected column, on the same axis as an authored one. `created_at.x` reads as the `datetime` scalar it is (nothing beneath it for a path to reach) and `owner_id.name` as the `lookup` it is (it stores an id, not an embedded document). `assertFilterIsMaterializable` and the REST ingress have always answered `400 INVALID_FIELD` for both — the linter was silent only because the type was missing here. -- **`dataset-include-unknown`** now judges an `include[]` entry naming an injected column instead of bailing on the marker: `include: ['owner_id']` joins (it is the registry's `lookup`), `include: ['created_at']` is refused (a `datetime` derives no join, so every dimension written against that prefix addresses nothing). - -`id` falls through the untyped branch of all three rules — the DRIVER provisions the primary key and no definition table describes it, so an unreadable head is what the door sees too, and none of them invents a refusal there. - -A relationship HOP through an injected column stays a skip (`unknowable` / `injected-hop`), deliberately: the slice now carries `reference`, and traversing it would newly judge every path through a platform anchor wherever `sys_user` is compiled into the stack — a widening with its own findings to measure. diff --git a/.changeset/lint-per-package-namespace-prefix.md b/.changeset/lint-per-package-namespace-prefix.md deleted file mode 100644 index cfd5264d0f..0000000000 --- a/.changeset/lint-per-package-namespace-prefix.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os lint`: evaluate the `naming/namespace-prefix` duplicate advisory per package. - -The advisory read one flattened array per collection key with no package boundary, so on a -composed multi-package project two packages that each legitimately declare the same bare name -(e.g. `home`) were reported as one package declaring it twice — prescribing a rename of a name -that was already correct, with the OTHER package's namespace as the suggested prefix, under a -closing sentence saying distinct packages may reuse a name freely. Both ADR-0130 D4 stack shapes -were affected (flattened-plus-`packages[]`, and `packages[]`-only). - -ADR-0130 D4/D5 registers artifacts per package, so the advisory now runs once per package — -the same shape `os build` has used for the author-time rule table — and a genuine duplicate -inside one package still warns, with the suggestion taken from that package's own namespace and -a path written whole (`packages[1].manifest.apps[1].name`) so it resolves in either shape. A -single-package project is judged exactly as before. diff --git a/.changeset/listview-calendar-type-axis-scope-16577.md b/.changeset/listview-calendar-type-axis-scope-16577.md deleted file mode 100644 index d36781eb61..0000000000 --- a/.changeset/listview-calendar-type-axis-scope-16577.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): record which axis the list-view calendar guard gates — and which it does not (#16577) - -`checkListViewCalendarVisualization` gates ONE way of asking for a calendar: `appearance.allowedVisualizations` includes `'calendar'`. A view can also ask for one by BEING one — `type: 'calendar'` — and that axis parses CLEAN at all three doors (`ListViewSchema`, `ObjectListViewSchema`, `VIEW_METADATA_MEMBERS.listOverlay`). The disposition was correct but undocumented, so it read as an oversight rather than a decision. - -**No behaviour changes.** Every parse verdict at every door is byte-identical before and after; the diff is a TSDoc block on the exported check (which ships in `dist/*.d.ts` and in `src/**/*.zod.ts`) plus pins in `view.test.ts`. - -What the docblock now records, all of it measured rather than inferred: - -- The `type:` axis is **not unwatched**. It is carried by `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` (`kernel/functional-completeness.ts`) at **warning** severity, under the same ADR-0078 §1 rubric this file's `page` note already cites — refuse what renders NOTHING, warn what degrades. The two doors have complementary coverage: the completeness check reads `type` only and is blind to `allowedVisualizations`; this check reads `allowedVisualizations` only and is blind to `type`. -- `viewType` is **not** a second spelling of `type`. The two authoring doors refuse it as an unknown key; the `.strip()`ed overlay write door (`PUT /api/v1/meta/view`) DROPS it, so the view parses as the defaulted `type: 'grid'` — an author who spells it reaches a grid, never a calendar. - -⛔ Escalating the `type:` axis to a parse refusal is deliberately NOT done here: it would refuse a shape 17.3.0 accepts, which is a published-surface narrowing and belongs to a ruling — the same disposition the `timeline` scope pin has stated since #13817. diff --git a/.changeset/lookup-picker-reader-prose-remeasured.md b/.changeset/lookup-picker-reader-prose-remeasured.md deleted file mode 100644 index f8f6252451..0000000000 --- a/.changeset/lookup-picker-reader-prose-remeasured.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The lookup-picker "who reads this" claims in `packages/spec` are re-measured against objectui and dated to the commit they were measured on. No schema, accept set, default or refusal moves — this is evidence prose, and every verdict it sits under is unchanged. - -Three claims had gone false, all in the same direction: they credited objectui's picker with reading a `snake_case` alias that objectui no longer reads. A stale *tolerance* claim fails in the dangerous direction — it tells an author a spelling is accepted downstream when it is not, so a value that will silently arrive as nothing looks supported by the spec's own prose. - -- **`liveness/field.json`, both `displayField` notes.** `/props/displayField` claimed the record picker "reads displayField || display_field"; `/props/inlineColumns/children/displayField` named the `snake_case` spelling flatly as *the* key the grid's lookup cells pass. objectui deleted that twin from `LookupFieldMetadata` with no deprecation window and no dual read. Both notes now name the read chain they actually have — `LookupField.tsx`'s `fieldMeta?.displayField || fieldMeta?.reference_field || 'name'`, and `GridField.tsx` handing the column's camelCase `displayField` straight through at all three lookup-cell call sites. Both entries stay `status: "live"`: `displayField` is live, and more exclusively so than the notes claimed. -- **`src/data/field.zod.ts`, the LOOKUP PICKER (forward) docblock.** It told authors that objectui's `LookupField` / `RecordPickerDialog` / `deriveLookupColumns` read "both these camelCase keys and their snake_case aliases" — a blanket claim over all seven keys declared beneath it. Measured, it holds for three: `lookupColumns`, `lookupPageSize` and `allowCreate` are each read as ` ?? `. The other four — `displayField`, `descriptionField`, `lookupFilters` and `dependsOn` — are read camelCase-only. The docblock now states that per key, keeps saying the truth for the three aliases that survive, and records that those three are objectui's own back-compat rather than a spelling this schema declares. -- **`liveness/field.json`, the `valueDomain` `evidence` string.** It described the shared membership predicate as one "the write path **will** call" while its own first clause already quotes the landed call site that calls it. Tense corrected; the pointer is unchanged. - -Each rewritten claim now names the objectui commit it is dated to, so a later reader can tell how old the evidence is instead of assuming it is current. That dating is prose by design: a gate over a pinned foreign tree would go stale at every pin bump and need its own anti-vacuity self-test, which is a worse trade than a dated sentence. diff --git a/.changeset/lookup-picker-reference-only.md b/.changeset/lookup-picker-reference-only.md deleted file mode 100644 index a290848a68..0000000000 --- a/.changeset/lookup-picker-reference-only.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -**BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route -`GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key -`reference` alone. The three tolerant fallback arms it used to read after it — the -`referenceTo`, `target` and `options.objectName` spellings — are deleted. - -Effect on the wire: a stored object-metadata row whose lookup field carries one of those -three spellings and no `reference` used to answer `200` with rows from the aliased object; it -now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field -carrying `reference` is unaffected, including a partially-migrated row carrying a legacy -spelling beside it. `publicPicker.object` on the form is still the explicit override and is -still read first. - -No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses -`relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering -with a rename hint naming the canonical key, so no authoring path can produce such a row; a -census across both trees found no producer and no relation field carrying any of them, with -positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to -preserve. The spec spelling is the contract, and a stored row spelling the target the old way -is a producer defect rather than a dialect this route accommodates. - - diff --git a/.changeset/lookup-reference-target-gate.md b/.changeset/lookup-reference-target-gate.md deleted file mode 100644 index 53ef53a1bd..0000000000 --- a/.changeset/lookup-reference-target-gate.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/cli': patch ---- - -`object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` - -`FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. - -The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: - -1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; -2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; -3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; -4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. - -Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). - -## Migration - -**A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. - -**A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. - -**A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. diff --git a/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md b/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md deleted file mode 100644 index cb38774326..0000000000 --- a/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/mcp": patch ---- - -docs(mcp): the README no longer promises that Claude Desktop reaches intranet deployments — *Add custom connector* is the claude.ai connector system and dials from Anthropic's servers (#16882) - -`packages/mcp/README.md` grouped the clients by **where the client application runs**: "Local clients (Claude Code / Desktop) can reach intranet deployments; claude.ai web connectors additionally need the endpoint publicly reachable." That grouping is wrong for Claude Desktop. Its *Settings → Connectors → Add custom connector* flow is the same claude.ai connector system, and the connection to the MCP server is made **from Anthropic's servers** — Anthropic's custom-connector documentation requires the server to be reachable over the public internet from Anthropic's IP ranges and states that a server on a private corporate network, behind a VPN, or blocked by a firewall will not connect. An operator following the old sentence pointed Claude Desktop at an intranet address and the failure surfaced inside a third-party client, with nothing to connect it back to our instructions. - -The README now groups by **where the connection is made from**, which is the mechanism and does not go stale when a client's dialog is redesigned: - -- **Claude Code** (`claude mcp add`, or the plugin) dials the endpoint from your own machine, so `localhost` and intranet-only deployments work — this is the door that genuinely reaches a private deployment, and the README now names it as such. -- **claude.ai (web) and Claude Desktop** go through the one claude.ai custom-connector system and need public HTTPS; a locally trusted certificate does not make a private address reachable. - -Documentation only — no exported symbol, endpoint, schema or runtime behaviour changes. The `patch` bump is because `README.md` is in this package's published `files[]`, so the corrected text ships to the npm page. diff --git a/.changeset/mcp-refuse-undeclared-tool-arguments.md b/.changeset/mcp-refuse-undeclared-tool-arguments.md deleted file mode 100644 index 7e82ab200d..0000000000 --- a/.changeset/mcp-refuse-undeclared-tool-arguments.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -fix(mcp): refuse undeclared argument keys on every MCP tool instead of stripping them - -`query_records` answered `{"objectName":"crm_opportunity","sort":"-amount","limit":3}` with `200` -and rows in seed order, and `{"objectName":"crm_opportunity","filters":[["name","contains","Meridian"]]}` -with `200` and the full unfiltered set. Neither key is declared, and zod's strip default — reached -through the MCP SDK's raw-shape wrap — deleted both before the handler ran, so the handler could not -report what it never received. Nothing in either payload distinguished it from a real answer, and the -consumer of these tools is an AI agent: it reads a successful response and reports the wrong answer -confidently. A dropped sort key answers a differently ORDERED set; a dropped filter key answers a -WIDER one. - -All eleven tools held that posture; none refused. Each tool's `inputSchema` is now a built strict -object, so an undeclared key is refused before dispatch, the data bridge is never reached, and -`tools/list` advertises `additionalProperties: false` — the closed set is readable off the schema -rather than discoverable only by being refused. The refusal names the offending key and, where the -spelling is recognisable, the declared one to send instead. - -Spellings that used to be accepted-and-ignored, and what to send now. Every one of them was already -inert: it was dropped, and the call proceeded exactly as if it had never been sent. - -| previously sent and ignored | send instead | on | -| :-- | :-- | :-- | -| `sort`, `sortBy`, `order`, `order_by` | `orderBy` | `query_records` | -| `filters`, `filter`, `conditions`, `criteria` | `where` | `query_records` | -| `select`, `columns`, `projection` | `fields` | `query_records` | -| `pageSize`, `top`, `take` | `limit` | `query_records` | -| `skip`, `start` | `offset` | `query_records` | -| `filters`, `filter`, `conditions` | `where` | `aggregate_records` | -| `metrics`, `aggregates`, `aggs` | `aggregations` | `aggregate_records` | -| `group_by` | `groupBy` | `aggregate_records` | -| `tz`, `timeZone` | `timezone` | `aggregate_records` | -| `object`, `table` | `objectName` | every object-scoped tool | -| `id`, `record_id` | `recordId` | `get_record`, `update_record`, `delete_record`, `run_action` | -| `record`, `values`, `fields` | `data` | `create_record`, `update_record` | -| `action`, `name`, `action_name` | `actionName` | `run_action` | -| `args`, `input`, `arguments`, `parameters` | `params` | `run_action` | -| `formula`, `expr`, `cel` | `expression` | `validate_expression` | - -A key outside this table is refused with its name echoed back and a closest-declared-key suggestion -when one is within a length-relative edit distance. diff --git a/.changeset/mcp-token-human-principal.md b/.changeset/mcp-token-human-principal.md deleted file mode 100644 index aa4ce21770..0000000000 --- a/.changeset/mcp-token-human-principal.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -MCP OAuth: refuse a `client_credentials` (machine-to-machine) access token - -`AuthManager.verifyMcpAccessToken` resolved an M2M access token to a -principal — a machine ran as an authenticated member, stamping a user id that -belongs to no user into `created_by` / `updated_by` and owner columns — while -the method's own contract declared such tokens rejected. The contract's -premise was that they carry no `sub`; the OAuth provider stamps -`sub = user?.id ?? client.clientId`, so the premise was never true and the -rejection it described could never fire. - -The subject and the client identity are now read as a pair, the way RFC 9068 -defines them for a JWT access token: `client_id` is REQUIRED (§2.2), and `sub` -is the resource owner for a grant that had one or an identifier for the client -application for a grant that did not (§2.2.3.1). A token whose `sub` equals its -own `client_id` / `azp` therefore assembles no principal, and the MCP HTTP door -answers `401`. A token carrying neither client claim is refused as well: the -check has no input, and a check that cannot run must not silently pass. - -Unchanged: interactive OAuth clients (authorization code + PKCE) resolve -exactly as before, and the headless track is untouched — `x-api-key` / -`Bearer osk_…` over HTTP and `OS_MCP_STDIO_API_KEY` over stdio are a separate -chain with a separate credential shape, and remain the supported way for a -machine to call this platform. diff --git a/.changeset/memory-driver-tenant-scope-refusal.md b/.changeset/memory-driver-tenant-scope-refusal.md deleted file mode 100644 index f3374541d0..0000000000 --- a/.changeset/memory-driver-tenant-scope-refusal.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -fix(driver-memory): refuse a call the engine tenant-scoped, instead of silently answering with every organization's rows (#16589) - -**BREAKING** for a `driver-memory` deployment that holds more than one organization's rows: an operation the engine tenant-scoped now refuses loudly instead of answering. Shipped as `minor` under the launch-window convention, the same grading the driver's `update()`/`upsert()` type-surface narrowing used. - -Two predicates decided "is this object tenant-scoped", and they disagreed on the default case. The engine scopes an object **unless** it opts out (`buildDriverOptions`: `execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated`), while this driver's boot guard refused only an explicit opt-**in** (`declaresTenantScope`: `tenancy.enabled === true`). An object that **omits the `tenancy` block entirely** — the common case — therefore fell between them: the engine scoped it, the guard never saw it, the deployment posture really was `single` so the posture check passed, and the driver then discarded the scope and returned every organization's rows. A SQL driver refuses the same read. - -This driver still implements **no row-level tenant isolation**, and deliberately does not gain any: it declines to answer rather than answering correctly. `assertCallNotTenantScoped` is a third seam beside the two boot seams, and it judges the scope the engine actually handed over (`DriverOptions.tenantId` / `tenantIds`) rather than re-deriving the engine's predicate from object metadata — a driver that re-derived it would drift from the engine the first time that reasoning changed, and drift here is silent exposure. It runs first in every driver door that accepts a `DriverOptions`, so a refusal leaves the store exactly as it found it. - -**⚠️ Every isolation measurement previously taken on the memory driver is void and must be re-taken.** A suite asserting "tenant A cannot see tenant B's rows" passed here trivially — not because isolation worked, but because both tenants' rows came back to every caller and the assertion was written against a single tenant's fixture. An app that proved out its isolation model on this driver measured nothing. - -What is unaffected, and why: an object declaring `tenancy: { enabled: false }` is never scoped by the engine (ADR-0066), so the driver never sees a scope for it and serves it unchanged; a caller with no organization context is never scoped either, which is the ordinary dev, example-app and single-organization path. Only a call that actually arrives carrying a tenant scope is refused. A deployment that needs organization-scoped reads in development uses `@objectstack/driver-sql`, whose `:memory:` connection is the closest in-process replacement; a deployment whose data genuinely is platform-global can say so with the ADR-0066 posture, which stops the engine scoping it at all. - -The refusal reuses the existing `MemoryMultiTenantUnsupportedError` and its `MEMORY_MULTI_TENANT_UNSUPPORTED` code rather than introducing a second error family: the cause is identical, so a host that already recognises the boot refusal recognises this one with no new code and no second code to learn. - -Also corrects `declaresTenantScope`'s docstring, which closed on a false sentence — "every object in a single-tenant deployment omits the block". A `single` posture constrains the **wall**, not the number of organizations: a `single`-posture run was measured holding 13 `sys_organization` rows, with each row carrying whichever `organization_id` it was written with. The sentence is recorded as superseded rather than deleted, because it is what justified the predicate being an opt-in test. - - diff --git a/.changeset/memory-matcher-scalar-comparand-array-value.md b/.changeset/memory-matcher-scalar-comparand-array-value.md deleted file mode 100644 index dd0926535c..0000000000 --- a/.changeset/memory-matcher-scalar-comparand-array-value.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -fix(driver-memory): a scalar comparand against a stored ARRAY is read as membership on both filter faces, so a filter written to narrow stops returning rows it never selected (#16838) - -`memory-matcher.ts`'s equality arm ended in `value == condition`. Loose `==` converts a stored ARRAY to a primitive — `['a','b']` becomes the string `"a,b"` — so this package's reference matcher and its live query path (`InMemoryDriver.find`, through mingo) answered the same filter two different ways, in both directions at once: - -| filter | stored value | reference matcher, before | live query path | -|---|---|---|---| -| `{ tags: 'a' }` | `['a','b']` | no row | the row | -| `{ tags: 'a,b' }` | `['a','b']` | the row | no row | -| `{ tags: 'a' }` | `['a']` | the row | the row | - -The second row is the sharper one: a **false positive**, a filter written to narrow returning a row it should not, which on a read scope is a permission concern rather than a degraded filter. The first is fail-open in the other direction and just as silent — `if (!rows.length)` cannot tell "genuinely none" from "the predicate asked the wrong question". - -**What changes.** A stored array is now read as its elements, and each is asked the question the arm asks of a scalar: the answer for a row storing an array is the OR of the answers for the rows storing its elements. That is MongoDB's array semantics and therefore mingo's, so the reference face converges on the path this package's users actually run rather than on a third reading nobody wrote. One level only — a nested array is not descended into, matching mingo. `$eq` and `$ne` take the same equality as the implicit spelling, so `$ne` stays the exact complement. - -**What does not change.** An array in the **comparand** position is still refused (`INVALID_FILTER` / 400) by the shape gate every face of this package runs; this is the VALUE side, which that door does not judge. The live query path is untouched — it already answered membership — so a caller who only ever used `find()` sees no difference. Callers who compared results against the reference matcher, or who ran it directly as a driver double, will see a stored array select on membership instead of on its joined string. diff --git a/.changeset/memory-unique-sticky-tenancy-opt-out.md b/.changeset/memory-unique-sticky-tenancy-opt-out.md deleted file mode 100644 index d877f97e20..0000000000 --- a/.changeset/memory-unique-sticky-tenancy-opt-out.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -"@objectstack/driver-memory": minor -"@objectstack/driver-sql": patch -"@objectstack/objectql": patch ---- - -fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) - -## What was wrong - -`InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever -schema THAT call happened to carry. A second registration without a `tenancy` -block — the `{ name, fields }` shape — fell through to the implicit -`organization_id` heuristic, so a `unique` field moved from **one row per -install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) -to **one row per organization**. A duplicate the declaration refuses then -landed. Measured at the driver door on `origin/main` `d61139f1ba`: - -| sequence | second `key: 'K'`, different organization | -|:--|:--| -| register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | -| …then re-register with `{ name, fields }` | **`LANDED`** | - -`SqlDriver` running the same sequence refuses in **both** cases: it has kept a -sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner -`computeTenantField` and not the wrapper that consults the record, so "mirrors -`computeTenantField` arm for arm" stayed literally true while the pair diverged. - -It is silent in both directions — nothing logs the flip, and the refusal names -the field, never the partition. That is the declared-vs-enforced shape Prime -Directive #10 forbids, reached by a state change rather than by a missing check. - -## What it does now - -- **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the - sticky resolver, and the `TenantOptOutRecord` type for the per-instance record - a driver owns. `InMemoryDriver` holds one and resolves through it, handing - BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — - the same resolved column. `uniqueConstraintsFromFields` and - `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional - second argument; called with one argument they answer exactly as before. - `tenantFieldOf` is unchanged and still a pure function of its argument. -- **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with - the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` - block gave a shard an organization key part the base table's index does not - have — one object, two partitions, decided by which physical table a row - landed in. It now resolves through the record, keyed by the base table. -- **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The - Archiver hands that object straight to `cold.syncSchema`, and the published - type refused the key while the driver below read it — so an author writing a - fresh literal was pushed into producing exactly the partial re-registration - above. Same correction #16711 made where the shard leaf narrowed the key off - the object it was handed. - -The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a -declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object -that never declared the opt-out never enters the record, so a genuinely -org-scoped object keeps its `organization_id` partition across a partial -re-registration — an implementation answering `null` more often would not be -stickier, it would be tenant isolation switched off. A carried `tenancy` block -stays authoritative in both directions and CLEARS a recorded opt-out. - -`@objectstack/driver-memory` is `minor` for the two new public-entry exports. -The behaviour repairs themselves are `patch`: each restores an implementation to -the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was -already declaring, rather than replacing one legal published answer with -another. The `objectql` entry is a published type WIDENING — a key the interface -refused is now accepted, and nothing that compiled before stops compiling. diff --git a/.changeset/meta-state-route-engine-outage-distinguishable.md b/.changeset/meta-state-route-engine-outage-distinguishable.md deleted file mode 100644 index af9945d70d..0000000000 --- a/.changeset/meta-state-route-engine-outage-distinguishable.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `GET /meta/object/:name/state/:field` tells a wired-and-failing engine apart from an absent one (#15405) - -`objectQLProvider` has two consumers in `rest-server.ts`. #13476 repaired one of them — the `computeExecCtx` authorization-input seam — by reaching the provider through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine was wired and could not be resolved" as two facts instead of one `undefined`. This route, the slot's second consumer, reached it through `.catch(() => undefined)` and converted every rejection straight back into the `undefined` a never-registered engine produces, three lines before the answer is chosen. So a wired-and-failing engine and a never-registered one both answered `404 NOT_FOUND · "Object not found"` — a diagnostic route lying about the cause during exactly the incident it would be consulted in. - -That line was newly load-bearing rather than long-broken: before #13904 the shipped provider was `try { … } catch { return undefined; }` and could not reject at all, so the `.catch` was dead code. #13904 made the provider re-raise precisely so a consumer could see the outage, and this consumer caught it back. - -**What moves.** On this route only, an engine that is wired and fails to resolve now answers `503 SERVICE_UNAVAILABLE` instead of `404 NOT_FOUND` — the same answer its sibling seam and the package door (#13476) already give for the same fault. No accept set widens and no new wire code is minted: `SERVICE_UNAVAILABLE` is an existing `StandardErrorCode` member, reached through the existing `AuthzStoreUnavailableError`. - -**What does not move.** An engine that was never wired, and a provider that resolves `undefined` (the seam contract declaring absence rather than failing), both keep the `404 NOT_FOUND` they answered before — that is the supported no-data-plane composition. A healthy engine asked about an object that genuinely does not exist still answers `404 NOT_FOUND`; a healthy engine asked about an object that exists is still served. - -**Reachability, stated rather than implied.** Every `/meta` route sits behind the anonymous-deny gate, and that gate resolves the same engine first. Where it takes its provider branch (a single-kernel boot such as `pnpm dev:crm`) a broken engine already raised there, before this route's line ran — so nothing changes for those deployments. The collapse was reachable where a resolvable kernel supplies auth and the separately-wired `objectQLProvider` is broken, which is the multi-kernel wiring, and that is where the new answer lands. - -`POST /email/send` carried the other retired `.catch(() => undefined)` in the same file and moves to `seamOrUndefined`. Its answer is deliberately unchanged at `501 NOT_IMPLEMENTED`; what changes is that a host wiring a **non-`async`** provider — which the seam's declared type cannot prevent — now reaches that same 501 instead of throwing past a `.catch` that did not exist yet and landing in the handler's own `500 EMAIL_SEND_FAILED`. Not reachable from the shipped wiring, where both providers are declared `async`; repaired because it is the same spelling at an embedder-reachable seam. diff --git a/.changeset/meta-types-action-schema-no-longer-empty.md b/.changeset/meta-types-action-schema-no-longer-empty.md deleted file mode 100644 index 4b3b378440..0000000000 --- a/.changeset/meta-types-action-schema-no-longer-empty.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Fix `GET /meta/types` serving an empty JSON Schema for `action` - -`ActionSchema` is a `ZodPipe`, and the `output` derivation of a pipe carries no -properties, so `/meta/types` advertised `action` as -`{"$schema": "https://json-schema.org/draft/2020-12/schema"}` — a document that -reads as "this type declares no constraints" for a type that accepts 47 keys. -The hand-crafted fallback declared for this case never fired, because the -conversion did not throw: it succeeded and returned a truthy husk, which -short-circuits the `??` that was supposed to reach the fallback. - -A derivation that comes back with no properties, no union arms, no `$ref` and no -`additionalProperties` object is now treated as a non-answer. It is retried in -the authoring shape (`io: 'input'`), and if that degenerates too the type is -named in a one-shot warning and the hand-crafted fallback decides. - -Only `action` changes. The `output` derivation remains the served default on -purpose: deriving every type with `io: 'input'` was measured across the whole -served surface and would move 24 of the 26 types that carry a Zod schema, in the -direction of a weaker contract (`required` entries 1132 to 867, -`additionalProperties: false` 663 to 637). Gating the retry on degeneracy keeps -the change to the one type that was actually broken. - -Consumers reading `schema` for `action` from `/meta/types` or `/api/v1/meta` now -receive its real 47 properties instead of an empty object. No other type's -served payload moves, and a type that resolves no Zod schema at all continues to -be served with no schema — absence is not the same failure as a derivation that -came back empty. diff --git a/.changeset/migrate-meta-default-range-terminus.md b/.changeset/migrate-meta-default-range-terminus.md deleted file mode 100644 index 2ef119d6d7..0000000000 --- a/.changeset/migrate-meta-default-range-terminus.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os migrate meta --from N` — the invocation every tombstone prescribes — lists the conversions it was sent to list, and an empty range stops reading as success (#17134) - -`--to` defaulted to `PROTOCOL_MAJOR`, the major the runtime implements. But retirements land throughout a major's line, and their ADR-0087 conversions are registered under the NEXT one: `@objectstack/spec@17.4.0` tombstones `dashboard.refreshInterval` while the conversion that renames it is `toMajor: 18`. The `retiredKey()` house sentence names the major the source was **authored** against — `Run \`os migrate meta --from 17\` …` — so the prescribed invocation composed the range `17 → 17`, which `composeMigrationChain` selects **no step** for, and the command answered: - -``` -✓ Nothing to migrate — the metadata is already canonical for this range. -``` - -exit 0, printed immediately under the five refusals that named that exact command. **29 shipped tombstones across 15 source files prescribe it.** - -Two changes, both in `packages/cli`: - -- **`--to` now defaults to the highest major this build of `@objectstack/spec` carries a migration step for** (`Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS)`), so the tombstone template's presumption holds in every window rather than only after the next major has shipped. Nothing is migrated "past" the runtime: every registered conversion maps a shape the installed schemas already **refuse** onto the one they accept, which is why the terminus is the only target for which the command's own `schemaValid` verdict is reachable. `Math.max` keeps the runtime's major as the floor for the reverse case. -- **A range holding no step is answered as one.** `already canonical` was a green verdict on a check that never ran, so the empty-range case now says so, names the range that would list the conversions (`--to N`), and no longer returns past the schema verdict that contradicted it — the same run used to report `schemaValid: false` in `--json` while the human output claimed the metadata was canonical and stopped. - -**What changes for you.** `os migrate meta --from ` with no `--to` now replays one hop further than it did, so a cross-major run prints that hop's semantic TODOs as well — the same wall a `--from N-1` run has always printed, one major on. The mechanical rewrite list is still first. `--to` is unchanged when you pass it, `--stored` is untouched, exit codes are unchanged (this command reports findings, it does not exit on them), and a range that holds real steps and rewrote nothing still answers `Nothing to migrate`. diff --git a/.changeset/migrate-meta-protocol-version-key.md b/.changeset/migrate-meta-protocol-version-key.md deleted file mode 100644 index a2fa561249..0000000000 --- a/.changeset/migrate-meta-protocol-version-key.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/metadata-core": minor ---- - - - -feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) - -**BREAKING** — two published machine surfaces change a key name. There is **no alias -and no dual-key transition window**: one axis, one name. - -| Surface | Was | Now | -|:--|:--|:--| -| `os migrate meta --json` payload | `runtime` | `protocolVersion` | -| `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | -| `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | - -The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol -major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload -moves — no other key is added, removed or reshaped, and both text faces are byte-identical. -The parameter rename is positional, so no call site changes. - -## Why the name had to move - -`PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed -`@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* -it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or -a stale install, next to the real package versions of the same upgrade session. - -The human line was repaired first and now reads -`Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the -worse half and was left standing, because a key on a published payload is a contract -change: an agent scripting an upgrade has no prose to disambiguate at all, and the -diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer -does not parse. - -## What a consumer should do - -Read the new key. The old one is absent, so a consumer that does not move reads -`undefined` rather than a wrong value. - -```diff -- const v = payload.runtime; // os migrate meta --json -+ const v = payload.protocolVersion; - -- const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE -+ const v = err.diagnostic.protocolVersion; -``` - -The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` -spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it -from the package install boundary, and `@objectstack/services-package` reads it during -hydration — so a consumer reading it from any of those reads the new name too. - -`runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer -protocol major, not a semver in a version position, and it does not carry the ambiguity -this rename closes. - -The breaking surface was measured before the rename and is closed inside this repository: -the only reader of the `--json` key was this repo's own e2e pin and the only reader of the -diagnostic member was `metadata-core`'s own unit test, both of which move in this same -change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever -naming the field. **Zero external consumers were found.** Graded `minor` rather than -`major` for the launch window; the banner above carries the breaking-ness the level cannot. diff --git a/.changeset/nested-strand-chain-restore.md b/.changeset/nested-strand-chain-restore.md deleted file mode 100644 index 7da61e9c18..0000000000 --- a/.changeset/nested-strand-chain-restore.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/plugin-approvals': patch ---- - -`restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit - -`resumeInternal`'s catch arm journalled the consumed suspension of the run that -threw, and nothing else. For a nested run the ancestors were handled on both -paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` -and calls `failSuspendedRun` on each suspended ancestor) and delegation (the -parent frame sees a failed child with no retryable code and calls -`failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, -'failed')` plus a `failed` log record — it journalled nothing. - -So the leaf was restorable while every ancestor was recorded `failed` with its -pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered -`NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent -that never continues: `bubbleToParent` found no parent suspension and logged. -The operator ended up worse off than before using the exit. - -`failSuspendedRun` now journals the pause it consumes whenever the descendant -whose failure consumed it is itself repairable — from the same single producer -and onto the same durable terminal row as the strand's own snapshot, so the -chain is repairable from any replica and after a restart, not only from the -process that stranded it. `restoreConsumedSuspension` then repairs the chain as -one unit: it walks down to the stranded descendant and up through the ancestors -it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes -resumable only after the run it is parked awaiting is parked again. The entry -point does not matter — naming any member of the chain repairs all of it — and -the continuation is then re-issued once, on the run that was named. - -Additive on the wire and in the type: the result's existing fields still -describe the run the caller named, and the new `chain` key is present only when -the repair was a chain repair. `ChainRestoreEntry` is exported for it. The -narrower `IAutomationService.restoreConsumedSuspension` contract in -`@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the -door answers `{ runId, restored, reason }` as it always did. - -Every member goes through the same per-run call as a flat restore — its own -in-process claim, its own strict live-suspension read, its own two-witness read, -its own durable park — so idempotence and the #14333 advance claim hold per run -in the chain: a second restore finds every member parked and answers -`RUN_SUSPENDED` without minting a second pause anywhere. - -⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run -that consumed its OWN pause and then threw downstream, and nothing re-arms an -ancestor by resuming it; stamping it would send an operator to retry a recovery -that cannot succeed. The parent frame's delegation result still carries no -status at all, and an ancestor's repairability is carried by the journal and by -this verb's answer. - -Journalling is EARNED, not applied to every cascade: an ancestor whose -descendant is beyond repair is still consumed without a snapshot, because -re-arming it would promise a chain repair that could not be completed. - -**`@objectstack/plugin-approvals`** reports the consequence rather than causing -it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed -ancestor whose descendant is repairable now comes back `runState: -'repairable'` instead of `'unrepairable'`, and restoring either row repairs the -pair. `'unrepairable'` keeps its other causes — a run that never paused, a -snapshot no longer held, and a cascade whose descendant was itself beyond -repair. No plugin logic changed; the docblocks that documented the old -limitation did. diff --git a/.changeset/notify-zero-delivery-is-distinguishable.md b/.changeset/notify-zero-delivery-is-distinguishable.md deleted file mode 100644 index dfd6d00a27..0000000000 --- a/.changeset/notify-zero-delivery-is-distinguishable.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -`notify` now reports the recipients it addressed, so a run that notified nobody stops reading like a run that had nobody to notify - -A `notify` node whose delivery count came back zero contributed `acted: 0` and nothing else to the run summary. A flow whose only effect-bearing node is that one then folded to `selected: 0, acted: 0, unmeasured: 0` — byte for byte the summary of a run that had nothing to notify about, and of a run whose `notify` node never executed. The run read healthy, and the only trace was a log line. - -`emit()` returns `delivered: 0, enqueued: 0` on several paths, each after logging and nothing else: an audience that resolved to no recipient, a preference filter that suppressed every (recipient × channel) pair, a dedup hit, every enqueue failing. A stack with no messaging service installed lands in the same place. All of them were silent in the summary, so this is not one cause being fixed — it is the whole class becoming visible. - -The node now reports `selected` — the recipient entries it addressed — on every path that reaches a recipient list, alongside the `acted` / `unmeasuredEffect` rules it already had. Those two are unchanged, so a delivering run keeps its existing `acted` (inline) or `unmeasured` (outbox) reading and stays outside the broken-sweep filter; a zero-delivery run now reports `selected: N, acted: 0` with no `unmeasured`, which is the platform's declared "matched N, acted on none, and that zero is trustworthy" signature and puts the run **inside** `selected > 0 AND acted = 0 AND unmeasured = 0` — the filter that exists for exactly this, and whose first clause the old reading could never satisfy. - -The zero is deliberately NOT reported as `unmeasuredEffect`. That flag means the count is unknown; this count is known and it is zero, and claiming otherwise would take the run out of the very filter it belongs in. - -`selected` counts audience entries, not resolved users: the entry (`role:manager`, a bare id) is what the node has, since expansion happens inside the messaging service and is not reported back. diff --git a/.changeset/numeric-column-representation.md b/.changeset/numeric-column-representation.md deleted file mode 100644 index 719018cda5..0000000000 --- a/.changeset/numeric-column-representation.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/driver-sql': minor -'@objectstack/cli': minor ---- - -One physical representation for the NUMERIC column family, read by every producer of DDL - -`packages/spec` now states, per field type, what column a numeric field gets, and all three -producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and -`os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object -through all three producers, before and after: - -``` - BEFORE AFTER - driver sql gen ts gen all three -number real numeric(18,2) numeric(8,2) numeric(65,30) -currency real numeric(18,2) numeric(8,2) numeric(65,30) -percent real numeric(5,2) numeric(8,2) numeric(65,30) -slider real numeric(18,2) numeric(8,2) numeric(65,30) -summary real numeric(18,2) numeric(8,2) numeric(65,30) -progress real numeric(5,2) numeric(8,2) numeric(65,30) -rating real integer integer integer -``` - -7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own -direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; -`numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round -half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is -MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only -candidate measured to lose nothing on a nine-value corpus. - -Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from -`required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is -the write-time contract the record validator enforces, and binding the DDL to it made every -post-deploy tightening a destructive migration. - -**BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no -backfill runs. Four consequences to know before creating new tables: - -- `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count - DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a - `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no - error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal - set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` - as a REAL in an INTEGER-affinity column, unchanged from today. -- An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 - fractional digits: a magnitude whose significant digits run past the 30th decimal place loses - the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so - the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 - are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; - the rounding it replaces was not. -- Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number - (`z.number().finite()`), so a value that was never a JS double does not survive the round trip - exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. - The fidelity this buys is an exact COLUMN read through a double: values written by this - platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any - magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract - change and is not in this release. -- A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. - Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's - own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT - supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on - 2026-09-08). A source author who wants the column they had must write that block themselves; - `required: true` keeps its own meaning, the write-time contract the record validator enforces. - -SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both -`table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. - - diff --git a/.changeset/oauth-agent-runs-as-the-user.md b/.changeset/oauth-agent-runs-as-the-user.md deleted file mode 100644 index 4c945367bb..0000000000 --- a/.changeset/oauth-agent-runs-as-the-user.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/spec": minor -"@objectstack/mcp": minor -"@objectstack/runtime": minor ---- - -fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) - -Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** - -**The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: - -| identity path | `crm_account` | `crm_opportunity` | `crm_task` | -|:--|--:|--:|--:| -| API key, `principalKind: human` | 9 | 23 | 45 | -| OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | - -The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. - -**The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. - -**(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. - -⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. - -**(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. - -**(3)** The Setup page's promise is untouched — it is now true rather than rewritten. - -Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. - -`DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: - -| direction, after release | consumer cost | -|:--|:--| -| ship optional fields, later tighten them to required | a compile break | -| ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | - -The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. diff --git a/.changeset/oauth-register-declares-only-honoured-members.md b/.changeset/oauth-register-declares-only-honoured-members.md deleted file mode 100644 index cf48a99420..0000000000 --- a/.changeset/oauth-register-declares-only-honoured-members.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `oauth.applications.register` declares only the members `/oauth2/create-client` accepts — `name`, `scopes` and `metadata` are removed (#15447) - -**BREAKING** — three members leave a published request type. A caller who sets one compiles today and gets a type error after this release. That is the point: the route never honoured any of them, so what the compiler now refuses is code that was already having its value thrown away. - -## What a caller passing these members should do instead - -| you were passing | pass instead | why | -|---|---|---| -| `name: 'My App'` | `client_name: 'My App'` | same `string`, and `client_name` is the member the route reads | -| `scopes: ['openid', 'profile']` | `scope: ['openid', 'profile'].join(' ')` | ⚠️ **not** a rename — `scope` is one space-delimited string; posting an array is refused with `400 [body.scope] Invalid input: expected string, received array` | -| `metadata: { tenant: 'acme' }` | nothing — delete the member | no door this SDK can reach accepts it (see below) | - -## ⚠️ These were the vendor's RECORD vocabulary, not typos - -`client_name` writes the DB column literally named **`name`**; `scope` writes the DB column literally named **`scopes`**, as a JSON array. The removed members were the *column* names offered next to the *wire* names in the same declared type — an author picking the adjacent one of two got a success receipt and no value. Treating them as misspellings would be the wrong reading of what they were; the prescription above is still the wire member either way. - -## Why they had to go rather than be honoured here - -`POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider@1.7.2`. Its body schema declares 21 members and sets no `catchall`, so it is zod's default **strip**: an unknown key is dropped, not refused, and the caller gets **HTTP 201 and a client that quietly does not have the value**. Driven end to end against a real `betterAuth` + `oauthProvider` over a real ObjectQL engine on a real socket, through this client: each of the three came back absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, and `null` in the `sys_oauth_application` row. - -A second, independent barrier stands behind that strip — the handler funnels the parsed remainder into the opaque-metadata envelope, and all three names sit in `OPAQUE_METADATA_RESERVED_FIELDS` — so no amount of loosening on the SDK side could ever have made them arrive. `metadata` in particular is honoured only by `PATCH /admin/oauth2/update-client`, which is `SERVER_ONLY` and therefore not an HTTP route at all: over the wire it answers 404 with a zero-byte body. - -Nothing else on the method moves. The two members the route does honour, `client_name` and `scope`, are declared exactly as before and still reach the server byte for byte; the method's return type, its URL and its request-building step are unchanged. - -Graded `minor` rather than `patch` because a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch #35) that such a change takes at least `minor`; the banner above carries the breaking-ness the level cannot. - - diff --git a/.changeset/object-block-sort-item-array.md b/.changeset/object-block-sort-item-array.md deleted file mode 100644 index 4e09034f60..0000000000 --- a/.changeset/object-block-sort-item-array.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `object-grid` and `object-calendar` constrain the `sort` VALUE to the `SortItem` array — one sort orthography platform-wide reaches the last two unconstrained doors (#16553; objectui#8221, decision batch #77 option B) - - - -**BREAKING** accept-set change at two doors — `ComponentPropsMap['object-grid'].sort` -and `ComponentPropsMap['object-calendar'].sort` — shipped as `minor` under the -repo's launch-window convention for breaking changes; the migration prescription -is registered under protocol major 18 as `object-block-sort-item-array`. - -One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, -2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is -objectui PR #8758, which drops the legacy string arm from -`convertSortToQueryParams`). Item 4 of that ruling is this release's subject: -「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the -`sort` value to the array shape (today it accepts anything), so the spec, the -registrations and the helper agree; that is a pull-back to the declared contract, -ordinary tier」. - -Until this release both doors declared `z.unknown()` — no orthography at all. -Measured on `@objectstack/spec` 17.2.0 and re-measured on this tree before the -change: an array, the legacy string clause and a bare NUMBER all returned -`success: true`, while `bogusProp` was refused by name on the same call. So key -checking was live and only the VALUE was unheld, and an author following -objectui's own registrations (`plugin-grid/src/index.tsx:222` has published -`type: 'array'` all along) and an author following the legacy string each got a -silent success receipt for a different shape — while objectui's html tier -answered `type-mismatch` on the second one. Both doors now declare -`z.array(SortItemSchema)`, the array `ElementDataSourceSchema.sort`, -`ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already -carry: one shared schema, not a third copy. - -Sequenced measurement-first, as this family has to be. At the objectui pin this -repo builds against (`53ded82b`) the string is still lowered — -`ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto -`$orderby` beside the array arm, and `ObjectCalendar.tsx:431` hands `schema.sort` -to `convertSortToQueryParams`, whose string arm is still present at -`sort-query.ts:66-70`. This declaration therefore lands ahead of the pinned -consumer, which the ruling permits explicitly — either order, since the -registrations already declare the array — and the next pin bump carries the -retirement in. - -**Migration** (`object-block-sort-item-array`): `sort: 'created_at desc'` becomes -`sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name -`sort: 'created_at'` meant ascending and becomes -`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in -`SortItemSchema`, so it is written out rather than omitted; a comma-separated -clause becomes one array entry per key, in the same order. The string is refused -at `sort` (`invalid_type`, expected array), as is a bare number; a misspelled or -absent direction is refused at `sort.0.order`. Metadata AT REST is not rewritten -and this disposition adds no D2 conversion — a stored page carrying a string -`sort` keeps loading and still renders at the pinned `.objectui-sha`; what -changes is that RE-SAVING it is refused at the `sort` door. - -**Not moved by this release.** `record:related_list.sort` keeps its declared -string arm: that string is the `'field'` / `'-field'` dialect read by -`RelatedList.normalizeSortSpec`, it never reaches `convertSortToQueryParams`, and -retiring it was not ruled — objectui#8221's own implementing round narrowed it, -established the dialect and reverted the narrowing byte-identically. -`object-grid.defaultSort` is a different key, already retired by #11805. Zero -authored `sort` values on either block exist in this repo (the two showcase pages -that author `object-grid` declare none), so nothing in-tree was converted. - -Type aliases are unchanged: `SortItemSchema`'s input equals its infer, so neither -block's parsed state moves for this key, and both already take the -`…PropsParsed` route for `filter` (ADR-0122). diff --git a/.changeset/objectql-aggregate-inmemory-rows-ast.md b/.changeset/objectql-aggregate-inmemory-rows-ast.md deleted file mode 100644 index 0075d356ac..0000000000 --- a/.changeset/objectql-aggregate-inmemory-rows-ast.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): `engine.aggregate`'s in-memory lowering asks the driver for ROWS, so a per-aggregation `filter` stops being refused by the driver it was lowered for (#16642) - -`engine.aggregate` forks: a driver with a native `aggregate()` gets the pushdown, and anything the pushdown cannot express — a per-aggregation `filter` (#10576), a date granularity the driver does not advertise, a non-UTC reference timezone — falls back to `driver.find()` plus `applyInMemoryAggregation`. That fallback handed `find()` the whole aggregate AST, **aggregation keys included**. - -`find()`'s contract says nothing about `groupBy` / `aggregations`, and the drivers disagree about them. `driver-sql` and `driver-rest` ignore both and return rows — which is the only reason this path ever worked. `driver-memory` **honours** them (`find()` → `performAggregation`, the same method its `aggregate(AST)` door funnels through), which is the shape measured here; `driver-mongodb` and `driver-turso` carry the same refusal on their own aggregation faces, so a driver that ever routes `find()` into one lands in the same place. Against a driver of the second kind the one seam answered two different wrong things: - -- the per-aggregation `filter` that **routed the call here** was refused `NOT_IMPLEMENTED`/501 by the driver's own #10413 guard — a guard aimed at a caller reaching the driver's aggregation face directly, whose remedy text is *"route the query through the engine"*. The engine's own lowering was being told to use the engine. Downstream, `service-analytics`'s ObjectQL strategy lowers a dataset measure `filter` into exactly this key, so on the memory driver a measure `filter` (and the `derived: { op: 'ratio' }` that needs two differently-filtered counts) answered **501** while sqlite answered the number; -- a date-bucketed `groupBy` came back **already grouped**, on the raw timestamp — `dateGranularity` is an engine concept no driver face reads — and `applyInMemoryAggregation` then aggregated those group rows a second time. That half does not refuse: it reports a count of *buckets* under the author's own measure name. - -The fix is one seam: on the in-memory path the AST sent to `find()` carries no `groupBy`, no `aggregations` and no `having` — the three things this path is about to evaluate itself. `where` is untouched, so the middleware-injected read scope (RLS / tenancy) still travels with the call. - -`patch`: no signature moves and no key is added or retired. The pushdown fork is unchanged (an aggregation with no filter still goes to `drv.aggregate`), and on `driver-sql` — which ignored the stripped keys — the emitted statement and every number are unchanged. What changes is that two shapes that used to answer a refusal or a wrong number now answer the number the contract already promised: `driver-memory`'s `refusePerAggregationFilter` and `driver-sql`'s `unsupportedAggregationFilterError` both document themselves as *unreachable through `engine.aggregate`, which lowers in memory for every driver* — this is the line that makes that true. diff --git a/.changeset/objectql-scoped-repository-declared-returns.md b/.changeset/objectql-scoped-repository-declared-returns.md deleted file mode 100644 index 0cd2a59e90..0000000000 --- a/.changeset/objectql-scoped-repository-declared-returns.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -feat(engine): `ObjectRepository.findOne` / `.update` publish their honest types — the contract's shapes, not `any` (#16786) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, and PR #14434 before it on `@objectstack/driver-memory`). - -`ObjectRepository.findOne()` and `.update()` were written out with an explicit `Promise` while they have always answered what the contract declares — each one forwards, one line down, to an `IDataEngine` door that already declares the shape: - -- `findOne` → `Promise | null>` -- `update` → `Promise | number | null>` - -`IScopedObjectRepository` — the contract this class carries an `implements` clause for — declares both, and has since ruling A on #16231 landed (PR #16783). An explicit `any` satisfies that structurally, because a **wider** declared return always satisfies a narrower one: `class ObjectRepository implements IScopedObjectRepository` compiled green the whole time while the emitted `.d.ts` read `Promise`, so no caller holding an `ObjectRepository` — or reaching one through `ScopedContext` or `ObjectQL.createContext()`, both exported from this package's index — was ever asked to narrow. They are now declared as the contract declares them. No runtime behaviour changes. - -A caller that read fields off `findOne()`'s result through the `any` now narrows the `null` arm first; a caller that read `update()`'s result now separates the by-id record from the predicate-form count. The in-repo census for this change was one file, repaired alongside. - -`updateById` is deliberately untouched: `IScopedObjectRepository.updateById` itself declares `Promise`, so the class already matches its contract and there is no drift to repair on this side. That half stays open on #16786. - - diff --git a/.changeset/olive-donuts-invent.md b/.changeset/olive-donuts-invent.md deleted file mode 100644 index 4db59c46d1..0000000000 --- a/.changeset/olive-donuts-invent.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -**`AppPlugin` now names the manifest-stage `permissions` value its ADR-0057 security registrar cannot read, instead of dropping it in silence.** - -The registrar flattens the manifest under the stack's own collections (`{ ...manifest, ...collections }`), so `manifest.permissions` is read whenever the stack declares no `permissions` collection of its own. That key is the ADR-0025 §3.2 capability grant a package *requests* — a flat list of permission strings, or `{ services, hooks, network, fs }` — while the registrar wants ADR-0090 `PermissionSet[]`. Both arms were skipped with nothing logged: the structured arm is not an array, so the whole value never entered the loop; every member of the flat list carries no `name`, so all of them were dropped. An author who wrote `manifest: { permissions: ['sales_rep'] }` meaning a permission set got no set registered, no `sys_audience_binding_suggestion`, and no line anywhere saying why — the "absence must be loud" rule in AGENTS.md → Route & surface ownership §3. - -It now warns once per boot, naming the field, how many entries were lost, both readings of the key, and where permission sets belong (`defineStack({ permissions: [ … ] })`). The report is written per `SECURITY_FIELDS` entry, so a hand-built bundle carrying `positions` / `capabilities` / `sharingRules` on its manifest is named too. - -**Nothing else moves.** Which items register is byte-for-byte unchanged — the registrar is deliberately *not* made tolerant of the grant reading (widening the key was rejected by name, #14242 road C, maintainer 2026-09-02). The line is `warn`, not `error`: nothing here claimed to persist anything. It stays silent on every shape where nothing was lost — a stack declaring its own `permissions` collection, a manifest with no such key, a manifest whose entries the registrar really can read, and the `securityMetadataRegistrar: 'artifact-door'` composition that owns the route. diff --git a/.changeset/one-app-rule-adr-0019-citation.md b/.changeset/one-app-rule-adr-0019-citation.md deleted file mode 100644 index 233a4f248b..0000000000 --- a/.changeset/one-app-rule-adr-0019-citation.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the one-app-per-package refusal cites the record it means, `ADR-0019 (app-as-consumer-unit) D3` - -`ADR-0019` names **two** records in this repository — `0019-app-as-consumer-unit` (D3 = a `type: 'app'` package defines at most one app) and `0019-approval-as-flow-node` (D3 = deprecating `ApprovalProcessSchema`). Both have a D3, and `stack.zod.ts` cited the bare number for both, so an author following the refusal's own citation was as likely to reach the wrong decision record as the right one. - -The three citations of the app-cap rule now name the record: - -- the `STACK_SINGLE_APP_VIOLATION` message — the only one an app author ever sees; -- the `validateSingleApp` docblock; -- the `StackSingleAppViolationError` docblock. - -Only the message tail changed: `An 'app' package must define at most one app, but found N (…)` is untouched, so any consumer matching on that prefix is unaffected. The rule, the refusal's condition and `defineStack`'s behaviour are unchanged. - -The approvals-side citations are deliberately left bare — repo-wide ADR-number disambiguation is tracked separately. diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md deleted file mode 100644 index b563b54051..0000000000 --- a/.changeset/operator-facing-raw-exec-cause-text.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/metadata-protocol': patch -'@objectstack/metadata': patch -'@objectstack/cli': patch -'@objectstack/driver-sql': patch ---- - -fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal - -Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer -lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a -COMPOSED message that discloses neither the statement nor the diagnostic, and carries -the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and -is unchanged here. - -What changed underneath it is what every consumer STORED. Each migration probe, backfill -and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded -`error.message` into an operator-facing record, so those records began reading - - the database refused to run a raw statement - -where they used to read - - no such column: foo - -For a live console that costs nothing — the driver prints the statement and the dialect -text to its warn sink one line earlier. For a record read later it costs everything: -whoever opens a customer install's backfill result a week on never had that line, and the -dialect's words were unrecoverable for them. - -`@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk -of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen -stored-record sites plus `os db clean`'s console line read through it: - -- `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; -- `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the - three per-object warnings; -- `partial-index-probe` — the `detail` both callers report (and its two module comments, - which stated the opposite of what happened); -- `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, - `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; -- `os db clean` — the `VACUUM failed` line. - -Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned -on its own message channel, its `cause` never walked, and a declared envelope that is not -the raw-path one — the typed read exits' terminal, which composes a different sentence — -is left exactly as it arrived. - -That message channel is deliberately NOT byte-identical to what the replaced expressions -computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as -`messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the -string itself when a string was thrown, and `String(error)` when neither yields text. Every -difference from the replaced expressions follows from that rule, so read the rule and not a -list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, -which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a -thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read -`undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no -record was written at all and the operation aborted; an object carrying a NON-EMPTY string -`message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` -(one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads -`''`, so this channel is neither always prose nor never empty. - -## The levels, and why they are not uniform - -`@objectstack/types` takes **`minor`**: it is the one package here that grows a published -surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the -export list. A purely additive widening takes at least `minor`. - -The other four take **`patch`**, because none of them widens anything: they are a bug fix in a -released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named -because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper -against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this -PR: no entry point reaches a test file, and `files` packs `dist` only. - -**Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: -what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name -and never a type. The change these sites were made for is the declared raw-path fault, where -the record gains the dialect's words in place of the driver's composed placeholder. Every -other throw now reaches these records through the rule above rather than through the -expression each site spelled out, so its text can move too — a consequence of the rule, not a -bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, -and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an -`Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was -`seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on -top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; -that fallback was load-bearing — the site read an empty value as "the probe did not fail" — -and #17167 removed it in this same release, so all fourteen sites now record the channel as -is and that site carries its failure fact structurally. The sentence being replaced is not a value any -consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these -records gets the dialect's words back where it had been getting a placeholder. diff --git a/.changeset/organizations-open-core-prose.md b/.changeset/organizations-open-core-prose.md deleted file mode 100644 index 55844b53e8..0000000000 --- a/.changeset/organizations-open-core-prose.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/plugin-dev': patch ---- - -Operator-facing text no longer tells an open-source install that multi-organization -operation requires a subscription. - -ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` -is Apache-2.0, carries no licence check, and declares both walled postures (`group` and -`isolated`) as its own constant. The messages an operator actually reads had not followed: - -- `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and - is NOT on the public npm registry ... Without one this bullet is not followable" — it now - says the runtime is Apache-2.0 and on the public registry, and notes that a commercial - deployment resolves the same package name to its own private, licence-gated build. -- The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the - runtime "enterprise". -- `os verify`'s `--org-scoped` flag description drops the same word. -- The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe - the package as the enterprise runtime. - -Text only — no control flow, no identifiers, no behaviour change. diff --git a/.changeset/osv-advisory-bumps-2026-09.md b/.changeset/osv-advisory-bumps-2026-09.md deleted file mode 100644 index af74c7d9a4..0000000000 --- a/.changeset/osv-advisory-bumps-2026-09.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/plugin-hono-server": patch ---- - -Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. - -The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. - -Two published packages change what a downstream install resolves, which is what this changeset grades: - -- **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. -- **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). - -No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. - -The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). - -`hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. diff --git a/.changeset/page-guidance-stops-prescribing-assignedprofiles.md b/.changeset/page-guidance-stops-prescribing-assignedprofiles.md deleted file mode 100644 index a7bec10c9c..0000000000 --- a/.changeset/page-guidance-stops-prescribing-assignedprofiles.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): `PageSchema`'s rejection guidance stops prescribing `assignedProfiles` as a page gate (#16929) - -The two wrong-layer prescriptions `PageSchema` hands an author at parse time both ended by pointing at `assignedProfiles`: the `visibleWhen` pointer said "or gate the page with `assignedProfiles`", and the `permissions` pointer said "reach it through `assignedProfiles`". Neither is true. `assignedProfiles` gates nothing. - -Measured 2026-09-10 on `origin/main` `e1eee43beb` and objectui `3fbdd4a2d`: `assignedProfiles` has **zero readers** in this repo — every one of its 25 matching files is a declaration, a generated artifact, prose, a `CHANGELOG`, the liveness ledger, or this schema's own round-trip test — and **zero readers** in objectui, whose three hits are a docs table row and two type/zod declarations. Lit controls in the same sweeps (`visibleWhen` 308 files, `PageSchema` 94 files in objectui; `visibleWhen` 168 files here) prove the instrument fired; a fabricated dark control read 0 in both. The key is also named for the concept **ADR-0090 D2** removed, which `security/permission.zod.ts` states to authors three times over. - -Prescribing it was Prime Directive #10's exact prohibition — advertising a capability the runtime does not deliver — delivered to the author in the error that is supposed to be teaching them the correct spelling. Both prescriptions now say only what the platform actually does: put `visibleWhen` on the component inside a region, and gate the DATA a page shows with the object's permission sets. - -**Nothing about what `PageSchema` accepts changes.** `assignedProfiles` remains an authorable key with its declaration untouched, and the `profiles:` / `assignedTo:` alias entries are untouched. Both channels edited here fire only from the `unrecognized_keys` path, so every key involved is rejected before this change and rejected after it, with identical `issue.code` and identical `path` — only the human-readable text moves. The key's own disposition (keep, rename, or remove) needs a ruling and stays open on #16929. diff --git a/.changeset/permissions-alias-hosts-justification.md b/.changeset/permissions-alias-hosts-justification.md deleted file mode 100644 index 83235a96ae..0000000000 --- a/.changeset/permissions-alias-hosts-justification.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct the `permissions` alias table's justification for `hosts`, and pin the two aliases nothing measured. - -`PluginPermissionsSchema` (`kernel/manifest.zod.ts`) curates three aliases — `filesystem` and `paths` point at `fs`, `hosts` points at `network`. The block's only comment said edit distance cannot reach any of them, and it sat directly above all three. That is true of the two `fs` entries and false of `hosts`. - -The fallback budget is `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), so a 5-character key gets 2, and `hosts` differs from the declared `hooks` by exactly 2. Measured against the real `findClosestMatches` with the alias table out of the picture: `filesystem` and `paths` return nothing, `hosts` returns `hooks`. So without the alias an author writing `hosts` is answered ``Did you mean `hosts` → `hooks`?`` — pointed at lifecycle hooks on the one block that also grants network access. - -The alias is therefore better justified than the comment claimed: it overrules a confident wrong suggestion rather than filling a silent gap. Only the justification moves — the alias stays, the declared keys, the strictness and the union are untouched, and no message an author reads changes. - -`hosts` is also the only one of the three whose absence would be invisible, since it is the only one that changes a live suggestion, so `manifest-unknown-keys.test.ts` now pins both it and `paths` alongside the `filesystem` pin that was already there, asserting the offending key and the rename — and, for `hosts`, that `hooks` is not what comes back. diff --git a/.changeset/persist-terminal-run-status-distinction.md b/.changeset/persist-terminal-run-status-distinction.md deleted file mode 100644 index f45dc36989..0000000000 --- a/.changeset/persist-terminal-run-status-distinction.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -A run's durable history row records the terminal status the run actually reached — `completed`, `failed`, `cancelled` or `timed_out` — instead of folding all four into two. A restart no longer changes a run's answer. - -`RunRecord.status` declared two members (`'completed' | 'failed'`) while `AutomationEngine.recordLog`'s own terminal predicate admitted four and `ExecutionStatus` (`@objectstack/spec`) has declared them all along. Both ends of the store folded to match the narrower declaration: the write mapped everything that was not `completed` to `failed`, and the read mapped everything that was not `failed` back to `completed`. The distinction was therefore not hidden — it was **destroyed at write time**, so no later change could recover it for a row already stored. The cost was that one run answered differently depending on where you read it: `getRun` prefers the in-memory ring entry and said `cancelled`, while after a restart or a ring-buffer eviction the durable row answered, and it said `failed`. - -- **The write side.** `recordLog` writes the status its own terminal predicate admitted, resolved once into a `const` that also decides whether a row is written at all. The predicate is now the single declared vocabulary, `TERMINAL_RUN_STATUSES` (`engine.ts`) — three sites had a copy of that list and only one of them was ever going to be updated together with the writer. -- **The read side.** `ObjectStoreSuspendedRunStore` resolves the row's status once in the gate that already decided whether the row is terminal at all and hands the member to `deserializeTerminal`, which no longer re-reads or folds it. `listHistory`'s filter was the second copy of the two-member list — left alone it would have replaced a wrong status with a *missing row*, dropping cancelled runs out of the Runs list entirely. -- **The stored column.** `sys_automation_run.status` accepts the two added members, and the retention scope (`lifecycle.retention.onlyWhen`) counts them as terminal — a widened writer over a two-member sweep scope would have left `cancelled` and `timed_out` history rows never ageing out, on a table whose whole retention posture (ADR-0057) is that history is telemetry. `refused` is deliberately not added: `ExecutionStatus` declares it (#14945) but no engine path produces it, and an option nothing can write is declared-but-inert metadata (ADR-0078). -- **Rows already stored keep reading `failed`.** The information they lost is not recoverable and this change does not pretend otherwise — there is no backfill, because there is nothing to backfill *from*. Rows written from this release forward carry the distinction. -- **`TerminalRunStatus`** is exported for the same reason `ConsumedSuspensionDropNotice` is: `RunRecord` is barrel-reachable, and a host store implementing `recordTerminal` / `loadTerminal` has to be able to name the field it round-trips. - -Not a breaking change, and deliberately carries no breaking-change banner: the published contract (`IAutomationService.getRun` / `listRuns` return `ExecutionLog`, whose `status` is `ExecutionStatus`) has declared all four members since before this row existed. What changes is that the implementation stops under-reporting one the contract already promised — a consumer written against the declared contract is unaffected. Also no ADR-0087 migration entry: that ADR governs authorable metadata shapes on `sys_metadata`, and this is an engine-owned system data table whose existing values stay valid under the widened option set. diff --git a/.changeset/plain-donkeys-repeat.md b/.changeset/plain-donkeys-repeat.md deleted file mode 100644 index ee8dfe6a85..0000000000 --- a/.changeset/plain-donkeys-repeat.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Declare the ASSEMBLED manifest stage on the installed-package read API. - -`GET /api/v1/packages` and `GET /api/v1/packages/:packageId` serve whatever a -package was installed with, and two stages reach that table through declared -doors: `POST /api/v1/packages` installs an authoring manifest (`manifest.objects` -= glob patterns), while a `defineStack()` host installs the assembled body -(`manifest.objects` = object definitions). Both response schemas typed every row -at the authoring stage alone, so the shipped `defineStack()` path served a -payload its own declared contract refused. - -Following the #14242 ruling — declare the assembled stage rather than widen the -authoring one — `@objectstack/spec/api` gains two exports: -`AssembledInstalledPackageSchema` (the assembled-stage counterpart of -`InstalledPackageSchema`) and `InstalledPackageAtEitherStageSchema`, a union -over the two whole closed stage declarations. `ListInstalledPackagesResponseSchema` -and `GetInstalledPackageResponseSchema` are bound to the union. - -This is additive at runtime, and the runtime parse is where the gain is: every -payload that parsed before still parses, payloads that were refused for their -manifest stage now parse, and a row belonging to neither stage — an `objects` -array mixing globs with definitions — is still refused. `ManifestSchema` is -unchanged. - -The STATIC gain is one-sided, and smaller than a union normally implies. -`AssembledPackageBodySchema` is annotated `z.ZodType, …>` -in `stack.zod.ts` — deliberately, for the declaration-size reasons recorded -there, and untouched by this change — so the assembled branch carries no field -typing. Measured against the built `.d.ts`: a plain `.manifest.version` read off -one of these two response types now yields `unknown` where it used to yield -`string`; narrowing toward the AUTHORING branch restores the whole of -`ManifestSchema` (`version: string`, `objects: string[]`), while narrowing away -from it yields `Record` — every manifest field `unknown`. In the -assignment direction the assembled branch admits any object at `manifest`, so a -garbage manifest and the mixed-stage row named above both typecheck clean even -though the runtime union refuses both. So: narrow at the point of use for the -authoring stage, and treat an assembled manifest as a record the runtime — not -the compiler — has checked. - -`@objectstack/spec/api` also gains a `browser` export condition. Declaring the -assembled stage makes this entry's module graph reach the datasource -declaration and with it the driver-config validators, whose postgres URL -refinement links `pg-connection-string` — a package whose `parse` statically -resolves `require('fs')`, so a browser bundler that reaches it fails on -`Can't resolve 'fs'`. The entry now resolves, for browser consumers only, to a -build with the pg-grammar arm swapped for its dependency-free twin: exactly the -boundary the four entries that already carry the condition use. Node resolution -and the Node bundles are unchanged, byte for byte. For browser consumers the -postgres `url` refinement degrades to the shape-only checks it already performs -before `parse` — the unix-socket short-circuit and the refusal of the -filesystem-reading `?sslcert=` / `?sslkey=` / `?sslrootcert=` query parameters -are kept; the "is this a URL `pg` can open" arm answers "no findings". Datasource -publish is a server-side act, so that arm never legitimately ran in a browser. diff --git a/.changeset/platform-admin-existing-holder-scan.md b/.changeset/platform-admin-existing-holder-scan.md deleted file mode 100644 index 18e204a702..0000000000 --- a/.changeset/platform-admin-existing-holder-scan.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -The first-boot `already_have_admin` short-circuit now FINDS an existing platform admin instead of sampling for one, so a tenant's organization-admin count can no longer decide whether a second unscoped `admin_full_access` grant is minted. - -Before this change the holders read was `sys_user_permission_set` with **no `orderBy` and a cap of 50**, and the predicate that actually decides — `!organization_id` — was applied **client-side to whatever 50 rows the driver returned first**. `admin_full_access` is not only the platform-admin set: every *organization-scoped* grant of it writes a row carrying the same `permission_set_id`, so this population grows with the number of **org** admins, not platform admins. A tenant with fifty-odd of them filled the window with rows that all fail the filter, the short-circuit did not fire, a **second** unscoped grant was minted, and `claimSeedOwnership` re-owned the seeded business records to the newly promoted user — silently, because the boot logs a successful promotion exactly as on a genuinely fresh install. Measured on the real better-sqlite3 driver: with 60 organization-scoped grants plus one unscoped human grant, the unordered 50-row window contained 50 organization-scoped rows and not the one that decides. - -That is the guarantee #14348 case D pins — 「Moving an already-granted platform admin is reserved to the maintainer.」 — failing open by row count. - -- **The read asks the driver the narrow question first.** `{ permission_set_id, organization_id: null }`, ordered and bounded. Because it is narrowed server-side, no number of organization-scoped grants can crowd the answer out of a window. -- **A second, ordered and bounded leg still applies the exact predicate.** It runs only when the narrow leg found nobody. This is deliberate rather than redundant: `organization_id: ''` is storable and reads back as `''` on both SQL families, which `!organization_id` counts as **unscoped** and `where: { organization_id: null }` does **not** return — so replacing the client-side predicate with the narrowed read alone would have made this guard fire *less* often and mint the very grant this fixes. Both legs are strictly additive to what the old read could see, so the guard can only fire more often than before, never less. -- **The bound is never silent.** The scan pages 200 rows at a time up to a 5000-row ceiling, and reaching that ceiling without finding an unscoped human holder now WARNS — naming the ceiling, the number of rows examined, and the consequence (promoting from here would mint a second unscoped grant and re-own the seeded records). -- **The answer says how many rows it examined.** `bootstrapPlatformAdmin`'s returned report gains an optional `adminGrantRowsExamined`, counted by row identity across both legs, on every return the guard reaches. A guard that had seen the whole population and one that had seen a truncated slice of it previously returned byte-identical payloads. -- **The ordering is stated to the driver, and it is measured, not assumed.** `tryFind` answers `[]` when a query is refused, and on this guard `[]` reads as "no platform admin exists yet" — which promotes. An order this object could not serve would therefore be a silent relaxation, so `id` ascending was measured honoured through ObjectQL on both SQL driver families against the real declarations. - -Unchanged: an unscoped grant held by the seed identity `usr_system` still never counts, so a database where it was wrongly promoted stays self-healing on restart; the walled postures still mint no grant row and still point a legacy unscoped holder at the config path; and a genuinely fresh install still promotes exactly as before. diff --git a/.changeset/platform-admin-promotion-selection.md b/.changeset/platform-admin-promotion-selection.md deleted file mode 100644 index 8095c9e08a..0000000000 --- a/.changeset/platform-admin-promotion-selection.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -First-boot platform-admin promotion under the `single` posture now CHOOSES its target instead of sampling one: the candidate read is ordered by the database, and an operator who declared an owner gets that owner — and only once that owner has verified the address. - -Before this change the selection read `sys_user` with **no `orderBy` and a cap of 50** and then sorted that array client-side, so "the oldest authenticable user" actually meant *the oldest authenticable user among whatever 50 rows the driver produced first*. Measured on 113 seeded users with the intended owner inserted first, holding the oldest `created_at` and an id that collates last: the in-memory driver returned it in row 1 and promoted it, while the default sqlite driver returned rows in id order, never saw it at all, and handed the unscoped `admin_full_access` grant — plus, through `claimSeedOwnership`, ownership of every seeded business record — to a seeded job-seeker persona. Same code, same config, same data; the answer changed with the storage driver. - -- **The read is ordered where the driver can see it.** `created_at` ascending with `id` as the tie-breaker (seeded populations routinely share one timestamp). There is deliberately no client-side re-sort left behind: one would re-rank the returned page and keep the guard passing if the ordering were ever lost again. -- **The declared owner is asked first, and must be a VERIFIED holder.** `OS_PLATFORM_OWNER_EMAIL` was imported into this file and read only on the walled branch, so a deployment that had said who its owner is could still have someone else promoted. Under `single` the target is now a row that holds a declared address, is human, can authenticate, and has `email_verified === true` — all four. Requiring verification rather than merely preferring it answers the one direction in which honouring the declaration would otherwise have been a widening: because `sys_user.email` is UNIQUE on the SQL family, an attacker who registers the declared address before the operator does would have been promoted with no way for the real owner to coexist, so an unverified holder is refused instead. -- **A declared owner who cannot sign in, or has not verified, REFUSES.** No silent fall-back to whoever happens to be oldest — that is the outcome this fixes. The pass warns, naming the variable, the address and which of the two is missing (`declared_owner_not_authenticable` / `declared_owner_not_verified`), and promotes nobody. **Accepted cost, stated rather than discovered:** a `single` deployment whose declared owner has not verified their email gets no platform admin at first boot until they do, loudly. Because the pass replays per sign-up while no admin exists, that warning re-emits on each replay until the owner is promotable; it is deliberately not latched, so the condition stays visible in the log a fresh operator is actually reading. -- **Verification landing is a replay trigger again.** `shouldReplayBootstrapFor` admits a `sys_user` update touching `email` / `email_verified` under `single` — but only while an owner is declared, which is the only configuration where such a write can change the answer. With none declared, the trigger set stays exactly as narrow as it was. -- **The cap is replaced, and never silent again.** A 200-row page with a 5000-row scan ceiling, walked oldest-first. Because the page is ordered it holds the rows the age rule actually wants, so truncation can only bite when every one of the oldest 5000 humans is non-authenticable — and reaching the ceiling now WARNS, naming the number examined. -- **The grant's log line records WHY and FROM HOW MANY.** `[security] first user promoted to platform admin: ` keeps its prefix and gains the basis (`declared-owner` / `oldest-authenticable`) and the candidate-pool size, repeated as `basis` / `candidatePoolSize` fields for structured sinks. The returned report carries `basis` too. - -Unchanged: no declaration still means first-user promotion by age (`single` keeps Choice 4A), and that leg has no verification requirement; a user nobody can authenticate as is still never promoted; an existing unscoped grant still short-circuits before any selection runs, so no deployment that already has an administrator can be re-pointed by this. diff --git a/.changeset/plugin-auth-admin-import-canonical-query.md b/.changeset/plugin-auth-admin-import-canonical-query.md deleted file mode 100644 index ad0377e53d..0000000000 --- a/.changeset/plugin-auth-admin-import-canonical-query.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -`runAdminImportUsers`'s hand-written `ImportProtocolLike` reads the CANONICAL QueryAST (`where` / `limit`) — the payload `@objectstack/rest`'s import runner sends as of this same release — instead of the wire-only `$filter` / `$top`. - -`POST /api/v1/auth/admin/import-users` reuses the shared import runner but swaps in an identity-specific protocol, because an identity write is `auth.api.createUser` and not an engine insert. That protocol is hand-written, so it never passes through `ObjectStackProtocolImplementation` — the normalizer that folds `$filter` onto `where` and `$top` onto `limit` for a caller arriving off the HTTP door. It has to read the canonical keys itself. - -- **A mismatch here does not produce a missing filter, it produces an unbounded one.** `const where = args?.query?.$filter ?? {}` turns an unread key into an empty filter, and an empty filter constrains nothing: the upsert duplicate probe stops discriminating, `findExisting` matches rows it was given no key for, and an admin import updates the WRONG user. Both halves are measured in `admin-import-users.test.ts` — the email-match case reported `updated: 2` where one of the two rows was new, and the phone-match case sent a probe carrying no `where` at all. -- **One dialect, and no default behind it.** The two reads are now `args.query.where` and `args.query.limit`, with no `??`. A default here would not be tolerance for an older caller — this handle is fed by the runner, never off the wire — it is precisely the lenient fallback that converts a spelling mismatch into a silent match-everything. A request that arrives without a `query` now costs a loud `TypeError` instead. - -⚠️ No published version shipped the mismatch. The runner's rewrite and this adapter land in the same release, and `@objectstack/plugin-auth` depends on `@objectstack/rest` at an exact workspace version, so the two cannot be installed apart. What this entry records is why they move together — and what the same mismatch costs any OTHER hand-written `ImportProtocolLike`, which the `@objectstack/rest` entry calls out for implementors. diff --git a/.changeset/plugin-security-read-fault-vs-empty.md b/.changeset/plugin-security-read-fault-vs-empty.md deleted file mode 100644 index 8654b2d30f..0000000000 --- a/.changeset/plugin-security-read-fault-vs-empty.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Tell a read that DID NOT ANSWER apart from a read that answered NOTHING at two boot-reconciler seams, so a transient storage fault can no longer withdraw a standing org-admin grant or report an unreadable catalog as an already-canonical one (#15840). - -`reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. One transient read fault therefore revoked a sitting admin's standing grant, and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair: nothing is granted, so nothing widens, and nothing standing is destroyed. The next `sys_member` write and the `kernel:ready` backfill ask again. - -`normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels — the same `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while the row that needed healing stayed legacy. A read that does not answer now reports at `error` and refuses the pass instead of attesting counts it could not read. The refusal aborts at the first un-answered read, so it is one line per refused boot rather than the four the report-and-continue shape measured. Its only production consumer already declared the handling: the `kernel:ready` bootstrap catches it, reports it at `warn` as non-fatal, and boot proceeds. - -⭐ Per-site, not a sweep. A genuine EMPTY read keeps today's behaviour EXACTLY at both seams — a demotion with no membership row still revokes, a membership still grants, an already-canonical catalog still answers `{ positions: 0, permissionSets: 0 }` in silence. `claim-seed-ownership.ts` is untouched: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, which is the right disposition already. The plugin's other reads keep their existing best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed. - -No exported symbol, published payload key or spec path changes: `action: 'skipped'` is already in the returned union, `reason` is already free text, and the two logger option types gain an optional `error` method a caller may omit. Healthy-path behaviour is byte-identical; only the fault path moves. diff --git a/.changeset/plugin-version-honest-grammar-claim.md b/.changeset/plugin-version-honest-grammar-claim.md deleted file mode 100644 index 3f7e059ef2..0000000000 --- a/.changeset/plugin-version-honest-grammar-claim.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`PluginSchema.version` now describes the grammar it actually enforces instead of calling itself `"Semantic Version"`. - -The key's regex accepts **every** SemVer 2.0.0-valid string and, additionally, eight strings SemVer 2.0.0 forbids: - -| SemVer 2.0.0 rule | Strings this key accepts anyway | -|---|---| -| §2 — numeric identifiers MUST NOT include leading zeroes | `01.1.1`, `1.01.1`, `1.1.01` | -| §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes | `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` | -| §10 — build-metadata identifiers MUST NOT be empty | `1.0.0+.` | - -**No accepted value moved, in either direction.** The regex is byte-for-byte what it was; the `describe()` string is what changed. The leading-zero half is older than the recent widening — the original `/^\d+\.\d+\.\d+$/` admitted `01.1.1` too, because `\d+` always has — so tightening the key to the official SemVer regex would refuse plugin objects that load today, which the ruling on this key forbids. With the accept set frozen, the only side of the declared/enforced pair still free to move is the claim, and the bare `"Semantic Version"` was the false half: it named a standard this key does not implement. - -The replacement states the shape an author can predict a verdict from — `major.minor.patch` with an optional `-prerelease` and an optional `+build` suffix — and disclaims the standard it exceeds rather than merely dropping the word. This follows `ManifestSchema.version`, which already spells `(major.minor.patch)` explicitly rather than leaning on "SemVer". - -**What consumers see.** The `description` on `version` in the shipped `json-schema/` tree and on the generated `kernel/plugin` reference page. No `pattern`, no `type`, no accepted or rejected value changes, so a tool that validates against this schema behaves identically. - -All eight forms are now pinned as **accepted** — in `packages/spec` (`plugin.test.ts`) and in `packages/core` (`plugin-loader.test.ts`, `plugin-contract-enforcement.test.ts`) — so the honesty is enforced rather than narrated, and a future edit that "corrects" the grammar to be standards-compliant fails those pins on purpose. - -`@objectstack/core` is deliberately **not** listed above. Its `PluginLoader` predicate was renamed `isValidSemanticVersion` to `isSemverShapedVersion` in the same change, for the same reason, but the symbol is `private` and package-internal: measured against the built `dist/index.d.ts`, `import { isValidSemanticVersion } from '@objectstack/core'` is TS2305 (no exported member) and `loader.isValidSemanticVersion` is TS2341 (private), while a public member on the same class compiles. Nothing published moves. diff --git a/.changeset/plugin-version-semver-grammar.md b/.changeset/plugin-version-semver-grammar.md deleted file mode 100644 index 4cd3a9306a..0000000000 --- a/.changeset/plugin-version-semver-grammar.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -`PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. - -Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: - -| Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | -|---|---|---| -| `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | -| `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | - -SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. - -**The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. - -**`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. - -**`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: - -- **`id`** — a non-string, or the empty string. -- **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. -- **`staticPath`** — a non-string. -- **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. -- **`default`** — a non-boolean. -- **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** -- **`description`** — a non-string. -- **`author`** — a non-string. -- **`homepage`** — a non-string, or a string that is not a URL. - -**`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. - -⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. - -**What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. - -**What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. - -⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. - -**Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. - - diff --git a/.changeset/preview-avg-empty-group-null.md b/.changeset/preview-avg-empty-group-null.md deleted file mode 100644 index c9c989a2d4..0000000000 --- a/.changeset/preview-avg-empty-group-null.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Draft-preview analytics: `avg` answers the mean of the NON-NULL operands, and `null` when there are none — matching every live face - -A dataset measure `{ aggregate: 'avg', field: 'amount' }` compiles to the cube -metric `{ type: 'avg', sql: 'amount' }`, and the draft-preview evaluator built -its operand list with `rows.map((r) => Number(r[field]))`. `Number(null)` is `0` -and `Number.isFinite` accepts it, so every NULL entered the average as a zero -OPERAND and was counted in the divisor. `AVG(col)` is defined over non-null -values in every SQL dialect, so a drafted chart showed a different number than -the published one, silently — and where a group's column was NULL in every row -the number it showed was `0`: a plausible-looking average that a reader cannot -tell from one somebody measured. - -Measured on one dataset, one row set, two `AnalyticsService` instances differing -only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated -SQL on a real SQLite). Rows `{meals, null}` and `{meals, null}` answered -`avg_amount` null live and `0` on preview; rows `{travel, 10}`, `{travel, 20}`, -`{travel, null}` answered 15 live and 10 on preview. Both cells now answer the -live number. - -The empty answer is READ from the platform's own ruling rather than restated -here: `emptyGroupValueFor` (`@objectstack/spec/data`) returns the identity `0` -where counting or summing nothing is a measured fact and `undefined` — spelled -`null` on this wire — where there is nothing to answer. It is the same function -`fillEmptyGroups`, `sql-driver` and `driver-turso` read, and the one #16203 cited -when it moved `min`/`max` off the same idiom in this function. - -Unchanged, and pinned by the same differential: `sum` over a group with no values -still answers the ruled identity `0`, `count` over one still answers `0` -(#16218), `min`/`max` still answer `null` (#16203), and `avg` over a group that -has values still answers its mean. `sum` and the numeric `default` arm keep their -existing operand list — `0` is the additive identity, so the coercion never moved -`sum`'s answer, and the `default` arm serves the custom-SQL metric types, which -have no live standard to be moved towards. - -The `null` fires on an EMPTY group and never on an incoherent one. "No numeric -operand" is two different situations: no row carried a value at all — the empty -group the policy rules on — or rows carried values that do not read as numbers, -such as a `date` column under `avg`. The second is an incoherent -aggregate/field-type pair that #16099 owns and no layer refuses yet; it keeps the -numeric identity it has always had, since the live face answers a different -number again (SQLite's numeric affinity over a TEXT column) and a `null` there -would invent a third answer. That boundary is pinned from both sides — by -`preview-aggregate-operand-type.test.ts` (#16203) and by a control in the new -differential. - -The live path is unchanged. - -Bumped `patch` rather than `minor`, on the same reasoning the sibling #16218 -shipped under: the package's published surface is byte-unchanged — `src/index.ts` -is not in this diff and does not re-export `preview-evaluator.ts` at all, and -`aggregate()` is module-private — and the only user-visible effect is a drafted -chart's number moving to the number the published chart already showed. A value -correcting toward the live standard is a fix, not the backwards-compatible -feature addition `minor` denotes. It is a real value change for a consumer -reading the preview response (`0` becomes blank), which is why the card was filed -separately rather than ridden along with #16203 — but the `0` it replaces was -never a number the platform promised. diff --git a/.changeset/preview-count-over-field-non-null.md b/.changeset/preview-count-over-field-non-null.md deleted file mode 100644 index 5df758e365..0000000000 --- a/.changeset/preview-count-over-field-non-null.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Draft-preview analytics: `count` over a declared field counts its non-null values, matching every live face - -A dataset measure `{ aggregate: 'count', field: 'payer' }` compiles to the cube -metric `{ type: 'count', sql: 'payer' }`, and the draft-preview evaluator carried -that field in and never read it — it answered the ROW count, nulls included, -while every SQL face lowers the same measure to `COUNT("payer")`, defined over -non-null values. A drafted chart therefore showed a different number than the -published one, silently, and the number it showed was the one `count(*)` gives: -the author's choice to count a specific column had no effect on the preview path. - -Measured on one dataset, one row set, two `AnalyticsService` instances differing -only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated -SQL on a real SQLite): rows `{meals, 'bob'}` and `{meals, null}` answered -`payer_count` 1 live and 2 on preview. Both now answer 1. - -Unchanged, and pinned by the same differential: `count` with no field and `count` -with `field: '*'` still answer the row count (the compiler writes -`sql: m.field ?? '*'`, so the star is the "no field declared" spelling), and -`count_distinct` still answers a cardinality. A group in which no row carries a -value counts `0`, never null — `emptyGroupValueFor` rules counting nothing the -identity `0`. - -The live path is unchanged. - -Bumped `patch` rather than `minor`: the package's published surface is -byte-unchanged — `src/index.ts` is not in this diff, `aggregate()` is -module-private and `evaluateAnalyticsQueryOverRows` is not on the barrel — and -the only user-visible effect is a drafted chart's number moving to the number -the published chart already showed, which is a correction toward the live -standard rather than the backwards-compatible feature addition `minor` denotes. diff --git a/.changeset/protection-block-unknown-key-refusal.md b/.changeset/protection-block-unknown-key-refusal.md deleted file mode 100644 index 0116075967..0000000000 --- a/.changeset/protection-block-unknown-key-refusal.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `protection` block's unknown-key refusal now names the surface, lists the declared keys and suggests the rename (#16845) - -`ProtectionSchema` (`shared/protection.zod.ts`) was a bare `z.object({ … }).strict()` with **no error map**, so an unknown key inside a `protection:` block was refused with zod's own default text and nothing else: - -``` -AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } }) - ✗ protection: Unrecognized key: "lockk" -``` - -`lockk` is one keystroke from the declared `lock`, and the author — human or AI, whose whole correction loop is the error text — was told the key was wrong and given no surface name, no declared-key list and no rename. The block is mounted on very nearly every authorable metadata type in the platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools, skills), so that was the message everywhere a protection key was misspelled. - -It is now built with the `strictObject` helper — the same conversion #16328 made for the manifest `permissions` block — and answers: - -``` - ✗ protection: Unrecognized key(s) on the `protection` block of this metadata item: `lockk`. - Did you mean `lockk` → `lock`? … The declared keys are `lock`, `reason` and `docsUrl`. -``` - -Curated alongside it: prose-slot aliases (`description` / `message` / `explanation` / `lockReason` → `reason`), documentation-link aliases (`docs` / `link` / `url` / `href` / `helpUrl` / `documentationUrl` → `docsUrl`), a wrong-layer prescription for the field-level `readonly` / `readOnly` booleans (which map to a `lock` *policy*, not a boolean), and one prescription for the whole private `_lock*` envelope family. Two of those aliases correct a measurably **wrong** answer: the edit-distance fallback used to point `docs` and `link` — each two edits from `lock` — at the lock policy rather than at `docsUrl`. - -**Not a breaking change: the accept set does not move.** `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a zod error map is consulted only for an issue already being raised, so it can neither admit a value that was rejected nor reject one that was accepted. Measured rather than argued — the same parse probe across the declared key set, every accepted input, and every rejection's issue `code` reads byte-identical before and after. diff --git a/.changeset/protocol-version-gap-key-rename.md b/.changeset/protocol-version-gap-key-rename.md deleted file mode 100644 index aa5a7b884b..0000000000 --- a/.changeset/protocol-version-gap-key-rename.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/cli": minor ---- - - - -feat(cli)!: the `--json` payload key `specVersionGap` is renamed to `protocolVersionGap` (#14261) - -**BREAKING** — a published machine surface changes a key name. `os validate --json` and -`os build --json` emit **`protocolVersionGap`** where they emitted `specVersionGap`. A -consumer reading `specVersionGap` reads `undefined` after this release and must switch to -the new name. There is **no alias and no dual-key transition window**: one axis, one name. - -The value shape is unchanged — `null` when the app's declared compatibility range admits -the installed `@objectstack/spec`, otherwise the same advisory record with the same -members. Nothing else on either payload moves: no other key is added, removed or -reshaped, and the text faces of both commands are byte-identical. - -## Why the name had to move - -The axis this advisory reports moved in **#13860**: it used to read the undeclared -`manifest.specVersion` and now reads `manifest.engines.protocol`, which is declared -(`PluginEnginesSchema`), stamped by every scaffold, and enforced at boot. The published -key name stayed behind for one release, deliberately — renaming a machine face with -pinned consumers is a break, and no ruling covered it at the time. - -Leaving it is a correctness problem, not untidiness. A key spelled `specVersion*` invites -the reader — an AI agent above all — to infer that a writable `manifest.specVersion` -exists. `ManifestSchema` is not `.strict()` and **silently drops unknown keys** (#14192), -so acting on that inference does not produce an error: it produces a manifest that looks -entirely normal and whose `specVersion` line never took effect. That is the same -ghost-key breadcrumb mechanism that caused #13860 in the first place, left standing on -the output side. - -## What a consumer should do - -```diff -- if (payload.specVersionGap) { … } -+ if (payload.protocolVersionGap) { … } -``` - -The breaking surface was measured before the rename and is closed inside this repository: -the only consumers of the old key were three in-repo e2e suites, which move in this same -change; **zero external consumers were found**. Graded `minor` by the maintainer's -explicit grading of 2026-09-02; the banner above carries the breaking-ness the level -cannot. diff --git a/.changeset/publish-honours-or-refuses-declared-manifest-id.md b/.changeset/publish-honours-or-refuses-declared-manifest-id.md deleted file mode 100644 index 1dc053f6da..0000000000 --- a/.changeset/publish-honours-or-refuses-declared-manifest-id.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -`os package publish` no longer publishes under a manifest id the author did not write. A `manifest.id` the artifact declares is now used or refused — never silently swapped for a derived one. - -Before this, `deriveManifestId` adopted `manifest.id` only when it parsed as `PackageSchema.manifestId`, and any other declared value fell through to `local.`. Nothing said so: the substituted id appeared in the ordinary progress line, byte-identical to the run where the artifact declared no id at all. - -``` -manifest.id = 'crm' before: → Registering package 'local.acme-crm'... (exit 0) -manifest.name = 'Acme CRM' - after: ✗ Invalid manifest-id 'crm'. … (exit 1) -``` - -`sys_package.manifest_id` is **immutable once set** — "renaming a package requires creating a new package" — so the value chosen there is a permanent, globally unique identifier. Choosing it silently, against the author's own declaration, is the one field that must not be rewritten without a word. - -- **A declared `manifest.id` reaches the existing preflight gate.** If it is not a manifest id the control plane accepts, the publish refuses before any network call, quoting the schema's own issue and description and naming where the id came from. No second rule is introduced in the CLI: the judgement is still `PackageSchema.manifestId`, which is the same schema node `CreatePackageRequestSchema.manifestId` declares for the `manifest_id` this command POSTs. -- **Honouring the declared value instead was not available.** The values that used to fall through are, by construction, exactly the ones that schema rejects, so forwarding one would only move the same refusal to the server, later and with a worse message. -- **Absent, blank and non-string `manifest.id` are unchanged** — none of those is a declaration, and each still derives from `manifest.name`, then the artifact filename. - -What to do if a publish that worked now refuses: the message names the three ways out. Fix `manifest.id` in `objectstack.config.ts` to a reverse-domain id and rebuild; remove the key to keep publishing under the derived `local.…` id (the value the previous release was already using); or pass `--manifest-id`. Every id the control plane accepts publishes with unchanged bytes. diff --git a/.changeset/quiet-pugs-tickle.md b/.changeset/quiet-pugs-tickle.md deleted file mode 100644 index c523bf123b..0000000000 --- a/.changeset/quiet-pugs-tickle.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os explain query` now teaches the two keys `QuerySchema` actually declares. - -The entry's example and its two optional-table rows named `filters` and `sort`. -Neither is a key of `BaseQuerySchema`, which is a plain `z.object` — so both -were dropped silently: an author who copied the example got a query that parsed -clean and ran with no filter and no ordering, with nothing in the output saying -so. - -Both faces now read the schema's own spellings: - -- `where` — one condition **tree**, not a `Filter[]`. A field-keyed entry is a - condition on that field (a bare value is implicit equality, an object is a map - of `$` operators), and `$and` / `$or` / `$not` combine conditions. -- `orderBy` — sort nodes, each `{ field, order }`. The direction key is spelled - `order`; `direction` is rejected by name. - -No schema changed, and no accept set moved: the correction is to the catalog -entry only. The `os explain` catalog sweep also gains a key-retention assertion -— an example must parse **and** come back with every key it declares — so the -next entry whose schema strips a key is named instead of passing. diff --git a/.changeset/raw-mount-declared-envelope.md b/.changeset/raw-mount-declared-envelope.md deleted file mode 100644 index a887ed4f7d..0000000000 --- a/.changeset/raw-mount-declared-envelope.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -**`getRawApp()` mounts now answer an escaped throw with the declared ADR-0112 envelope.** A route mounted on the Hono handle funnels through neither the adapter's `wrap()` nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler — `500 text/plain "Internal Server Error"`, no `success` flag, no `code`, and the thrown value's own declared `status` / `code` discarded. A transport error seam on the raw handle now renders the same throw-to-envelope rule a direct-mount route already used, so both doors answer one shape: a throw declaring `503` / `SERVICE_UNAVAILABLE` answers `503 application/json` with `{"success":false,"error":{"code":"SERVICE_UNAVAILABLE",…}}`, and a throw declaring no envelope still answers `500` with no cause in the body. - -The escape hatch is unchanged: consumers still mount framework-natively, still stay outside `getMountedRoutes()`, and still need no adapter verb. A thrown value carrying its own `Response` (Hono's `HTTPException`) keeps the response it declared. A consumer that installs its own `getRawApp().onError(...)` replaces the seam. - -Also fixed alongside it: `afterResponse` observers — and therefore `http_requests_total{status}` — reported a hard-coded `500` for any request that ended in a throw, which stops being the status actually sent once a declared envelope is rendered. diff --git a/.changeset/read-audit-preserve-view-instant.md b/.changeset/read-audit-preserve-view-instant.md deleted file mode 100644 index 47b7614fb9..0000000000 --- a/.changeset/read-audit-preserve-view-instant.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -fix(plugin-audit): record-view rows keep the VIEW instant instead of the buffer-drain instant (#16829) - -`sys_audit_log`'s `record_views` rows answer "when did this user look at this record?". Read auditing batches its INSERTs off the request path by design, so `buildRow` writes `created_at: event.viewedAt` rather than letting the column's `NOW()` default stamp a whole batch with one flush timestamp — up to `flushIntervalMs` after the fact, with read order inside the window destroyed. - -`persistReadAuditRows` wrote that row under `{ context: { isSystem: true } }`, and the module's comment cited that flag as what carried the view instant through. It never was. `isSystem` exempts a write from the readonly strip; the layer that decides `created_at` on an insert is the audit stamp hook `sys_stamp_audit_insert`, which reads `session.preserveAudit` and has never read `isSystem`. What was actually carrying the value was that hook's pre-#15964 line, `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. #15964 closed that accident (maintainer ruling 2026-09-06), and the ordinary branch has stamped `now` since: on this path, the flush instant. - -The write now declares both context keys, for two different layers: - -```ts -await engine.insert( - 'sys_audit_log', - rows as any, - { context: { isSystem: true, preserveAudit: true } } as any, -); -``` - -`isSystem` still carries the readonly-strip exemption the row needs; `preserveAudit` is the one the stamp hook reads. `preserveAudit` is the ruled historical-import channel (#3493, reaffirmed by #15964's ruling) — the door audit left open for reinstating an original timeline — and a view row's original timeline is the moment of the view, so this use is inside its declared purpose rather than a bypass of it. - -**What changes for a deployment.** Only for deployments that opted objects in to record-view auditing (`AuditPlugin`'s `readAudit.objects`). Rows written from now on carry the view instant. ⛔ Rows already written under the flattened behaviour are not repaired by this change: their `created_at` is the drain time of the batch they were in, and the view instant they should have carried was never persisted anywhere else, so it cannot be recovered. Only builds cut from `main` after #15964 are affected — the objectql half has not shipped in a published version. - -**No exported symbol, schema, route or config key moves.** The only observable change is that a `created_at` this writer already intended to write now survives. diff --git a/.changeset/readonly-create-side-bucket-exclusion-narrows.md b/.changeset/readonly-create-side-bucket-exclusion-narrows.md deleted file mode 100644 index a126ca48f0..0000000000 --- a/.changeset/readonly-create-side-bucket-exclusion-narrows.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/lint": minor ---- - -fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) - - - -**BREAKING** for a non-system caller that CREATES a static `readonly` column on an -object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name -outside the reserved `sys_` namespace: the forged value used to be persisted and is -now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop -reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` -under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). -That is exactly what the same caller's UPDATE of the same column already did. Shipped -as `minor` under the repo's launch-window convention. - -## The census, both halves — neither one is the whole reading - -**(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit -in the three now-judged buckets and carry a static `readonly` column between them — 64 -columns in all: - -- `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, - `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` -- `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, - `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` -- `system-data` (8 objects, 21 columns): `sys_approval_delegation`, - `sys_notification_preference`, `sys_notification_subscription`, - `sys_notification_template`, `sys_position_permission_set`, - `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` - -**And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree -carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above -included — so the namespace test, which this change does not touch, keeps all of them -exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct -non-system `engine.insert` from the metadata repository, is doubly exempt -(`engine-owned` bucket **and** `sys_`) and is pinned as such. - -⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the -population the narrowing reaches is real and named above, and an app that declares one -of those buckets on its own object gets the strip. The population on its own -understates it — not one shipped object changes behaviour on this release. What moves -is the contract for **app-authored** objects, which is the population the ruling is -about. - -## What was wrong - -`staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, -carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / -#3004 grounds: those columns have their own 403 guards, and a silent strip must not -swallow the payload the guard exists to reject. The argument is sound and the bucket -list was not. `managedBy: 'system-data'` means "platform-defined schema, -**admin/user-writable data**" by its own definition, and `object.zod.ts` says in the -same breath that it "carries no such guard; its writes are adjudicated by the -delegated-admin gate / RLS / permission sets". So the create side skipped the strip on -objects whose data is the user's, while the update side stripped them — and #14147's -"one semantics, one enforcement point" was not literally true on that population. - -## What it does now - -The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for -the three buckets whose columns really do carry a fail-closed refusal: - -| bucket | its own refusal | the create-side strip | -|:--|:--|:--| -| `engine-owned` | ADR-0103 engine-owned write guard | steps around it | -| `append-only` | ADR-0103, same guard (locked default) | steps around it | -| `better-auth` | ADR-0092 identity write guard | steps around it | -| `platform` | none — full user CRUD by default | judges it | -| `config` | none — admin-authored, writable by default | judges it | -| `system-data` | none — "admin/user-writable DATA" | judges it | - -An **unrecognised** bucket value is deliberately not read as platform-internal: the one -legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and -converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt -precisely the rows that conversion targets. The partition is pinned against -`@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing -silently on one side. - -The ruling's fallback ("leave it, if those buckets' readonly columns already carry -their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 -package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, -`drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / -`created_at` / `updated_at` stamps, which that guard does not reach. - -`@objectstack/lint` mirrors this predicate to decide which objects its create-verb -`flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, -and is narrowed in the same stroke — a lint that kept the wider exemption would go on -suppressing findings for a strip that now really happens. - -⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. -The asymmetry is closed by moving CREATE toward UPDATE. diff --git a/.changeset/readonly-insert-superseded-prose.md b/.changeset/readonly-insert-superseded-prose.md deleted file mode 100644 index b81a862b98..0000000000 --- a/.changeset/readonly-insert-superseded-prose.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/objectql": patch -"@objectstack/rest": patch ---- - -Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. - -The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. - -No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. diff --git a/.changeset/repeater-item-schema-titles-class-guard.md b/.changeset/repeater-item-schema-titles-class-guard.md deleted file mode 100644 index 9e8f0e6322..0000000000 --- a/.changeset/repeater-item-schema-titles-class-guard.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a repeater's property-panel table has column NAMES, and an untitled item schema is now loud (#17232) - -## What was wrong - -Studio renders a `type: 'repeater'` form field as a table whose column headers -read `items.properties[k].title ?? k` off the JSON Schema served by -`GET /meta/types` — derived by `packages/metadata-protocol`'s `toJsonSchemaSafe`, -i.e. `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`. -The bundle overlay `resolveMetadataFormSchemaTitles` (#16458 / PR #17227) only -replaces a title that is already there, so an item schema carrying no -`.meta({ title })` falls through to the raw machine key — in **every** locale, -English included. The maker read `actionUrl`, `defaultCollapsed`, `dateGranularity` -inside an otherwise fully translated panel. This is a missing authoring label in -the contract, not a translation gap. - -PR #17227 titled exactly one repeater, `dashboard.header.actions`, and was scoped -by dispatch to that one. **The class stayed silent**: the next repeater to land -would reproduce the defect with every gate green. - -## Measured on `origin/main` at `e758131b39` - -22 repeater fields are declared across 11 `*.form.ts` files. Derived through the -platform's own predicate rather than a source regex: - -- **1** was fully titled — `dashboard.header.actions`, PR #17227's instance. -- **1** has no object row shape at all — `action.locations` is an array of enum - STRINGS, so it renders no column headers and leaks no key. It is **not** a - carrier, which is why the class is **20** untitled tables today and not the 21 - the card premised. -- **20** were untitled. - -## What changed - -**Thirteen carriers are now titled** — every row property of `action.params`, -`app.areas`, `dataset.dimensions`, `dataset.measures`, `flow.nodes`, -`flow.edges`, `flow.variables`, `page.variables`, `page.regions`, -`page.interfaceConfig.sort`, `report.order`, `report.blocks` and -`skill.triggerConditions` carries a `.meta({ title })`. `page.interfaceConfig.sort` -is titled through the shared `SortItemSchema` it composes. - -**The silence is closed.** `packages/spec/src/kernel/repeater-item-titles.test.ts` -enumerates every repeater declared across every `*.form.ts` in the package, -derives each row schema through `z.toJSONSchema`, and requires a title on every -authorable row property. Carriers still owed one sit in an EXACT, shrink-only -ledger: a repeater absent from the ledger must be fully titled, and a ledger -entry whose debt has been paid must be deleted. A new repeater is therefore red -on the day it lands, and the ledger can only shrink. - -Two exclusions the pin makes deliberately, each with its own control: - -- a `retiredKey()` tombstone is a parse-time refusal, not an authorable column - (`flow.nodes[].outputSchema`); -- a scalar-item repeater has no row properties to name (`action.locations`), - and is pinned by name so an object-shaped one cannot land there silently. - -## What is still owed, and why - -Seven carriers remain on the ledger because their item schemas live in files held -by other in-flight PRs at the time of writing — `dashboard.widgets` and -`dashboard.globalFilters` (`ui/dashboard.zod.ts`), `view.columns` / `view.sort` / -`view.tabs` (`ui/view.zod.ts`), and `field.options` + `object.fields.options` -(the one `SelectOptionSchema` in `data/field.zod.ts`). The pin OBSERVES them -without editing them, so the ledger states the whole class rather than the slice -one PR could reach. - -Localisation is additive and unchanged by this round. `.meta({ title })` is the -English authoring layer by contract — `translation.zod.ts` states it in those -words — and a bundle's `metadataForms..fields...label` -overlays it per locale. No form file here enumerates repeater children, so -`os i18n extract` emits no new catalog keys and no catalog moves. Until those -leaves are authored, a non-English panel shows the English title rather than the -machine key — strictly better than today, and the localisation layer is still owed. diff --git a/.changeset/reserved-identity-name-position-guard.md b/.changeset/reserved-identity-name-position-guard.md deleted file mode 100644 index 1e2ca18ee3..0000000000 --- a/.changeset/reserved-identity-name-position-guard.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972) - -`sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — `platform_admin`, `org_owner`, `org_admin`, `org_member`. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant: an out-of-repo consumer that reads the NAME instead of the capability rung reopens the hole with nothing mechanical to catch it. - -Both declarations now carry an object-level `validations[]` rule whose CEL list literal is **generated** from `BUILTIN_IDENTITY_NAMES`, the `@objectstack/spec` constant that declares the identities. The set is a closed enumeration — imported, never retyped, and never widened to an `org_*` pattern, so an ordinary tenant position named `org_manager` still writes. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by one refusal carrying one code (`VALIDATION_FAILED`). - -Two doors, two shapes, for a reason: - -- **`sys_position`** exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling). `bootstrapBuiltinRoles` seeds exactly these four names per organization on purpose, and that catalog is unaffected. A `package`- or tenant-authored row is refused. -- **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity. - -Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export. - -Housekeeping this change drags along, disclosed because a reviewer should not have to discover it: a validation rule's `name` is snake_case by contract, and `scripts/tenant-audit-census.mjs` counts every snake_case `name:` literal in a `*.object.ts` as a "declared object" (it already counts the four `actions[]` names on `sys_position`, so that figure was never a count of objects). The two new rule names move it 298 → 300, so the census artefacts are regenerated with the script's own `--write`. That block regenerates **whole**, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 → 562 and `engine-shaped types recognised` 59 → 58 — which are drift accumulated since the block was last measured at `9cefca9a3`. diff --git a/.changeset/retire-adr-0030-notification-event-migration.md b/.changeset/retire-adr-0030-notification-event-migration.md deleted file mode 100644 index 2ae652749b..0000000000 --- a/.changeset/retire-adr-0030-notification-event-migration.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/metadata': minor -'@objectstack/spec': minor ---- - -**BREAKING** — retire the `adr-0030-notification-event` data migration. - -`migrateSysNotificationToEvent` had no way to be run: zero production callers -anywhere in the repo, and no `os migrate` sub-command, while the two sibling -members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel -export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's -pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed -together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform -on this line. - -## What is gone, and what an upgrader does about it - -⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to -adopt — every item below is a deletion, and the fix is to stop using it. - -- `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. - No replacement exists, and none is coming: an `os migrate notification-event` - sub-command was considered and refused. Delete the call. The compiler delivers - this one: the import fails to resolve. -- `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and - `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They - described that runner's own result, options and receipt and nothing else. -- `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a - three-member tuple and is now a two-member one holding - `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids - keep their sub-commands, their receipt rows and their birth attestation; only - the notification id left. Code typed against - `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification - id no longer compiles — delete that arm. - -`NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A -deployment attested at birth, or one that made the operator call while the runner -shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, -and the constant is that row's name. Nothing writes or reads a row under it any -more — `attestFreshDatastore` no longer includes it — and it is not a -registration: it gates nothing and never did. - -## Reversal path - -Two answers were considered and both refused: an `os migrate notification-event` -sub-command is a permanent operator surface for a migration with no measured -demand, and a boot-time invoker is an unattended data rewrite nobody asked for. -⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 -`sys_notification` rows. If a **named** deployment turns out to hold rows it -needs, the migration returns as an operator-runnable sub-command shaped exactly -like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, -documented consequence — under its own card. - - diff --git a/.changeset/retire-list-view-page-mount.md b/.changeset/retire-list-view-page-mount.md deleted file mode 100644 index 07f1fb2c30..0000000000 --- a/.changeset/retire-list-view-page-mount.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor -'@objectstack/metadata-protocol': minor ---- - -**BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. - -A list view could declare `type: 'page'` and name a published page in `pageName`, -and the view was to render nothing of its own and delegate to the page renderer. -Only the spec half of that was ever built. **No renderer ever routed the member**: -objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page -view has always drawn an empty table where the page was supposed to be, and the -three parse refusals that policed the binding policed a mount that never mounted -anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | -| `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | -| a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | - -**The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put -the page behind an app navigation item, which is a different key on a different -surface (`PageNavItem.pageName`) and is the page mount that has always rendered. - -`os migrate meta --from 17` lists the mechanical edits for existing sources; apply -them by hand. - -## The retirement kit - -- **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and - `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse - raises the prescription rather than a bare unrecognized-key report. -- **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on - (the def survives, one value lighter, and the four generated-surface ratchets are - blind to that by construction). The `type` enum's own `error` map carries it, - keyed on `issue.input` so only the value that used to be legal gets the - "was removed" message; every other invalid `type` keeps zod's default text. -- **`checkListViewPageMount`** — the exported object-level refinement existed only - to police this mount, so it is removed with it, along with its three refusal - messages. A downstream mirror that re-attached it (the reason it was exported) - should drop the `.superRefine` line; the compiler delivers this one. It held no - `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. -- **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the - `os validate` and publish-gate rule that resolved a mount against `stack.pages`. - Removed: there is no reference left to resolve. Its nav twin - (`validateNavTargetRefs`, on the app navigation item) is **untouched**. -- **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of - `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page - universe joined the per-write snapshot for that one rule, and leaves with it. A - `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a - collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / - `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. -- **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's - `pageName` against `stack.pages` is gone. The surviving three page references in - that function (an app nav item's `pageName`, a modal action's `target` at two - rungs) keep their own policy. -- **The metadata form** — `view.form.ts`'s `page` section, whose one input was - `pageName`, is removed. A form input for an unwritable key is the false-compliant - UI half of a retirement. - -## What an operator with a STORED page view sees - -A `sys_metadata` `view` row written before this release can carry `type: 'page'` and -a `pageName`. Nothing breaks at read: the ADR-0087 conversion -`view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, -so the row is served canonical. `type` is **stripped, not rewritten** — it defaults -to `grid` in the schema, so the row lands on exactly what it already rendered -without the platform guessing a view type. - -The strip is announced once per row per process, on whichever seam served it. -Grep for `carries a pre-protocol shape` — there are **three** emitters, one per -rehydration seam, and they differ: - -- `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` -- `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` -- `[Protocol] stored view/ carries a pre-protocol shape; The row - itself is unchanged — re-save it (Studio edit -> save, or run - "os migrate meta --stored --apply") to persist the canonical shape.` - -`os migrate meta --from 17` lists the same edits for authored sources; -`os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and -the next save through `PUT /api/v1/meta/view` heals one row the way it heals any -pre-protocol shape. - -⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does -**not** reach `objects[].listViews.*`, which no conversion in the registry reaches. -An object body still carrying a page mount is refused at its own door with the -prescription rather than converted. Measured population for both at the ruling: -**zero** authored `type: 'page'` list views in this repository or any consuming app -the seats can read — the in-tree `type: 'page'` hits are all app nav items. - - diff --git a/.changeset/retired-permission-bits-parse-time-accept-set.md b/.changeset/retired-permission-bits-parse-time-accept-set.md deleted file mode 100644 index e787cf9bb4..0000000000 --- a/.changeset/retired-permission-bits-parse-time-accept-set.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): state the retired `allowRestore` / `allowPurge` parse-time accept set exactly (#17425) - -Documentation only — no schema, no key, no exported symbol and no accepted value moves. What changes is what the tombstone's own prose claims about itself, in the three places a consumer reads it: the `permission.zod.ts` docblocks (published in the tarball, both as `dist/*.d.ts` and as the `src/**/*.zod.ts` sources this package ships), and the two hand-written permission docs pages. - -The prose said the retired bits are refused, and separately that "every other value" lands on the tombstone. Read together those two sentences describe a truthy/falsy split, and that is not what the schema does. Measured on this tree, `ObjectPermissionSchema` tolerates exactly ONE value: the boolean literal `false` the published 17.x toolchain materialized into every permission entry of every artifact it built, accepted as inert residue and silently stripped under the retired-defaulted-key class rule. Every other value of any type — including the string `"false"`, the number `0` and `null` — is refused exactly like `true`, with `code: 'invalid_type'`, `expected: 'never'` and the same guidance string, at the key's own path. - -The consequence consumers were missing is now stated with it: a successfully parsed permission entry can carry neither key on any input that came from JSON, so a post-parse guard against either bit is dead code — presence, truthiness and `=== true` alike can never be true on validated data. A `false`-versus-other distinction is observable only to pre-parse tooling reading raw sources, where the retired default is inert legacy residue and any other value is a hard ADR-0049 violation. - -One measured exception is documented and pinned, because it is the only post-parse observation that survives: an in-memory TypeScript input carrying an explicit `undefined` for either key parses and keeps the key as an own property whose value is `undefined`, so a presence check can be true there. JSON cannot spell it, and a serialize round-trip drops it again. - - diff --git a/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md b/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md deleted file mode 100644 index 2ddc533207..0000000000 --- a/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): resolve `current_user.accessible_org_ids` into the RLS variable bag (#16518) - -`patch` — a bug fix in a released package. No API signature changes, no exported -symbol added, no spec or ADR edit: the contract already promised this, and only -the line that delivers it was missing. - -## What was wrong - -`packages/spec/src/contracts/rls-membership-resolver.ts` does not merely reserve -the name `accessible_org_ids`. It declares the field's SHAPE (`:53`, -`accessible_org_ids?: string[]`), states at `:35` that the key is CORE-resolved -and not an app resolver, and lists it at `:70` in -`RESERVED_RLS_MEMBERSHIP_KEYS` — so an app's membership resolver is refused when -it tries to supply the set itself. `ExecutionContext.accessible_org_ids` goes -further and names the RLS spelling outright: *"RLS policies may reference it as -`organization_id IN (current_user.accessible_org_ids)`"*. - -`RLSUserContext` declared `id`, `organization_id`, `positions`, `org_user_ids` -and `email`, and nothing copied `accessible_org_ids` out of the execution -context. So the key was reserved on the grounds that core resolves it, and core -did not resolve it — a slot with a declared shape and no filler, which is the -ADR-0049 "declared but unenforced" shape. - -**The cost is the invisible one.** A predicate such as -`employer_org IN (current_user.accessible_org_ids)` compiled to an unresolved -variable, every applicable policy dropped out, and `RLS_DENY_FILTER` returned -**zero rows with no error raised**. Nothing failed. An empty list is -indistinguishable from "this user really has no data", which is how the shape -survived three green static gates and, in the reporting app, left ten policies -across six objects inert — the entire multi-tenant isolation model. - -The failure direction is **closed**: zero rows, never a cross-tenant read. This -is a usability and declared-means-enforced defect on a security surface, not a -leak. - -## What it does now - -`RLSCompiler.compileFilter` copies `ExecutionContext.accessible_org_ids` into -`RLSUserContext`, following `org_user_ids`' precedent exactly — both are -core-resolved membership sets the runtime **pre-resolves**, precisely so this -compiler never has to issue a subquery. The compiler is unchanged otherwise; it -already handled the value correctly once present. - -The producer already existed and is unconditional: `resolve-authz-context.ts` -types the set as required and `assemble-execution-context.ts` copies it on every -face, in every posture (*"in `single` posture the set is resolved but no wall -consumes it"*). Only the consuming line was missing. - -One consequence worth naming: **reserved now means reserved at the compiler -too.** `stageRlsMembership` screens reserved keys out of a *resolver's* answer, -but a bag already present on the context was spread through unscreened, and -landed in the variable bag because nothing named the field. Now that the kernel -names it, the compiler's own "a membership key never clobbers a named field" -rule covers it and the kernel's value wins. - -## Measured, end to end - -A rig on real drivers (`driver-sql`, `driver-sqlite-wasm`), six rows across -three organizations, a caller holding membership in two of them: - -| predicate | before | after | -|:--|--:|--:| -| `employer_org IN (current_user.accessible_org_ids)` | **0 of 6** | **4 of 6** — the rows of both orgs | -| same, caller scoped to ONE org | 0 of 6 | 2 of 6 — that org only | -| same, caller with no set / an empty set / an org with no rows | 0 of 6 | 0 of 6 — unchanged, still fails closed | -| a predicate naming a NON-EXISTENT variable | 0 of 6 | 0 of 6 — unchanged (#16119's face, untouched) | -| `org_user_ids`, `organization_id`, `email`, `id`, an app membership key | — | byte-identical | - -An app **could** work around the defect by supplying the same set under its own -unreserved key through `rlsMembership` and rewriting its predicates to -`current_user.my_org_ids`; that reads 4 of 6 on the same rig, before and after. -The workaround costs every app a membership-resolver registration it should not -need and moves every predicate off the documented spelling — and it is no longer -necessary. diff --git a/.changeset/rls-predicate-references.md b/.changeset/rls-predicate-references.md deleted file mode 100644 index feb36afed1..0000000000 --- a/.changeset/rls-predicate-references.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -Two new gating rules — `rls-predicate-unknown-field` and `rls-predicate-unknown-user-variable`: an RLS predicate that lowers correctly but names a column the object does not declare, or a `current_user.*` value nothing pre-resolves, is now an authoring-time `error`. - -The three shipped `rls-predicate-*` rules judge a predicate's **shape** — does it parse, does it lower, does it fit the platform's CEL bounds. Nothing judged what it **points at**. Measured as four injections at one site, in one run: `billing_address.country == "US"` reported `rls-predicate-unenforceable` and `is_private == = false` reported `rls-predicate-unparseable`, while `is_private_nope == false || owner_id == current_user.id` and `is_private == false || owner_id == current_user.nope` reported **nothing at all** — from the same site the linter had just reported twice. - -Both silent shapes are expensive rather than cosmetic, and they do **not** fail in the same direction — which is the part the card's own measurement did not reach. - -An unresolved `current_user.*` is refused by the pushdown compiler in **every** position, including under `!` and in a trailing `||` arm, so that half always fails **closed**: `RLSCompiler` drops the policy, the layer falls back to the `RLS_DENY_FILTER` sentinel, and the object disappears for every holder of the permission set — not because they were denied but because the narrowing they were granted resolves to nothing. - -An unknown **field** takes its direction from **position**, and one of the two is fail-**open**. `SecurityPlugin`'s field-existence safety net recognises only a *leading* `field ==` / `=` / `in` (`extractTargetField` is that shape match), so a miss there drops the policy and arms the deny sentinel — zero rows. A miss the net does not recognise — a negation (`nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])`) or any arm after the first — leaves the policy **kept**, and the phantom column lowers to a negated constraint that a row without that column *satisfies* (`noValueSatisfiesNegation`: `$ne` / `$nin` / `$notContains`). The authored narrowing is then **defeated rather than enforced**: measured at 3 of 3 rows, against 1 of 3 for the real narrowing and 0 of 3 for the same phantom column in a positive position, on the read path and on the write path's `matchesFilterCondition` alike. - -⛔ That is **not** a cross-tenant leak — tenancy is a separate layer and it holds; what is defeated is the narrowing authored inside the wall. Measured on driver-memory; driver-mongodb follows the same shared ruling; **driver-sql is NOT MEASURED** and is expected to fail closed by raising `no such column`. The runtime repair is tracked separately as #17042 and is deliberately not attempted here — these rules report the miss, in both directions, and the diagnostic says which direction applies so an author is not told "this denies everything" about a predicate that in fact matches everything. - -- **Two rules beside the three, not a widening of them.** The existing ids say *unenforceable* / *unparseable* / *over-budget* and are correct inside that scope; they are untouched, and the two controls above still report under them and under neither new id. The prescriptions differ (rewrite the predicate / fix the column name / pre-resolve the variable), and an author who suppresses one must not thereby suppress the other. The guards are disjoint by construction: the reference pass runs only where `isSupportedRlsExpression` has already said yes. -- **Where the existence answer comes from.** Field paths are read off the pushdown compiler's **own output** — the lowered `FilterCondition`'s keys are the columns the driver will be handed — and resolved through `object-graph.ts`, the shared index every field-existence rule in this package already uses. No new input path, no second parse of the predicate. The rule therefore inherits that module's three skips, each the difference between a finding and a false one: an object this stack does not define, an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at`, which are real at runtime and appear in no authored `fields`. -- **The `current_user` set is derived, not transcribed.** It is `RESERVED_RLS_MEMBERSHIP_KEYS` from `@objectstack/spec/contracts` — the keys an `IRlsMembershipResolver` may never supply *because the kernel already owns them*. A key added there stops being reported the same day, with no edit in this package. -- **§7.3.1 membership keys are left alone, and that boundary is the reason this rule can exist.** An app stages arbitrary sets into `ExecutionContext.rlsMembership` and references them as `field in current_user.`; the spec documents the pattern and `rls-predicate-unparseable`'s own hint recommends it. In an `in` position an unknown key is indistinguishable from a correct one and is never reported. It is decidable in the other positions only because the merge is array-only — the sole value an app-staged key can ever hold is an array, which a scalar position cannot use on any request — so `owner_id == current_user.nope` is refused while `assigned_to_id in current_user.team_member_ids` stays silent. A key used in both positions takes the membership answer. - -**What moves for consumers.** A stack whose RLS predicate names a renamed column or an un-pre-resolved context value built clean before and now fails `os validate` / `os lint` / `os compile`. That is the point — the policy had already stopped doing what it was written to do, denying the whole object in one position and granting every row in the other. - -A stack whose predicates all resolve is byte-identically clean. The reading is the shipped showcase: 3 RLS clauses, all 3 judgeable against declared objects, **zero** findings — with three firing controls at the real site (an injected dangling column, an injected unknown variable, and an injected fail-open negation shape each produce exactly one finding) and two nonsense controls (an injected membership test against an unknown key, and a real-field/real-variable predicate, stay silent). `plugin-security`'s seed sets and hotcrm's built-permissions fixture also emit zero, but ⛔ **those two are not readings**: every policy target in the seeds is an object that package does not declare, and the hotcrm fixture carries no `objects` key at all, so all 71 and all 4 clauses respectively are skipped by construction. Declaring one of their objects makes the fixture report 2 — which is what a control is for. diff --git a/.changeset/rls-reserved-membership-keys-refused-by-name.md b/.changeset/rls-reserved-membership-keys-refused-by-name.md deleted file mode 100644 index 07cd55324b..0000000000 --- a/.changeset/rls-reserved-membership-keys-refused-by-name.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -security(rls): the RLS compiler refuses `RESERVED_RLS_MEMBERSHIP_KEYS` by name - -A caller-supplied `ExecutionContext.rlsMembership` entry could supply a RESERVED -kernel key — `id`, `organization_id`, `positions`, `org_user_ids`, -`accessible_org_ids`, `email` — whenever the kernel had not resolved a value for -that key on the request. `RLSCompiler.compileFilter` admitted a membership key on -the test `userCtx[key] === undefined` ("did the kernel happen to resolve one"), -not on whether the key is reserved, so an absent kernel value handed the name to -the bag. - -The direction was widening. With the key unresolved, the predicate referencing it -fails CLOSED — it joins the dropped-policy path and the compile returns the deny -sentinel, which yields zero rows. The bag instead produced a satisfiable filter -over caller-chosen values, converting a denial into a match. - -The merge now refuses reserved keys by name, at the one seam both faces pass -through (the read layer compiles `using` there, the ADR-0058 D4 write gate -compiles `check` there). `stageRlsMembership`'s existing screen covers only the -registered resolver's answer, and only when a resolver is registered at all — it -returns at its first line otherwise — so it could not carry this guarantee. - -No behaviour change for non-reserved membership keys, and none when the kernel -did resolve the reserved value: the kernel's value already won, and still does. -A refused key simply stays unresolved, so its policies drop out and fail closed -through the reason vocabulary that already exists. diff --git a/.changeset/rls-undeclared-column-denies-in-every-position.md b/.changeset/rls-undeclared-column-denies-in-every-position.md deleted file mode 100644 index 3f6752cbab..0000000000 --- a/.changeset/rls-undeclared-column-denies-in-every-position.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/lint": patch ---- - -fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) - - - -**BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. - -A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. - -⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". - -Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): - -- **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. -- **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. - -**The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. - -⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. - -**Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. - -**driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. - -`@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. diff --git a/.changeset/rollup-non-numeric-aggregand.md b/.changeset/rollup-non-numeric-aggregand.md deleted file mode 100644 index 1673c5baed..0000000000 --- a/.changeset/rollup-non-numeric-aggregand.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`os lint` now refuses a `min`/`max` roll-up whose answer cannot be stored in the column it rolls up into — `rollup/non-numeric-aggregand`, at `error`. - -`FieldSchema.summaryOperations` admits `min`/`max` over ANY child field, and the engine's `aggregateSummaryValue` returns the driver's answer verbatim (only an empty-set fallback stands between the backend and the stored value). A `summary` field is a member of the spec's `NUMERIC_VALUE_TYPES`, so `valueSchemaFor` answers `z.number().finite()` for it and `driver-sql`'s `createColumn` emits a float column. An ordinary "latest shipment" roll-up — `max` over a `datetime` child field — therefore computes an instant into a column the value contract says holds a finite number, and nothing between author and driver correlated the two. It is refused at authoring time rather than tolerated in a consumer (Prime Directive #12). - -- **The accept set** is the numeric class union the boolean class, read from `NUMERIC_VALUE_TYPES` and `BOOLEAN_VALUE_TYPES` rather than typed out. The first is the set that DEFINES the criterion — it is the membership `valueSchemaFor` consults to answer `z.number().finite()`, so a type joining it moves the value contract and this door together. The second is admitted on the authority of the `min(flag)=0` / `max(flag)=1` ruling pinned by the spec's own `AGGREGATION_CASES` (#11152): the answer is a number, so it fits. -- **It is NOT `isAggregateCompatibleWithFieldType`.** That table deliberately accepts `min`/`max` over the temporal class, because there the answer is returned to a caller and "return[s] a value of the field's OWN type" (#15768). Reusing it here would accept the very declaration this rule exists to refuse. The two questions look alike and are not — "can every backend give one answer" versus "does that answer fit the column this roll-up is stored into" — so this predicate is that table's `min`/`max` row narrowed by exactly the temporal class, and a test pins the disagreement. -- **Scope.** `min`/`max` only. `count` reads no value off the field; `sum`/`avg` over a non-numeric child is a different shape, whose accept set the aggregate table's own rows already exclude, and is not widened into here. -- **Silent where it cannot resolve.** An unknown child object, a field the child does not declare, or a field with no declared type produce no finding — the aggregate table's own consumer tier ("a consumer that cannot resolve a field's type must NOT call the predicate with a guess"). A partially-loaded model cannot draw a false refusal. - -No export moves: the rule id is an inline literal inside the already-exported `lintDataModel`, beside `rollup/missing-summary`. Measured across this repository, no declaration trips the new refusal — all three `min`/`max` roll-ups aggregate a `number` child field — so this adds a door rather than migrating anything. diff --git a/.changeset/runtime-gate-overlay-redefinition-universe.md b/.changeset/runtime-gate-overlay-redefinition-universe.md deleted file mode 100644 index 317d41c578..0000000000 --- a/.changeset/runtime-gate-overlay-redefinition-universe.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -fix(metadata-protocol): the runtime authoring gate judges an OVERRIDDEN item from the body the runtime serves (#16224) - -The #4463 runtime authoring gate resolves references against the live metadata universe, and since #15950 it gathers that universe from BOTH homes — the `SchemaRegistry` and `sys_metadata`. That fold was **additive**: a stored row contributed a name the registry did not carry and never displaced a registry entry. Where an org or env-wide overlay REDEFINES an item a code package already declares, the gate therefore judged that item's CONTENT from the registry's copy — a body the runtime had already stopped serving. - -Measured end to end, in one process and one instant. A code package ships `dataset/D` with measure `m`; an env-wide overlay redefines `D` without it: - -- a dashboard widget bound to `values: ['m']` was **accepted**, and the runtime cannot serve it; -- a widget bound to the measure the overlay DOES declare was **refused** `422 widget-measure-unknown`, and the runtime can. - -One cause, both directions: an acceptance that should have been a refusal and a refusal that should have been an acceptance. - -The hand-rolled additive merge is replaced by `mergePackageAwareOverlay` with `foldObjectExtendersFromRegistry` as its transform — the merge, and the transform, that `getMetaItems` (the read API behind `GET /meta/:type`) already runs. The gate's universe is now the universe the platform answers reads from, by construction rather than by agreement, and ADR-0048 package slotting arrives with it: an overlay shadows the entry it actually overrides, and two installed packages shipping one `type/name` remain two entries. - -**#15950's resolved-vs-base distinction is kept by folding, not by declining.** Its argument was never "an overlay must not win" but "an UNRESOLVED body must not win" — the registry's copy of an object is its RESOLVED schema (ADR-0029 D9.2: base layer plus its `extend` contributors), a `sys_metadata` row is the base layer alone — and it names its own remedy, which is what `getMetaItems` does to its winner. Pinned: an `object` overlay wins on its own columns AND keeps the registry's `extend` contributors. For a name the registry does not carry the result is byte-for-byte #15950's additive contribution, pinned in the same process. - -Graded `minor` rather than `patch`: `PUT /meta/:type` is a published verb and this narrows its accept set. An `active` publish that names a reference the overlay removed now answers `422 INVALID_METADATA` where it answered `200` — one legal published answer replaced by another, not the repair of a value the schema already refused. The write it now refuses is one the runtime could never serve; the write it now accepts is one the runtime always could. diff --git a/.changeset/s3-adapter-key-namespace.md b/.changeset/s3-adapter-key-namespace.md deleted file mode 100644 index 66f679c9a3..0000000000 --- a/.changeset/s3-adapter-key-namespace.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/service-storage": minor ---- - -**Clause-②: yes** — a new REQUIRED member on two published option types (`S3StorageAdapterOptions.keyPrefix`, and the `s3` member of `StorageServicePluginOptions`), so the accept set a consumer writes against narrows. Contract-review tier. - -**BREAKING** — `S3StorageAdapterOptions` and `StorageServicePluginOptions.s3` now require `keyPrefix: string | null`. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. - -The **S3 adapter can now be confined to a key namespace**, and the confinement is structural rather than conventional: a caller holding the adapter has no door through which it can reach an unprefixed key. - -`keyPrefix` is applied on `upload` / `download` / `delete` / `exists` / `getInfo`, on both presigned doors, on every multipart door, and into `list()`'s `Prefix` — and it is stripped off every key and every `list()` cursor coming back. Callers therefore supply and receive unprefixed keys at every door, in both directions, and `list('')` enumerates this adapter's namespace and nothing else. Keys are concatenated, never path-joined, so a caller key such as `../elsewhere` stays a literal key inside the namespace instead of escaping it. - -**Why it is required rather than optional.** A shared bucket with no namespace has one thing keeping one deployment out of another's objects: that every `sys_file` metadata check above the adapter was written correctly. On a route that takes an identifier out of a request, one missed check is a cross-deployment read the object store cannot refuse, because what it sees is a well-formed key. An optional prefix reproduces exactly that gap the first time a host forgets to set it, silently — so the choice is made at the call site or the code does not compile. `null` is the written, greppable way to ask for bucket-root keys, and it produces byte-identical keys to those written before this option existed. - -For the same reason an empty or whitespace-only string is **refused at construction** rather than treated as "no prefix": that is what an unset environment variable looks like after interpolation. A leading `/` and any `..` segment are refused too, and a missing trailing `/` is appended — the last of those is load-bearing, not tidiness: S3 `Prefix` is a raw string match, so `tenant_1` without the delimiter also matches `tenant_10/...`, and one namespace would enumerate its neighbour through the isolation mechanism itself. - -Two further seams move with it: - -- `StorageServicePlugin` carries the **host's** namespace onto every adapter a `storage` settings re-read rebuilds, and deliberately reads no prefix out of the settings values. A boundary an administrator inside the deployment can set or clear is a preference, not a boundary; without this, one settings save returned a hosted deployment to a shared, unprefixed key space. A host that declared no `s3` constructor options expressed no namespace, and settings-configured S3 stays bucket-root as before. -- `resolveStorageTarget` puts the namespace in the target's **`location`**, not merely its fingerprint: two prefixes in one bucket are two disjoint object sets, so moving the prefix strands what the old one held exactly as moving the bucket does, and the swap must print the migration warning. `env_7` and `env_7/` normalise to one target, so the same namespace spelled two ways is not read as a move. - -`LocalStorageAdapterOptions` is deliberately unchanged: `resolvePath()` already refuses any `..` and joins every key under `rootDir`, so the local adapter's containment boundary exists and a second mechanism would be two ways to say one thing. - -**Migrating:** every `new S3StorageAdapter({ ... })` and every `new StorageServicePlugin({ adapter: 's3', s3: { ... } })` gains one member. Single-tenant deployments write `keyPrefix: null` and their keys do not move. Deployments sharing a bucket write the namespace they want and should treat the change as a store move — existing objects are not migrated into the new namespace. - - diff --git a/.changeset/sandbox-crash-outranks-declared-code-arm.md b/.changeset/sandbox-crash-outranks-declared-code-arm.md deleted file mode 100644 index 4ecad6d3f4..0000000000 --- a/.changeset/sandbox-crash-outranks-declared-code-arm.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest): a hook that crashes after declaring a code now answers 500 UNCLASSIFIED_FAULT instead of the declared status with the crash text (#15071) - - - -**BREAKING** — the answer this published door gives moves for existing inputs. -No export, signature or declared type changes; what changes is the response an -existing call observes, and a client branching on `error.code` for the affected -shape now falls to its 5xx path instead of its refusal path. Shipped as `minor` -under the launch-window convention (`major` is refused while the fixed group -versions in lockstep), so this banner — not the level — is the breaking-ness -signal. - -**What changes for an operator.** A sandboxed hook or action body that declared a -refusal code and then CRASHED — `throw`-ing nothing, but hitting a bug on a later -line — used to answer the single-record `/api/v1/data` routes with the code's own -business status and the QuickJS debug sentence as the client-facing message, for -example `409 DELETE_RESTRICTED · "hook 'guard' threw: TypeError: x is not a -function"`. It now answers `500 UNCLASSIFIED_FAULT` with the sanitised message -and no crash text, which is what the same crash carrying no declared code has -always answered. The full wrapper still reaches the server log through the -existing `[REST] Unhandled error` / withheld-fault path, so nothing an operator -diagnoses with is lost. - -**What does NOT change.** An ordinary declared refusal — a hook that throws a -business error carrying a code and does not crash — is untouched: same status, -same code, same sentence, same structured fields. So is every non-sandbox -producer of those codes, and so is the `developerMessage` channel, which keeps -the rule it already had for a fault. - -**Why.** A declared code is the author's statement about the failure mode they -handled; a crash is not that mode. Answering one with a business status shipped -an internal, stack-shaped sentence to an end user and told the client the wrong -thing about what happened, while the door one branch down already sanitised the -identical crash. Maintainer ruling, 2026-09-04, decision batch #27, on #15071. - -**If you were relying on the old answer,** the affected shape is a hook that -declares one of the classification's ten code-gated refusals and then faults: it -now surfaces as a 5xx to clients and retry policies rather than as a 4xx. That is -the point of the change — the crash was never the refusal the code named. diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md deleted file mode 100644 index e64819ac1b..0000000000 --- a/.changeset/schedule-trigger-acting-organization.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/trigger-schedule": minor -"@objectstack/lint": minor ---- - -fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) - - - -**Registered as an ADR-0087 semantic migration** -(`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable -is renamed, retired or re-typed — no `packages/spec` key changes its name, its -type or its optionality, no stored shape moves, and every flow, node and -start-node `config` that parses today parses byte-identically afterwards, -because the start node's `config` is an OPEN record (ADR-0018) and the new -`organization` key is an addition to a slot that already accepted anything. So -`objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a -value only the deployment holds, a `sys_organization.id` minted at runtime, with -no authored artifact and no stored representation a rewrite could act on — and -inventing one is precisely what the ruling forbids. ⚠️ That is the argument -against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a -migration that cannot be expressed declaratively gets a structured TODO -(surface, reason, acceptance criteria) rather than nothing, and what follows IS -a prescription in that sense — declare `config.organization` once per -organization, no fan-out, then act on the three consequences of the split named -below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — -behaviour-only, no shape moved, a deployment judgement no transform can make, -registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before -this narrowing landed, so the enforcement rides the 17.x line by the -launch-window convention while the prescription belongs at the major boundary -where `migrate meta` users look. - -**BREAKING** in the accept-set sense, and in TWO places rather than one — -landing in the launch window as `minor` on all four packages (the lockstep -convention: during the window the bump level is not the carrier, this banner and -the disposition above are). Nothing that was refused becomes admitted. - -1. **Bind time.** A `schedule` or `time_relative` flow that declares no - `organization` is no longer armed. -2. **Run time — the DATA PLANE.** A time-triggered run now carries a - `tenantId`, and a `time_relative` sweep now carries one on its own query. - Where a run previously read, updated and deleted across every organization, - it is now confined to the one it declares. - -⚠️ **Read (2) as a narrowing that can stop something that was working**, because -it is one. Two shapes to plan for, and neither is hypothetical: - -- **A deployment running ONE time-triggered flow to cover ALL organizations must - now declare one flow per organization.** That is the ruling - (「不允许跨组织的定时任务」) and it is the whole point, but it is migration - work: there is no fan-out, and a sweep wanted in N organizations is N - declarations. Nothing detects the shape for you — the flow simply starts - seeing one organization's rows. - - ⚠️ **And the split has three effects the sentence above does not carry.** Each - is deployment work, and none of them is detected for you either: - - 1. **A NULL-organization row fans out N-fold.** The driver's scope is - `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no - tenant column value stays visible to a *scoped* read — this PR's own - negative control fixture selects exactly that row under scope, on purpose. - After the split every `organization_id IS NULL` row in a swept object is - therefore matched **once per flow**: N runs, N notifications, each acting - as a different organization. Before the split it was matched once. ⇒ Either - backfill the tenant column on swept objects or declare the object - platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the - scope rather than multiplying under it. - 2. **The current window's dispatch claims are abandoned.** The dedup key - embeds the FLOW NAME — `schedule::` and - `time-relative:::` — so N differently-named - flows claim under N different keys. A window already delivered under the - old name can deliver again, once, under each new one. ⇒ Cut over at a - window boundary, or accept one duplicate window. - 3. **A run suspended before the upgrade is not retroactively confined.** - Resume rebuilds the run's context from `context_json` - (`suspended-run-store.ts`), and a row written before this change carries no - `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills - it. Not a regression (that is how it already ran), but the banner would - otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight - suspended time-triggered runs, or accept that the tail of them is - unconfined. -- **On a SINGLE-organization install a time-triggered flow WAS delivering** — - the #8844 guard derives the only organization there — and after this change it - is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` - that install loses nothing at run time once the line is added: the scope is - `org = :tenant OR org IS NULL` and its one organization is the only scope there - was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss - has no legal configuration.** That driver refuses *any* call handed a tenant - scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) - — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / - `bulk*` / `aggregate`, one call at a time, regardless of how many - organizations the install holds. So a time-triggered flow that touches - per-organization data on that driver is refused per call if it declares an - organization and unarmed at boot if it does not. The declaration is not what - breaks it — the driver has no row-level tenant isolation to offer either way — - but this change is what moves such a flow from the "no organization context at - all → served" case into the refused one. Multi-organization deployments use - `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are - genuinely platform-global can declare them so (`tenancy: { enabled: false }`, - ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the - refusal on data that really is per-organization. - -A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. - -Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 - -A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. - -- **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. -- **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. -- **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. -- **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. - -**What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. - -⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. - -**Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. - -No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. - -**What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: - -- **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. -- **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. - -**The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. diff --git a/.changeset/scope-resubmit-discriminator-invariant.md b/.changeset/scope-resubmit-discriminator-invariant.md deleted file mode 100644 index 14866f7ff6..0000000000 --- a/.changeset/scope-resubmit-discriminator-invariant.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -Correct the `resolveRecordedContinuation` discriminator's stated invariant in -`approval-service.ts` to what was measured. The comment claimed the -`action: 'resubmit'` audit row was "at most one per request"; a `resubmit` whose -own resume strands opens no next round, so the row stays `returned` and a second -`resubmit` after `restoreConsumedSuspension` lands a second such row. The -comment now records that more than one row can exist, states why the read is -correct anyway (it is a presence check with `limit: 1`, deciding identically on -one row or two), and points at the pin that measured it. - -Prose only — no behaviour change, no door narrowed, no guard touched. The audit -trail's one-row-per-advancement shape is accepted residue; requiring one row per -advancement is a separate change. diff --git a/.changeset/scoped-packages-dispatcher-door.md b/.changeset/scoped-packages-dispatcher-door.md deleted file mode 100644 index 2036027c13..0000000000 --- a/.changeset/scoped-packages-dispatcher-door.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -fix(runtime): mount the scoped `/api/v1/environments/:id/packages*` door, and reconcile the package read/delete responses to their declared schemas (#16781) - -**The door.** `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `@objectstack/plugin-hono-server` + this plugin with `enableProjectScoping: true` and **without** `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET …/packages/:id` and `DELETE …/packages/:id` answered by the transport's own `notFound` — a bare 404 on routes `content/docs/api/environment-routing.mdx` documents. The domain has resolved scoped package paths since #15859; nothing mounted one. - -`mountPackagesRoute` is now wrapped in a `base`-taking `registerPackageRoutes(base)`, exactly like its three siblings, and called a second time with the scoped base. **The same handler, no second implementation.** The unscoped mounts keep their registration position and their unconditional mounting, so the change is purely additive: no route that answered before stops answering. - -**The wire.** Two responses gained the key their own declared schema requires (contract review of #16628, finding F2). Both additions are **additive** — no key left either payload: - -- `GET /packages` now sends **`hasMore`** (`ListInstalledPackagesResponseSchema`). It is `false`: this door applies its `status` / `type` filters and returns every remaining row, reading no `limit` and no `cursor`, so there is no next page to announce. -- `DELETE /packages/:id` now sends **`packageId`** (`UninstallPackageApiResponseSchema`). `registryRemoved` and `persisted` stay on the wire unchanged. - -A client that reads only the keys it read before is unaffected; a client parsing either payload against the published schema stops being refused. - -The `DELETE /packages/:id` route-ledger row now carries `responseSchema: 'UninstallPackageApiResponseSchema'`, backed by new conformance coverage that drives the real handler. `GET /packages` is deliberately left blank: its rows are the ASSEMBLED package body, while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` — the #14242 stage mismatch, which no `@objectstack/spec/api` export declares yet. Both directions of that boundary are pinned, so the row becomes fillable against a red test rather than a guess. diff --git a/.changeset/scoped-sdk-honours-metadata-prefix.md b/.changeset/scoped-sdk-honours-metadata-prefix.md deleted file mode 100644 index 590236786a..0000000000 --- a/.changeset/scoped-sdk-honours-metadata-prefix.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/client': patch ---- - -fix(client): the scoped SDK reads `metadata.prefix` off the advertised routes instead of restating `/meta` - -`metadata.prefix` is a live `RestServerConfig` key: REST mounts every metadata -route under `metaPath = ${basePath}${metadata.prefix}` and the discovery handler -advertises the same value as `routes.metadata = ${realBase}${metadata.prefix}`. -Three surfaces describe one set of paths — the mounts, the discovery document, -and this SDK. - -`ScopedEnvironmentClient` restated `/meta` as a literal in all six of its -metadata methods — `getTypes`, `getItems`, `getItem`, `saveItem`, `deleteItem`, -`getHistory` — so on a deployment that moved the prefix, every one of them -called a path the server does not mount. The unscoped twin of each method was -already correct (it builds `${baseUrl}${getRoute('metadata')}`), so one SDK -disagreed with itself: the unscoped half read the advertised value while the -scoped half guessed. Measured on a live server booted at -`metadata: { prefix: '/metadata' }`, all six went to -`/api/v1/environments//meta`, which that deployment answers 404. - -The six now build through `metaUrl()`, which takes its base from `_apiBase()` -and its prefix from the new `_metaPrefix()` — the exact sibling of the -`_dataPrefix()` derivation that fixed `crud.dataPrefix`, fallback discipline -included. `_metaPrefix()` prefers the advertised `routes.metadata`, recovers the -prefix from `routes.data` as a second equation over the same `realBase` when the -advertised value is not the conventional one, and **declines to `/meta`** -whenever the document does not determine the answer: an SDK must not become -unusable because a server's discovery document is missing a key. - -Deployments on the default prefix are unaffected, by construction and by -measurement: the conventional-suffix rule is taken first, so a default -deployment is answered from `routes.metadata` alone, and a client that never -connected never reaches a rule at all. The pinned negative control asserts the -six request URLs of a default deployment byte for byte, for a connected client -and for an unconnected one, and that the unconnected client puts no discovery -request on the wire. - -The unscoped metadata methods are untouched. diff --git a/.changeset/sdui-parser-stageorder-funnel-only.md b/.changeset/sdui-parser-stageorder-funnel-only.md deleted file mode 100644 index 57810963f2..0000000000 --- a/.changeset/sdui-parser-stageorder-funnel-only.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/sdui-parser': patch ---- - -`dashboard-widget-options.ts` header: `stageOrder` is a `funnel`-only key, not `funnel` / `pyramid` - -The accepted-set census comment at the top of the module (carried into the -published `index.d.ts`) described `stageOrder` as "funnel/pyramid stage order". -There is no `pyramid` widget type: `ChartTypeSchema` refuses it, so an author -who copied the pair got a parse refusal. The line now says what the schema's -own `.describe()` says: `funnel` is the only widget type that reads the key. -Comment-only — the accepted set, the diagnostic code and the emitted JS are -unchanged. diff --git a/.changeset/security-fls-unknown-field.md b/.changeset/security-fls-unknown-field.md deleted file mode 100644 index 0c3782ac14..0000000000 --- a/.changeset/security-fls-unknown-field.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -New gating rule `security-fls-unknown-field`: an object-qualified field-permission key naming a field the object does not declare is now an authoring-time `error`. - -`security-fls-unqualified-key` has always caught the *bare* spelling — `fields: { budget: … }` — because the runtime evaluator matches FLS keys by their `.` prefix and a bare key matches nothing. The qualified-but-dangling spelling (`fields: { 'crm_account.description_nope': { readable: false } }`) has the identical runtime consequence and was reported by nothing: `PermissionEvaluator.getFieldPermissions` strips the prefix and looks the remainder up as a column, so a remainder no column answers to contributes nothing to the merged permission map. The masking the author declared **never enforces**, and the field stays as readable and as editable as the object-level grant leaves it — for every holder of the set. - -The failure direction is **fail open**, and this spelling is the one that accumulates: unlike a bare key it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind. - -- **A second rule, not a widening of the first.** `security-fls-unqualified-key` is correct inside its declared scope and is untouched; the two defects have different prescriptions (add the object prefix / fix the field name) and suppressing one must not suppress the other. Two ids, two messages. -- **Where the existence answer comes from.** The rule resolves through `object-graph.ts`, the shared index every field-existence rule in this package already uses — no new input path. It therefore inherits that module's three skips, each of which is the difference between a finding and a false one: an object this stack does not define (it may be another installed package's), an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at` or `owner_id`, which are real at runtime and appear in no authored `fields`. -- **A truncated key is the same defect and is reported by the same rule.** `fields: { 'crm_account.': … }` passes the runtime's prefix test and resolves to the empty column name, so it matches nothing exactly as a dangling name does. `PermissionSetSchema.fields` is `z.record(z.string(), FieldPermissionSchema)` — a bare string key with no pattern and no refinement — and this rule is the only reader of those keys, so before this change nothing reported it at all. A key naming an object this stack does not declare still falls to skip 1, truncated or not. -- **It mirrors the evaluator, including on a multi-dot key.** Only the first dot separates object from field, because `ObjectSchema.name` is `/^[a-z_][a-z0-9_]*$/` and cannot contain one. `'crm_account.owner.name'` therefore asks for a column literally named `owner.name` and is reported: FLS keys address columns, never joins, and resolving that as a relationship hop would have been a fail-open divergence from the gate the rule mirrors. - -**What moves for consumers.** A stack carrying a dangling FLS key built clean before and now fails `os validate` / `os compile`, and is refused at the runtime publish door for `permission` and `object` writes (this rule joins the existing `validateSecurityPosture` registration; no new registry entry). That is the point — the key was never enforcing anything. A stack whose FLS keys all resolve is byte-identically clean: measured on the shipped showcase, whose six authored keys emit zero findings, with a firing control (one injected dangling key produces exactly one finding) beside the zero. diff --git a/.changeset/seed-locale-producer-wiring.md b/.changeset/seed-locale-producer-wiring.md deleted file mode 100644 index a5e6555ec4..0000000000 --- a/.changeset/seed-locale-producer-wiring.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": patch ---- - -`AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. - -The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. - -- **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. -- **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. -- **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. - -`SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. - -The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. - -⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. - -⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. diff --git a/.changeset/serve-org-remedy-defers.md b/.changeset/serve-org-remedy-defers.md deleted file mode 100644 index c9ddb300ab..0000000000 --- a/.changeset/serve-org-remedy-defers.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`serve`: the multi-org runtime's stage-1 refusal no longer prints its own install remedy for a `declared-unresolvable` failure — it defers to the importer's message, which the same refusal already prints as its `cause:` line. - -Driven on both shapes that kind covers, the minted bullet ("Repair the INSTALL … run `pnpm install`, check that a production prune did not drop it, and that its dist is actually built") was wrong twice over. For a genuinely broken install it repeated, word for word, the three remedies the cause line four lines below already carried. For a location install the finder cannot tie to the declaration, the cause says outright that re-running `pnpm install`, un-pruning a deploy and rebuilding a dist all change nothing — so one screen contradicted itself. - -The arm now says only what it uniquely knows (the app DOES declare the package, so re-reading `package.json` will not help) and names the cause as the authority on the remedy — the same deferral the `declared-no-loadable-entry` arm has had since it landed. diff --git a/.changeset/single-posture-organization-census.md b/.changeset/single-posture-organization-census.md deleted file mode 100644 index 2e949a573c..0000000000 --- a/.changeset/single-posture-organization-census.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -fix(plugin-auth): a `single`-posture deployment holding more than one organization is reported at `error` instead of booting silently (#17010) - -ADR-0131 §1.2(3) states that its precondition — many organizations with the organization wall inert — 「is today a refused boot」. It is not. A deployment that never REQUESTS a walled posture and simply HOLDS more than one `sys_organization` row under `single` boots, serves, and says nothing: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently. The harm then surfaces far away and looks like an unrelated data outage: users reconciled from then on are bound to no organization, a platform admin reads zero rows of every organization-stamped object while analytics still counts them, and system-context writes are refused `ambiguous-organization` by the per-write guard. - -The tenancy service now takes a `count(sys_organization)` census on that same seam and reports at `error` when a non-walled deployment holds more than one, naming the posture it DECLARED, the count it HOLDS, and the two ways out: declare a walled posture (`OS_TENANCY_POSTURE=group` / `isolated`, plus the `@objectstack/organizations` package that activates it), or hold one organization and model the sub-units as business units. - -**The boot is not refused.** This change only reports; whether the boot should instead be refused stays open for the maintainer, and nothing here has to be undone if that is the answer. The per-write `ambiguous-organization` refusal is untouched. - -Cost is one `count()` per process: the census sits downstream of the walled-posture early return (a `group`/`isolated` deployment pays nothing and says nothing) and downstream of the memoized resolution, and an engine that cannot answer stays silent rather than guessing. A healthy install — exactly one organization, or none bootstrapped yet — is silent by construction. diff --git a/.changeset/solution-blueprint-module-header.md b/.changeset/solution-blueprint-module-header.md deleted file mode 100644 index a91da82efd..0000000000 --- a/.changeset/solution-blueprint-module-header.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`ai/solution-blueprint.zod.ts` publishes its own sentence again, instead of a list of the symbols it happens to export. - -The file always carried a real module header — ADR-0033 §4 plan-first authoring, and how the `apply_blueprint` tool expands each entry into a proper metadata body. But only a blank line separated that header from `const SNAKE_CASE`, and TSDoc's own attachment rule says a block belongs to the declaration it immediately precedes. The header-zone selector reads that rule back, so the header counted as the regex constant's documentation and was disqualified as the module's. Both generators then fell through to their export-list fallback, and the row published into the `objectstack-ai` skill index read: - -``` -- `…/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, … -``` - -A true statement about the file that says nothing about its subject — on the one row whose job is to send an agent to this source for exact field shapes. - -`SNAKE_CASE` now carries the one-line doc it always deserved. A comment is not a declaration, so the preamble ends there and the header becomes the module's own block. The published row and the public reference page both open on it: - -``` -- `…/ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) -``` - -The selector is untouched. Under its own rule it was deciding correctly, and a census of every source under `packages/spec/src` found this file to be the only one of its kind: 19 shipped `*.zod.ts` sources have a header-zone block sitting against a declaration, and in the other 18 that block genuinely documents the symbol it sits against (`Transport Protocol Enum` against `TransportProtocol`, `Shared history for this file` against `AGENT_HISTORY`). Only here did a module header sit against a constant it says nothing about. - -Neither generator can see this class — each compares its artifact against itself, and each reproduced the selector faithfully, so a generator-only check passes on the defect. A pin now asserts the content of the published row directly. diff --git a/.changeset/sour-moons-smile.md b/.changeset/sour-moons-smile.md deleted file mode 100644 index abfed44a45..0000000000 --- a/.changeset/sour-moons-smile.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os generate schema` can now reach its own `fs.writeFileSync`. - -`runSchemaGeneration` called `z.toJSONSchema(ObjectStackDefinitionSchema, { target: 'draft-2020-12' })` -bare — the one `toJSONSchema` call site in this repository that neither fell back nor used the -`unrepresentable` convention. That call has no JSON form in either io direction on today's tree (a -transform in the output direction, a function type in the authoring direction), so the `catch` below -it printed and exited 1 for every repository and every flag combination: the command could never -write the IDE schema it exists to write. - -It now runs the same three-tier ladder `packages/spec/scripts/build-schemas.ts` already runs for -every schema it publishes — output, then the authoring (`io: 'input'`) direction, then that direction -with `unrepresentable: 'any'` as `packages/metadata-protocol` spells it — and each tier re-raises any -error the known-unsupported predicate does not recognise, so a real conversion failure is still loud. - -No new flag, no new key and no new exported symbol: the change is confined to the body of a -module-private function. - -The published document lands on the third tier today. It is the authoring derivation, so a property -carrying a `default` is not reported as required; the nodes that have no JSON form in any direction — -`onEnable`, and the inline-callable branch of each `handler` under `hooks`, `functions` and -`packages` — are published as unconstrained, which means an IDE validates everything else in -`objectstack.config.ts` and asks nothing about those. diff --git a/.changeset/spec-approval-continue-restored-contract.md b/.changeset/spec-approval-continue-restored-contract.md deleted file mode 100644 index fc5e4198d1..0000000000 --- a/.changeset/spec-approval-continue-restored-contract.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Declare `continueRestoredRun` on the `IApprovalService` contract, so the approvals half of the operator repair pair is reachable through the published interface rather than only off the implementation class. - -`IAutomationService.restoreConsumedSuspension` re-arms the pause a failed resume consumed and, by its own contract, does not replay the resume signal — the continuation must be re-issued. For an approval suspension nothing could re-issue it: every front door guards on a live request — `pending` for decide and send-back, `returned` for resubmit, and `pending` or the revise window for recall — and the stranding call leaves the row where none of them can issue the continuation it owes. The issuer landed as a class member on `plugin-approvals`; this declares it, so a caller programs against the contract instead of importing the implementation. - -Additive and OPTIONAL, the way `cancelRun` / `restoreConsumedSuspension` are declared on `IAutomationService`: an existing implementation still conforms, and a service that does not declare the member has no operator door for it — a caller must probe for presence and refuse fail-closed rather than answer success for a verb it could not dispatch, because promising a repair verb that will refuse is worse than promising nothing. No REST or CLI route is declared or implied. diff --git a/.changeset/spec-cloud-provided-package-version.md b/.changeset/spec-cloud-provided-package-version.md deleted file mode 100644 index 1ee95fcc3f..0000000000 --- a/.changeset/spec-cloud-provided-package-version.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`CLOUD_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) gains a member: -`sys_package_version`. `isPlatformProvidedObjectName('sys_package_version')` now -returns `true`, so a reference to that name resolves instead of being flagged as -a platform-prefixed name nothing registers (#16745). - -This widens an accept set. The name was previously refused, the list is a closed -set, and nothing in the published header enumerated this member — so the ladder -now accepts a value it used to warn on, and the widening reaches every surface -that consults the predicate: a dataset `object`, an action parameter -`reference`, a dashboard `optionsFrom.object` and a navigation `requiresObject` -naming `sys_package_version` all stop being diagnosed. - -Why this name and not another: the list already carried `sys_package` and -`sys_package_installation` — the head and tail of the three-table package family -that `cloud/package.zod.ts` declares — but not the release-snapshot table -between them, whose row schema this repository ships as -`cloud/package-version.zod.ts`. Platform metadata that ships with the product -references it: `sys_metadata.package_version_id` in `@objectstack/metadata-core` -is a `Field.lookup('sys_package_version', …)`. - -One entry is added; no other member moves and nothing is removed or narrowed. -The cloud-side half of the contract — that `@objectstack/service-tenant` -registers the table — is owned by the cloud repository per the list's header and -is not asserted from here. diff --git a/.changeset/spec-cloud-subpath-retired.md b/.changeset/spec-cloud-subpath-retired.md deleted file mode 100644 index ecbef526de..0000000000 --- a/.changeset/spec-cloud-subpath-retired.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": patch -"@objectstack/metadata": patch ---- - -feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) - - - -**BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no -alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, -用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window -convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness -is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription -is registered under protocol major 18 as `cloud-subpath-retired`. - -## What moved, and why - -Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, -ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). -`packages/spec/src/cloud/` held two families with different owners: - -- **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, - `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema - defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the - open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: - `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and - the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it - is recoverable from git history at `d5d8d50db`. -- **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, - `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the - open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` - and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is - byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the - author-facing contract). - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | -| `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | -| `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | -| `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | -| `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | - -Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` -deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking -binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That -type no longer exists in the open-source package, so the wrong binding is structurally -impossible rather than warned about in a docblock. - -`@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and -`system` respectively); no behaviour moves. diff --git a/.changeset/spec-functional-completeness-symbol-anchors.md b/.changeset/spec-functional-completeness-symbol-anchors.md deleted file mode 100644 index 5cc858614d..0000000000 --- a/.changeset/spec-functional-completeness-symbol-anchors.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `functional-completeness`'s three `objectql/engine.ts` citations name symbols instead of line numbers (#16960) - -The module doc block of `kernel/functional-completeness.ts` cited the runtime that -justifies each rule by line number. All three had rotted: re-measured on `origin/main` -`7ddf13dca` (`engine.ts` is 15,309 lines), the quoted texts live at 8630, 8978 and 921 -against cited 3001, 3191 and 346 — drifts of 5,629, 5,787 and 575. Each quoted text -occurs exactly once in `engine.ts`, so those are readings rather than artefacts. - -The citations are the only limb tying a rule's justification to the runtime that -implements it, and that limb is walked by a human reading it — nothing in the module can -notice the runtime moved. `:3191` was the dangerous one: the line it names today is -ordinary-looking `dispatch:` code, so a reader following it lands somewhere plausible and -never learns they were sent to the wrong place. - -Each now names the enclosing symbol in the repo-root `path#symbol` form -`packages/spec/liveness/field.json` already uses — -`packages/objectql/src/engine.ts#buildSummaryIndex`, `#planFormulaProjection`, -`#expandRelatedRecords` — beside the verbatim snippet. A corrected line number would rot -again on the next refactor; a symbol plus a unique snippet is greppable and survives -movement. The anchor form also moves these three from -`check-spec-docblock-symbol-anchors`' not-judged bucket into resolution (that gate now -reports `3 symbol (3 declaration)` where it reported `0`), so a rename reddens CI. - -Doc text only — no schema, export, type or runtime behaviour changes. It ships because -this block is emitted into the published `dist/kernel/index.d.ts`. diff --git a/.changeset/spicy-pears-count.md b/.changeset/spicy-pears-count.md deleted file mode 100644 index 3476abdd66..0000000000 --- a/.changeset/spicy-pears-count.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct `FieldReferenceSchema`'s first TSDoc `@example`: a `{ $field }` comparand names a column of the SAME row, never a relation path. - -The example spelled its comparand as `{ "$eq": { "$field": "order.owner_id" } }` and captioned it as a join ON clause, while the same docblock's "Execution support" prose states that a dotted path is refused by SQL push-down with `INVALID_FILTER` (HTTP 400). Copied as written it does not fail at the schema door — both spellings parse — so it fails later and quietly: the in-memory evaluator answers `false` for a flat row, and SQL push-down refuses. The ON clause it advertised no longer exists either; `query.joins` was removed and related records are read through `expand`. The example is now the same-table cross-field comparison both execution paths compile, and the docblock header no longer advertises a join surface. `@objectstack/spec` publishes `src/**/*.zod.ts`, so this docblock ships to authors and to IDE hover. diff --git a/.changeset/spotty-jars-shave.md b/.changeset/spotty-jars-shave.md deleted file mode 100644 index eea558bf3d..0000000000 --- a/.changeset/spotty-jars-shave.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os validate` and `os lint` now judge the same stack `os build` judges when a project declares its metadata only in `packages[]`. - -A project in the ADR-0130 D4 artifact shape — every definition inside `packages[]`, no collections at the top level — was handed to the author-time rule table as an **empty stack** by both commands, so all 44 rules reported nothing and both exited 0 having read none of the project. `os build` folds the packages back in first (`authoringRuleUnionStack`) and refuses the same stack. Two of the three authoring gates were certifying an unread project as clean, and `os validate` is the check an author runs before shipping. - -Both commands now hand the rule table the stack that same helper returns — one fold, shared with `os build`, not a second implementation. It is a rule **input** only: neither command's output, `--json` payload nor `os lint`'s metadata score changes, and a stack that still carries its top-level collections is returned by identity, so single-package projects are unaffected by construction. - -⚠️ **A project that was silently passing may now fail.** That is the defect surfacing, not a new rule: the finding was always there and `os build` was always reporting it. Run `os build` on the same tree to see the identical diagnostic. diff --git a/.changeset/standalone-plugin-scaffold-unscoped-private.md b/.changeset/standalone-plugin-scaffold-unscoped-private.md deleted file mode 100644 index 5792287e88..0000000000 --- a/.changeset/standalone-plugin-scaffold-unscoped-private.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os create plugin` names the standalone scaffold `plugin-` and marks it `private` - -The default (standalone) emission wrote `"name": "@objectstack/plugin-"` into a -project scaffolded for a developer outside this monorepo — a scope they cannot publish -to — and did not mark the manifest `private`. Nothing failed at scaffold time: the name is -never resolved from a registry inside the project, so `pnpm install`, the type-check and -the scaffold smoke were all green on it, and the cost landed later at `npm publish`. The -emitted README compounded it by instructing `pnpm add @objectstack/plugin-`. - -The standalone default now emits: - -- `"name": "plugin-"` — unscoped, and the same string as the directory the - scaffolder prints and creates; -- `"private": true` — the line that actually stops an accidental publish, whatever the - name says; -- a README whose install instruction is a local reference (`pnpm add link:../plugin-`) - and whose import specifier matches the emitted package name. - -`os create plugin --in-repo` is unchanged: it still emits a publishable -`@objectstack/plugin-` with no `private` flag, because that placement lands under -`packages/plugins/` where every sibling genuinely carries that scope. - -No action is needed for a project already scaffolded. If you generated one with the old -name and have not published it, rename `package.json`'s `name` to `plugin-` (or a -scope you own) and update the README's install line; the exported symbol and the plugin's -runtime `name` are unaffected. diff --git a/.changeset/standalone-stamp-comment-accuracy.md b/.changeset/standalone-stamp-comment-accuracy.md deleted file mode 100644 index 9e8254b956..0000000000 --- a/.changeset/standalone-stamp-comment-accuracy.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/objectql": patch -"@objectstack/cli": patch ---- - -docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces - -The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. - -No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. - -The sites were judged individually rather than search-and-replaced, because they are not all the same edit: - -- Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. -- `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. - -The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. diff --git a/.changeset/strict-env-scope-roots-dyn.md b/.changeset/strict-env-scope-roots-dyn.md deleted file mode 100644 index a0ca4a33de..0000000000 --- a/.changeset/strict-env-scope-roots-dyn.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/lint": minor ---- - -fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) - - - -**BREAKING** in the accept-set sense — an accept-set narrowing on published -CHECKERS, in the same sense as a route that starts refusing a request it should -always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is -not the carrier of breaking-ness; this paragraph and the disposition above -are). Nothing that was already reported stops being reported, and no source -that is correct starts being reported. - -`firstUndeclaredReference` asks cel-js's checker for the first undeclared -identifier in a source. That checker returns exactly ONE error, and the helper -acts only on `Unknown variable: X`, so whenever the first error is of another -class every undeclared reference behind it in the same source went unjudged and -the helper answered `null` — which is also the value that means "every -reference is rooted". Four published call sites read that answer, and none of -them can tell the two readings apart. - -The widest way to reach that state was a disagreement between two environments -in this package about the same names. The strict env declared every -`SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, -`input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` -type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an -ordinary comparison on one of those names compiled clean and then faulted `no -such overload` in the strict env only — taking the single error slot and -silencing everything behind it. An author reaches it by naming an object field -or a flow variable after a namespace root and reading it bare, which on a -metadata-editing form is not even a coincidence: that layer binds the row under -edit as `data`. - -The strict env now declares those roots `dyn`, which is what the list's own -doc-comment already claimed it was for — member access, arithmetic and -comparison on a root all deferring to runtime — and which `map` delivered only -the first of. The two environments agree about these names, so the class cannot -arise rather than being compensated for downstream. - -What starts reporting, measured on each published surface: - -- `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare - reference behind a root name is the hard error it always was for the same - identifier written first (`ok` was `true` with zero errors; it is now `false`). -- `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the - did-you-mean warning reaches a misspelled field behind a root name. -- `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a - root name in a `visibleWhen` predicate is a finding. Per that rule's own - message the console otherwise falls open and the element renders - unconditionally. -- `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a - root name is warned. That rule's documented blind spot is now name-local, as - its wording always claimed: the colliding name itself is still not reported. - -⚠️ One published answer also WIDENS, and it is not a reporting surface. -`inferExpressionType` (`@objectstack/formula`, re-exported from the package -root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a -formula's coarse value type through `inferCelType`, which shares this same -strict environment. While the roots were `map` there was no `==`, `<` or `+` -overload for them, so an expression using a namespace root as a DIRECT OPERAND -did not type-check at all and the answer was `'unknown'`. With the roots `dyn` -those expressions type-check and the answer is the truthful CEL type: -`result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, -`data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No -answer changes from one concrete type to another and nothing narrows to -`'unknown'` — `size(record)` and `"a" in record` still answer, and a root that -is only the base of a member access (`record.amount > 100`) never consulted this -declaration. A consumer that keys off a concrete type therefore sees strictly -more expressions classified, never a different classification; for the -motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is -now correctly seen as text rather than as unprovable. Pinned on both sides in -`validate.test.ts`. - -⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL -TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no -declaration this package makes can reach it; measured on the strict env, the -message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root -declaration. And `has()` handed a non-select argument still faults its own -class, which `@objectstack/lint`'s visibility rule masks at its own call site -(#16118) and which nothing else masks. - -The narrowing this helper is built on is unchanged: it still acts only on -`Unknown variable`, so `type(record.x) == string`, comprehension macros, guard -idioms, optional chaining and stdlib calls report nothing, and a widening of -that regex onto the overload message remains refused. diff --git a/.changeset/strict-object-aliases-two-roles.md b/.changeset/strict-object-aliases-two-roles.md deleted file mode 100644 index b924bb8095..0000000000 --- a/.changeset/strict-object-aliases-two-roles.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct `aliases`' documented contract: it is not "only for what edit distance cannot reach". - -`strictObject`'s `aliases` option was documented as a universal in the three places an adopter reads — the module docblock in `shared/strict-object.ts`, the `StrictObjectOptions.aliases` JSDoc an editor shows on hover, and the same JSDoc on the published `strictUnknownKeyError`'s `StrictUnknownKeyErrorOptions.aliases` — all saying aliases are "semantic near-misses edit distance cannot reach". The word *cannot* denies the option's second job. - -The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]`: an alias is consulted **before** the distance fallback and wins outright. So an entry is equally right when distance *does* reach the key and answers with the wrong one — `hosts` is 2 edits from the declared `hooks` against a budget of `Math.max(2, Math.floor(5 / 3))` = 2, so on the plugin `permissions` block the entry is what keeps an author off lifecycle hooks. - -Neither role is rare, and the correction carries its own count rather than the hedge it replaces. Measured over every surface the `strictObject` registry records, 2026-09-11: **1910** alias entries, **1658** unreachable by distance, **252** reachable — 211 where the fallback would have answered identically, and **41** where it answers a different key the entry overrules. - -The failure mode the old sentence produced is precise and has a live carrier: an adopter with a reachable-but-wrong near-miss read "edit distance cannot reach", concluded `aliases` was not the tool for their case, and left the confident wrong suggestion in place. - -Prose only. No alias is added or removed, no schema, key list, strictness or error message changes, and `visibleWhen → visible` (verified still unreachable) stays as the proving case for the gap half. diff --git a/.changeset/summary-column-doc-sweep.md b/.changeset/summary-column-doc-sweep.md deleted file mode 100644 index 9bbdecdc94..0000000000 --- a/.changeset/summary-column-doc-sweep.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Correct the `summary` column representation stated in the sort-hint TSDoc. Since #16318 an engine-maintained `summary` column is an exact `table.decimal` on tables created after that change and a `table.float` on tables created earlier; the shipped doc comment still said flatly that it is a `table.float`. Comment text only — the sort behaviour it describes is unchanged, and the column type was never what makes a `summary` field sortable (having a provisioned column is). diff --git a/.changeset/sys-account-issuer-retired.md b/.changeset/sys-account-issuer-retired.md deleted file mode 100644 index cdfcd0ba9d..0000000000 --- a/.changeset/sys-account-issuer-retired.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/plugin-auth": minor -"@objectstack/client": minor -"@objectstack/cli": minor -"@objectstack/spec": minor ---- - -feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) - - - -**BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` -drops six published symbols. Shipped as `minor` under the launch-window convention -(`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this -banner plus the ADR-0087 disposition above). The hand-migration prescription is -registered under protocol major 18 as `sys-account-issuer-retired`. - -better-auth `1.7.3` removed the issuer-scoped account identity outright -(`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, -`accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the -`account.issuer` column and its unique index are gone from `get-tables`. There is no -drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is -the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. - -## 迁移:FROM → TO - -| FROM | TO | the one-line fix | -|:--|:--|:--| -| `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | -| reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | -| `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | -| `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | -| `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | -| `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | - -## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped - -Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a -**narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in -`issuer` are legal under the old key and are ONE account under the new one. - -``` -os migrate account-issuer # read-only; exits non-zero when the drop must not proceed -# … take a backup (the operator's act, and the apply step's precondition) … -os migrate apply --allow-destructive -os migrate account-issuer # post-check: reads zero -``` - -The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a -plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and -lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration -without the constraint — and on such a database the drop does not fail loudly, it -degrades silently: the rows become indistinguishable and a sign-in can resolve onto the -wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight -and refuses the drop before writing any DDL. A read that throws, or a scan that -truncates, refuses too — an unread table is not a clean one. - -⛔ Colliding rows are never merged or dropped for you: which row survives is application -knowledge, and two different people can be behind one colliding key. Keep the row whose -provider account is live, delete the rest so a fresh sign-in re-links, and re-run. - -The boot refusal is unchanged and needs no new machinery: a runtime already refuses to -start against unapplied destructive drift, naming the command to run, and never -auto-migrates. - -## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT - -This is the one case `issuer` still discriminated. After the drop no column records which -IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one -had already issued to somebody else, the key resolves that sign-in onto the other -person's account. Under the old key that failed loudly (`unable_to_link_account`); under -the new one it is silent. - -⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still -bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account -bindings first; each user re-links on their next sign-in. - -## Why the column was a liability, not an asset - -A credential row whose `issuer` was not the local credential issuer was invisible to -`findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not -found" warn pointing at the `sys_user` row rather than at the account. **Four checklist -items had that recorded as a knownGap, each rediscovering it.** Its discriminating power -here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: -true }`, so `provider_id → issuer` is a function within an environment. - -## Also in this change - -`pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and -still resolves every named symbol — its self-test re-anchors from the now-retired -`@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two -deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it -is a generic driver capability serving five UNIQUE members of the >768-char class. diff --git a/.changeset/sys-user-role-prose-retired-action.md b/.changeset/sys-user-role-prose-retired-action.md deleted file mode 100644 index 3d1f2800d6..0000000000 --- a/.changeset/sys-user-role-prose-retired-action.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -`sys_user.role`'s field description and its `readonly` comment stop pointing at the retired Set Platform Role action (#15188) - -Both strings named `set_user_role` / "Set Platform Role", an action retired in #9968 — the description told an operator to press a button that no longer exists anywhere in the product. This is not a source comment: a field `description` is authored data that ships in the published bundle and is extracted into the i18n bundles, so it surfaces in the admin UI's field help and in generated reference material. The correct path was already there and already the only one: platform-admin standing comes from an unscoped `admin_full_access` grant in `sys_user_permission_set` (ADR-0068 D2), which is exactly what the #9968 removal note in the same file says. - -- **`description`** now reads "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`." It states what the column IS (a vendor authentication-layer scalar that stays published as `user.role`) and where the operator actually goes, and it deliberately does not claim the scalar confers nothing: `judgePlatformAdmin` still reads `user.role === 'admin'` as the legacy fallback it has always been, so a pre-D2 deployment carrying the value is not locked out. Saying "this field grants nothing" would have replaced one false sentence with another. -- **The `readonly` comment** keeps its ADR-0092 anchor and now states the true reason the field is not editable — nothing writes it since #9968 — instead of naming a writer that is gone. -- **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand: the default locale's leaves are rewritten from the source on every run. - -**Deliberately unchanged, and pinned so it stays that way.** The same file carries a third mention inside the #9968 removal note — *"a working \"Set Platform Role\" button **was** a supported, one-user-at-a-time resurrection channel…"*. It is past tense, it narrates what was removed, and it is true; sweeping it up with the other two would turn a true sentence false. A new test pins the removal note's tombstone opener and that past-tense sentence as occurrence counts over the source text, so both directions fail: deleting the history drops a count to 0, and re-introducing the retired action's name in live prose pushes one past 1. diff --git a/.changeset/temporal-text-operator-declared-type-gate.md b/.changeset/temporal-text-operator-declared-type-gate.md deleted file mode 100644 index 7c525afb31..0000000000 --- a/.changeset/temporal-text-operator-declared-type-gate.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/driver-sql": minor -"@objectstack/driver-turso": minor -"@objectstack/driver-sqlite-wasm": minor -"@objectstack/service-analytics": minor ---- - -feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) - - - -**BREAKING** in the answer sense, on every SQL face, landing in the launch -window as `minor` under the lockstep convention this cluster's siblings use. - -**The behaviour that GOES AWAY, by name: searching a date as a string.** On the -SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and -`driver-turso`'s local transport — a `Field.date` / `Field.datetime` / -`Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator -matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 -row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; -`{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three -now return nothing**, and their `$notContains` mirrors now return every valued -row. If you are relying on any of them, this is a row-set change and the -replacement is a range filter — spelled out below. The behaviour was never -declared by any contract row and it never worked outside SQLite: the same three -filters were a `DATABASE_ERROR` 500 on live Postgres. - -Nothing that was refused becomes admitted, and no new error code is minted — the -refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the -numeric and boolean classes. - -Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: -「a text operator over a column whose DECLARED type is temporal is type-gated -exactly like the numeric and boolean classes; the SQLite ISO-text match is not -a contract」. - -## What was wrong — one filter, three answers across one driver family - -`{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding -`2026-01-05`: - -| face | before | mechanism | -|:--|:--|:--| -| `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | -| `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | -| `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | - -Three answers to one filter, and no face declared which was canonical. The -SQLite answer was the accident of a storage form, not a capability: the same -query against Postgres was a 500. - -## What it does now - -The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` -(`@objectstack/spec`), the set the SQL compilers consult at compile time -because the stored value is not visible until run time. Every face that reads -it — `SqlDriver` (and everything that inherits its compiler), -`driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — -compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / -`$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to -the TRUE constant. Postgres's 500 becomes that declared answer; complementarity -holds; the constants compose with the existing NULL-safe rules and the `$not` -rewrite unchanged. - -**The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask -for "records in 2026" writes a range instead, which every dialect has always -answered the same way: - -```ts -// before — matched only on the SQLite family, 500 on Postgres -{ on_day: { $contains: '2026' } } -// after — the prescription, identical on every backend -{ on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } -``` - -## Boundaries, so a reader does not over-read this - -- **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON - TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on - a JSON column — not a substring test. It keeps compiling exactly as before. -- **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not - caveated.** `driver-memory` canonicalises a declared temporal write to ISO - TEXT (#4047), for a `Date` input and a string input alike, so a positive text - operator MATCHES there — the exact complement of the answer this changeset - declares. That divergence is filed as #17348 and pinned by name in that - driver's conformance suite, alongside a correction: the two rows previously - read as pinning the no-match answer pass because their comparand omits the - milliseconds, not because anything type-gates. `formula` and `having` cannot - key on the declaration at all — `matchesFilterCondition(record, filter)` takes - a bare record ("this evaluator sees a bare record and has no schema to - consult", its own docblock), and `having` filters AGGREGATED rows whose columns - carry no field declaration. ⛔ So "on every face" is NOT delivered by this - change, and this changeset does not claim it: the SQL family answers the - declared rule, the JS faces do not yet. -- **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there - is keyed on the STORED value — which is why its non-string column is a number - and not a date — so a temporal fixture would assert one stored form across all - five drivers that import it, the stored-form guarantee the ruling refused - option (b) for. -- **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its - cell rests on the compiled-shape pin, which reads the constant a statement - would carry without executing one. diff --git a/.changeset/time-update-interval-sub-day-retired.md b/.changeset/time-update-interval-sub-day-retired.md deleted file mode 100644 index 6bbf1841cc..0000000000 --- a/.changeset/time-update-interval-sub-day-retired.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/driver-memory": minor ---- - -fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296) - - - -## ADR-0087 disposition - -`second`, `minute` and `hour` leave a published closed enum that reaches TWO authored sites: an analytics request body's `timeDimensions[].granularity`, and an analytics cube dimension's `granularities[]`, which is stored metadata (`defineCube()` / `defineStack({ analyticsCubes })`). The stored half is rewritten by the D2 conversion `cube-sub-day-granularities-removed`, which strips the retired members from `analyticsCubes[].dimensions..granularities` and drops the key entirely when nothing coarser remains (an empty list would read as "offers none", the absent key as "offers all"). The semantic entry `time-update-interval-sub-day-retired` carries the half no transform can decide: a dimension that offered ONLY sub-day intervals needs an author to say what it actually serves. `day`, `week`, `month`, `quarter` and `year` are untouched and parse byte-identically. - -**BREAKING** for anyone authoring or sending `granularity: 'second'`, -`'minute'` or `'hour'`, and for anyone importing the `TimeUpdateInterval` -TYPE. Landing in the -launch window as `minor` under the lockstep convention this cluster's siblings -already use. - -## What was wrong - -`TimeUpdateInterval` declared **eight** intervals. The rest of the contract -never carried three of them, and this is the measurement rather than the -argument: - -| layer | declares | -|:---|:---| -| `TimeUpdateInterval` (`data/analytics.zod.ts`) | **8** — the five below plus `second`, `minute`, `hour` | -| `DateGranularity` (`data/query.zod.ts`) — what a `groupBy` entry and every driver bucket expression are typed by | 5 | -| `@objectstack/core`'s `BUCKET_GRANULARITIES` — the canonical bucket-KEY output contract a drill-down crosses | 5 | -| `driver-mongodb`'s `MONGODB_DATE_GRANULARITIES` | 5 | - -`DriverCapabilitiesSchema.supports.queryDateGranularity` — the one mechanism a -backend has for saying which granularities it buckets natively — is a -`z.record(DateGranularity, boolean)`. Measured: `{ day, week, month, quarter, -year }` parses; the same record plus `hour` raises `unrecognized_keys: ["hour"]`. -**No driver could advertise sub-day bucketing even if it had one.** That is what -makes this a retirement rather than a capability gap: a declared value one -backend cannot serve is a gap and the contract has a place to say so, but a -declared value *no* backend can even claim has no counterpart anywhere in the -contract that carries it. - -Driven against the built packages, two rows fourteen hours apart on one UTC -calendar day, before this change: - -| face | `granularity: 'hour'` | `granularity: 'day'` (control) | -|:---|:---|:---| -| `driver-memory` analytics | `NOT_IMPLEMENTED` / 501 | 1 group, `2026-09-06` | -| `driver-mongodb` bucket builder | `NOT_IMPLEMENTED` / 501 | `$dateToString` `%Y-%m-%d` | -| engine in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity | **200, 2 groups keyed on the RAW instant** | 1 group, `2026-09-06` | - -Two honest refusals and one silently wrong answer. No third behaviour, and no -backend that bucketed it. - -## What changed - -- `TimeUpdateInterval` is now `z.enum(DateGranularity.options, …)` — the members - come from the single source instead of a second literal list that disagreed - with it by three members for as long as both existed. -- A refusal message splits two populations that are not the same mistake: a - **retired** sub-day name gets the retirement and the `os migrate meta --from - 17` line; anything else gets the vocabulary. `driver-memory`'s own analytics - door carries the same split. -- `driver-memory`'s `NOT_IMPLEMENTED` / 501 answer for these three is **not - silenced** — the declaration it announced is gone, so the class moves to the - 400 the retirement makes correct. The 501 arm stays, and a pin measures that - its population is now empty (`TimeUpdateInterval.options` equals - `BUCKET_GRANULARITIES`), so the day one of the two is widened alone it lights - up again instead of a freshly declared value being called undeclared. - -## What this does NOT decide - -Sub-day analytics bucketing as a **capability**. Offering it means widening -`DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key -vocabulary and every driver's bucket expression together — new capability, -decided as such, rather than a name that parses in one enum and resolves -nowhere. diff --git a/.changeset/translate-flow-walks-adr-0031-regions.md b/.changeset/translate-flow-walks-adr-0031-regions.md deleted file mode 100644 index 31fba9e49b..0000000000 --- a/.changeset/translate-flow-walks-adr-0031-regions.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`translateFlow` overlays screen nodes inside ADR-0031 regions, at any depth - -`translateFlow` (`system/i18n-resolver.ts`) read the flat `flow.nodes` array and -nothing else. But `FlowNode.config` carries ADR-0031 regions — -`loop.config.body`, `parallel.config.branches[].nodes`, -`try_catch.config.try`/`.catch` — each holding a full `nodes` array that nests -arbitrarily, and a `type: 'screen'` node inside one is a real screen: the -executor pauses on it and the client receives its `ScreenSpec.nodeId`. - -So `flows..screens..{title,fields.*}` was authored for such a -node, parsed (the bundle schema is keyed by node id and knows nothing about -depth) and was then silently never applied. The wizard step rendered its -source-locale heading and field labels while its siblings one level up were -translated. - -The descent now runs through `mapFlowNodeList`, a per-flow region-aware -copy-on-write walk shared with the ADR-0087 conversions' `mapFlowNodes`, which -reads `FLOW_REGION_SLOTS_BY_TYPE` — the single declaration of where a region -lives (`automation/region-slots.ts`). This resolver is therefore not a fifth -hand-rolled reader of that table; the fourth pass written against the flat -one-liner is the last one that had to be. - -Reference identity is unchanged and is pinned: a node that resolves nothing -comes back as the same reference, every container `config` and region `nodes` -array on the way down is copied only when a descendant actually changed, and a -flow the bundle does not carry is returned as the same object. - -⛔ No wiring changed. `translateFlow` is still deliberately absent from -`translateMetadataDocument`'s dispatch table and no liveness row moved — that -decision belongs to the downstream runner card, as its docblock records. diff --git a/.changeset/two-factor-verify-echoes-live-user-row.md b/.changeset/two-factor-verify-echoes-live-user-row.md deleted file mode 100644 index f6a18df22c..0000000000 --- a/.changeset/two-factor-verify-echoes-live-user-row.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-auth": patch -"@objectstack/client": patch ---- - -`POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. - -On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. - -`two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. - -- **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. -- **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. -- **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. - -`@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. diff --git a/.changeset/validate-refuses-blank-structural-condition.md b/.changeset/validate-refuses-blank-structural-condition.md deleted file mode 100644 index ed1b732af5..0000000000 --- a/.changeset/validate-refuses-blank-structural-condition.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint)!: `objectstack validate` refuses a blank structural `condition`, the rule `registerFlow` has carried since #17322 (#17495) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): -`validateStackExpressions` — the pass behind `objectstack validate` — now -reports an `error` for a structural `condition` whose source is blank after -trimming. It reported nothing at all before. - -The value was already refused by two of the three doors. `FlowEdgeSchema.condition` -composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is -refused at `FlowSchema.parse`; #17322 rebound `AutomationEngine.registerFlow` to -that same rule, so the same value on a node's `config.condition` stops the flow -registering. `objectstack validate` was the door that still said nothing — so an -author got a clean bill, deployed, and the flow never registered: each boot path -in `service-automation`'s plugin wraps `registerFlow` in `try`/`catch`, logs one -`warn` naming the flow, and continues. On a `start` node that key is the -**trigger gate**, so the whole flow is armed by nothing. - -FROM → TO, for a build that used to pass and now fails: - -```yaml -# FROM — validate said nothing; registerFlow refuses it at boot -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } - - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } - -# TO — either write the predicate you meant… -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: 'record.active == true' } } - - { id: branch, type: decision, config: { condition: { dialect: cel, source: 'record.rating >= 4' } } } - -# …or drop the key. An ABSENT condition is still not a malformed one: a start -# node with no `condition` is an ungated trigger, and that is unchanged. -``` - -The refusal is the edge door's own sentence, not a second one — the finding -carries `EVALUATED_EXPRESSION_SOURCE_REQUIRED` verbatim, located at the node and -slot the author wrote (`flow 'f' · node 'gate' (start) condition`), because all -three doors now ask one imported schema. - -Unchanged, deliberately: the **evaluator**. A condition already stored blank -still answers `false` at run time — #15662's ruling on that half stands. What -moved is that it can no longer be authored past validate. diff --git a/.changeset/value-domain-note-settings-door-repointed.md b/.changeset/value-domain-note-settings-door-repointed.md deleted file mode 100644 index 7ef1135207..0000000000 --- a/.changeset/value-domain-note-settings-door-repointed.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the `field.valueDomain` liveness note stops claiming the settings door is "unchanged until then" - -The `valueDomain` row of the published `liveness/field.json` ledger ended on a sentence written -while the re-point was still in the future: - -> The settings door (`service-settings/value-domains.ts`) re-points onto the shared predicate in -> its own follow-up card and is unchanged until then. - -Both halves of the 2026-09-02 ruling have since landed — the settings half (#15434) and the engine -half (#15316) — and the engine half rewrote this note wholesale while carrying that sentence -forward verbatim. "Unchanged until then" therefore described a state that no longer existed: the -door it names had already re-pointed, one commit earlier. - -The sentence now says what is true of that door, read off its source rather than off a PR title: -its second copy of all three definitions is deleted, `firstRejectedDomainMember` asks -`isValueDomainMember` — the same call `record-validator.ts` makes — and what remains on that side -is the door's own business (which declarations it agrees to enforce, how a multi-value carrier is -walked, the fragments the env-override log line needs). A re-added local table reddens -`value-domains.shared-predicate.pin.test.ts`. - -Ledger-note text only. The row's `status` is untouched — it tracks the engine write path, and -`liveness/state-counts.md` is derived by `gen:liveness-counts` from the row states, none of which -move here (`check:liveness` reports the counts file current). diff --git a/.changeset/value-envelope-nullish-attribution.md b/.changeset/value-envelope-nullish-attribution.md deleted file mode 100644 index 5b75dc91d0..0000000000 --- a/.changeset/value-envelope-nullish-attribution.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(service-automation): a `null` / `undefined` envelope is refused attributed, not as a raw `TypeError` (#16439) - -`AutomationEngine.evaluateValueEnvelope` derives its verdict from `valueEnvelopeRefusals` — the same call `registerFlow` makes — so registration's reject set and evaluation's reject set are one set by construction. That covered every malformed **envelope**, and exactly two shapes fell outside it: `null` and `undefined`. Neither published primitive judges them (the shape rule is a no-op on anything not `isExpressionEnvelopeShaped`, and `validateExpression` reads an absent `source` as "not authored"), so both returned no findings and the method went on to read `envelope.source` off nothing — `TypeError: Cannot read properties of null (reading 'source')`, with no `where`, no source and no rule. Driven across the ten shapes the card enumerates, eight failed attributed and only these two did not. - -Both now fail attributed like the other eight, led by the published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` sentence and carrying the `where` and the source. The rule is stated in the **shared** refusal, never as a guard in the evaluator: a reject reason living only on the evaluation side would end the very property this design has. - -Refused rather than admitted, and the asymmetry with the predicate path is deliberate: `structuralConditionRefusal` admits `null` / `undefined` because the condition *field* is optional, so absence there means "the author wrote no predicate". A value slot's envelope **is** the value, so an absent one is a caller handing nothing where a value was required. - -**Why `patch`, not `minor` and not nothing.** Nothing changes for authored metadata: the only production call site guards with `isExpressionEnvelopeShaped`, which neither shape satisfies, and the value-role feeder emits only envelope-shaped objects, so `registerFlow` never presents a nullish value to the shared refusal — measured, and pinned. An authored `null` in an `assignments` slot is still a literal, still parses and still registers. What does move is the runtime behaviour of a **public method on an exported class**: a direct caller that passed a nullish envelope used to get a language-level `TypeError` and now gets an attributed `Error`. That is a published surface, so it is not silent — but it adds no API, no option and no capability, and no correct caller has to adapt, which is what makes it a patch rather than a minor. diff --git a/.changeset/verify-in-process-handle.md b/.changeset/verify-in-process-handle.md deleted file mode 100644 index 1e23f2eff5..0000000000 --- a/.changeset/verify-in-process-handle.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/verify": minor ---- - -**Clause-②: yes** — new exported symbols on a published package (`bootStackOnce`, `isVerifyRefusal`, and ten new members on the `VerifyStack` every `bootStack` caller already holds), so the accept set a consumer writes against widens. Contract-review tier. - -Every `VerifyStack` now carries an **in-process handle** on the stack `bootStack` boots — a way to run a hook, a validation rule, a flow, an action, a seed or a read against the REAL engine and assert on what the engine did, instead of writing through HTTP and inferring from persisted rows, or rebuilding the engine's semantics in a test stand-in. - -New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged): - -- `hooks.run(object, 'insert' | 'update' | 'delete', input, { as })` — one write through the engine's own door as the caller `as` (a bearer token from `signIn` / `signUp`). The bound hook chain, field defaults, declared validations and the SecurityPlugin middleware run inside it, in the engine's order, because this is the very call the REST data ingress makes. Returns what the engine returned; a refusal rejects with the engine's own error (`code`, `statusCode`). -- `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written. -- `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` — the runtime's `/automation` trigger and resume routes driven in-process (no Hono, no socket): the caller's resolved identity is forwarded exactly as the route forwards it, and the engine's `AutomationResult` comes back (plus `flowName`, so the value hands straight to `resume`). A never-dispatched refusal or a failed run rejects with the route's ADR-0112 envelope. -- `actions.run(object, action, { as, recordId?, params? })` — the `/actions/:object/:action` route driven in-process, the one door carrying the whole action contract (ADR-0066 D4 gate, ADR-0104 param contract, subject-record load, trusted body context). Returns the handler's value. -- `seed(object, rows)` / `rows(object, where?, { as? })` — real ObjectQL writes (the platform's own seed-replay context) and reads (system-scoped, or as a caller under that caller's grants and RLS). -- `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary. -- `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`). -- `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller. - -Also new: `bootStackOnce(config, opts?)`, a per-process memo of `bootStack` keyed on the `config` and `opts` object identities — the worker-scoped shared boot `packages/qa/dogfood` kept privately, promoted for suites that run many files under `isolate: false`. - -Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`. - -**Zero re-implemented semantics.** Every method is a thin facade over a door the kernel wired at boot; the handle assembles no `ExecutionContext`, orders no hooks, evaluates no permission. The package's own tests pin each method against the real service behind it (the PR's ablation record breaks each service in turn and shows only that method's pin going red), pin `hooks.run` against the REST write on the same row **and** the same refusal, and port one hotcrm exemplar (`opportunity_lifecycle`) onto `hooks.run` as the proof of ergonomics. - -No boot option was added: the tenancy posture a stack runs under is still chosen by `multiTenant` (the `--multi-tenant` option `os verify` already has) and read back through `tenancy()`. `os verify`, `runCrudVerification` and `runRlsProofs` are unchanged. diff --git a/.changeset/visiblewhen-app-scope-root-prose.md b/.changeset/visiblewhen-app-scope-root-prose.md deleted file mode 100644 index 5c0991aa42..0000000000 --- a/.changeset/visiblewhen-app-scope-root-prose.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): stop advertising `app` as an expression-scope root the shipping renderer mounts (#17203) - -Six prose faces of the UI schemas told an author that a CEL predicate could name `app` — that the shipping renderer mounts it alongside `features` and `os.user`. It does not, and it never contractually did. `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and ADR-0068 has never ruled it; decision batch #67 (2026-09-07) ruled option B — the engine's `SCOPE_ROOTS` is the contract and ObjectUI aligns to it — and ObjectUI shipped that, so `buildExpressionScope` no longer binds `app`. The producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was closed `not_planned` in the same ruling. - -The `app` token is deleted from all six. `features`, `os.user`, `data`, `current_user`, `record` and `user` all stay, in place and in their existing order, and the "renderer behaviour, NOT contract-guaranteed" framing is unchanged: - -- `ui/page.zod.ts` — the "Ambient roots" docblock, and the **published `.describe()`** on `PageComponentSchema.visibleWhen`, which republishes verbatim into `content/docs/references/ui/page.mdx` (regenerated here). -- `ui/action.zod.ts` — the param-level `visible` docblock, and the **action-level `visible`** docblock, which stated the same claim unbackticked (`record/user/app/features`) and was invisible to a probe shaped for the backticked token. -- `ui/component.zod.ts` — the `page:tabs` ambient-root name-resolution example, and its "also mounts the ambient …" sentence. - -Why this was worth correcting rather than leaving to rot: this `.describe()` is the surface an authoring tool and a metadata-generating agent read (ADR-0033 lists AI as a primary consumer), and it was the last place anywhere that could still teach either to write `app.tier == 'pro'`. The resulting predicate does not fail uniformly and is silent both ways — a field `visibleWhen` and a nav / area `visible` fail OPEN (the gate stops hiding), a conditional-formatting `condition` and a row-action `visible` / `disabled` fail CLOSED (the rule silently stops matching). - -No accept set moves: `SCOPE_ROOTS` is untouched, every schema parses exactly what it parsed before, and a predicate naming `app` is accepted and rejected precisely where it was. This narrows what the protocol advertises, and nothing else. A pin test now holds all six faces, published and TSDoc alike. diff --git a/.changeset/wild-jars-hammer.md b/.changeset/wild-jars-hammer.md deleted file mode 100644 index 5134e3d9b6..0000000000 --- a/.changeset/wild-jars-hammer.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct what `retiredFromLoadPath` declares about its own reach. - -The flag's docs said a retired conversion is "never at load" and that "the load -seam never sets this — only `objectstack migrate meta` (and the fixture CI) -replays it". Neither half held. Three data-at-rest call sites pass -`includeRetired: true` on purpose — `applyConversionsToStoredItem` (which pins -it rather than offering it), flow rehydration in the automation engine, and the -artifact-ingestion door `applyArtifactForwardConversions` — and `migrate meta` -does not reach the option at all: `applyMetaMigrations` looks each step's -conversion up by id and calls `apply` directly. - -What the flag actually governs is the **authoring** surface: it keeps the entry -off `normalizeStackInput`, the single funnel for `defineStack`, `validate`, -`lint`, `compile`, `info` and `doctor`, so a live author meets the tombstone -instead of a silent rewrite. That split is what ADR-0087's -`## Addendum (2026-07-31)` and the artifact-door ruling both bought. - -Documentation only — no behaviour, no schema key and no export moves. The -corrected text ships in `dist/*.d.ts`, and the split it describes is now pinned -by a test that drives `normalizeStackInput` and `applyConversionsToStoredItem` -over the same bytes, so the sentence and the behaviour cannot drift apart again. - -Authors setting this flag on a **default flip** (old and new shapes both legal, -meaning different things) should read the corrected doc: the flag does not -confine such a rewrite to history — the data-at-rest seams still apply it. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 04b848ed3c..c837fccd5b 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -74,7 +74,7 @@ docker run -p 8080:8080 \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET \ -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.0 ``` (`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -92,7 +92,7 @@ docker run -p 8080:8080 \ -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.0 ``` Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly @@ -143,7 +143,7 @@ COPY . . RUN npx os build # → dist/objectstack.json # ── Runtime: the official ObjectStack runtime image ────────────────── -FROM ghcr.io/objectstack-ai/objectstack:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.0 COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json ``` @@ -161,7 +161,7 @@ image)? The official image is nothing more than: ```dockerfile title="Dockerfile (self-built runtime, equivalent)" FROM node:22-slim -RUN npm install -g @objectstack/cli@17.4.0 +RUN npm install -g @objectstack/cli@17.5.0 WORKDIR /srv/app RUN chown node:node /srv/app diff --git a/content/docs/releases/index.mdx b/content/docs/releases/index.mdx index 2240dfba24..43cfec5667 100644 --- a/content/docs/releases/index.mdx +++ b/content/docs/releases/index.mdx @@ -18,7 +18,7 @@ migration steps, then covers new capabilities and notable fixes. ## Versions -- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.4.0, released 2026-09-09). +- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.5.0, released 2026-09-13). - [v16.0.0](/docs/releases/v16) — One org identifier (`organizationId`) across hooks and actions, quorum + per-group sign-off (会签) approvals with metadata-declared decision actions, time-relative automations, filtered roll-ups, strict dashboard widgets, an identity-scoped MCP stdio transport, and a platform-wide enforce-or-remove sweep that makes dead metadata loud; 16.1 adds a `requires` capability-provider preflight, two more dashboard build gates, and `runAs:'user'` automations that run with the triggering user's real grants (final release: 16.1.0). - [v15.0.0](/docs/releases/v15) — Explain record access layer by layer, a docked AI workspace in the Console, project-ready Gantt charts, and phone sign-in; 15.1 adds permission-following attachments, no-code third-party connectors, dashboard-wide filters, pinyin search, and whole-record inline editing — with materially safer multi-tenant and write-path defaults (final release: 15.1.1). - [v14.0.0](/docs/releases/v14) — ADR-0090 vocabulary convergence completed, object `enable.*` flags become real gates, admin user management, phone/SMS auth, book-audience enforcement, data-lifecycle contract, and effective-dated grants (final release: 14.8.0). diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 3ec0727d73..a06a96740c 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -51,7 +51,7 @@ The official image is `ghcr.io/objectstack-ai/objectstack`, and its tags mirror ```bash # docker-compose.yml, or your orchestrator's manifest -image: ghcr.io/objectstack-ai/objectstack:17.4.0 +image: ghcr.io/objectstack-ai/objectstack:17.5.0 ``` On a host running the artifact directly under systemd, the same move is a file diff --git a/docker/README.md b/docker/README.md index 75bd9cf7dc..80da9e3880 100644 --- a/docker/README.md +++ b/docker/README.md @@ -29,7 +29,7 @@ Multi-arch: `linux/amd64` + `linux/arm64`. [Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting)): ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.0 COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json ``` @@ -40,7 +40,7 @@ docker run -p 8080:8080 \ -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.0 ``` `OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -72,7 +72,7 @@ for a `file:…` path — one box only, wrong for multi-node) and MongoDB (`libsql://…` / Turso). Add one by extending the image: ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.0 USER root RUN npm install -g tedious USER node @@ -100,5 +100,5 @@ reverse-proxy / multi-node guidance: ## Local build of this image ```bash -docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.4.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.5.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 0c0788285c..65db7b80bc 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,134 @@ # @objectstack/example-crm +## 4.0.97 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [ea4d164] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/runtime@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/service-i18n@17.5.0 + ## 4.0.96 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 681122206f..b848647299 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.96", + "version": "4.0.97", "description": "Minimal CRM example \u2014 a smoke-test workspace that exercises the metadata loading pipeline (objects \u2192 views \u2192 app \u2192 dashboard \u2192 hook \u2192 flow \u2192 seed). For a full-featured enterprise CRM see https://github.com/objectstack-ai/hotcrm.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-multi-package/CHANGELOG.md b/examples/app-multi-package/CHANGELOG.md index ad88a3a4ff..0b5293ea1d 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/example-multi-package +## 0.0.4 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 0.0.3 ### Patch Changes diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json index 4955290209..107d3a5f26 100644 --- a/examples/app-multi-package/package.json +++ b/examples/app-multi-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-multi-package", - "version": "0.0.3", + "version": "0.0.4", "description": "One release artifact carrying TWO packages that share a namespace (ADR-0130 D4) — the producer-side fixture for `packages[]`", "license": "Apache-2.0", "private": true, diff --git a/examples/app-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index fdef41f669..c26808ef23 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,154 @@ # @objectstack/example-showcase +## 0.3.19 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [7c2c5ae] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [ea4d164] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/runtime@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/cloud-connection@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/connector-mcp@17.5.0 + - @objectstack/connector-openapi@17.5.0 + - @objectstack/connector-rest@17.5.0 + - @objectstack/connector-slack@17.5.0 + ## 0.3.18 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index d98f17aa53..17be7fd276 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.18", + "version": "0.3.19", "description": "Kitchen-sink showcase workspace — exercises every metadata type, every view type, every chart type, and the major end-to-end capability chains (security, automation, analytics). Built for demonstration, debugging, and coverage-driven verification.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-todo/CHANGELOG.md b/examples/app-todo/CHANGELOG.md index 56769e423a..e6fd6f2f81 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,171 @@ # @objectstack/example-todo +## 4.0.97 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [f19dbcf] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [7baf04a] +- Updated dependencies [6b2ec3b] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [400167a] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [ea4d164] +- Updated dependencies [01388fe] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [032452a] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/runtime@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/knowledge-memory@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 4.0.96 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 48173191e1..60250bdbf8 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.96", + "version": "4.0.97", "description": "Example Todo App using ObjectStack Protocol", "license": "Apache-2.0", "private": true, diff --git a/examples/embed-objectql/CHANGELOG.md b/examples/embed-objectql/CHANGELOG.md index 13626bd789..ad6393146a 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,136 @@ # @objectstack/example-embed-objectql +## 0.0.37 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/driver-memory@17.5.0 + ## 0.0.36 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index e60ead7f02..736c48b957 100644 --- a/examples/embed-objectql/package.json +++ b/examples/embed-objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-embed-objectql", - "version": "0.0.36", + "version": "0.0.37", "private": true, "description": "Embed the ObjectQL engine as a plain library via @objectstack/objectql/core — no kernel, no plugins, no metadata protocol (ADR-0076).", "type": "module", diff --git a/packages/adapters/hono/CHANGELOG.md b/packages/adapters/hono/CHANGELOG.md index 7e02a1a80a..0f939c158e 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,36 @@ # @objectstack/hono +## 17.5.0 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [89a652b] +- Updated dependencies [4af758d] +- Updated dependencies [bdb247d] +- Updated dependencies [cea85fd] +- Updated dependencies [1a25f4a] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [76ddab7] +- Updated dependencies [ea4d164] +- Updated dependencies [c3ebe4a] +- Updated dependencies [cefe068] +- Updated dependencies [288fe9c] +- Updated dependencies [6e3462d] +- Updated dependencies [331a1a2] +- Updated dependencies [e6965dd] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [0ced0aa] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 389dc2f5e1..4fd636ad50 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apps/account/CHANGELOG.md b/packages/apps/account/CHANGELOG.md index 8b2c00325d..c922fcd2ff 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,125 @@ # @objectstack/account +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index db70f15728..746221f765 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack Account — the end-user account/self-service console app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/setup/CHANGELOG.md b/packages/apps/setup/CHANGELOG.md index a1a5027d1e..d23f28e366 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,125 @@ # @objectstack/setup +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index fc4866f62d..4eba739f61 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack Setup — the platform administration app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/studio/CHANGELOG.md b/packages/apps/studio/CHANGELOG.md index f3fe0e382c..ec59f1cf63 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,125 @@ # @objectstack/studio +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index a18b7881a6..b88d2549e9 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack Studio — the metadata builder app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 36d8ee67c7..635753033a 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,1738 @@ # @objectstack/cli +## 17.5.0 + +### Minor Changes + +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- 89a652b: feat(cli): `objectstack dev --cert --key ` terminates TLS in the dev process, and the canonical origin follows the listener (#16804) + + An interactive MCP client refuses to start an OAuth sign-in against a non-TLS + URL, so the self-serve identity path the product advertises — "interactive + clients just open a browser login" — could not be exercised against a local dev + server at all. The only way round it was a hand-built https reverse proxy plus + `OS_AUTH_URL`, a page of setup that every developer, demo and video recording + repeated off-camera. + + **Bring your own certificate.** Nothing here generates one, and nothing here — + not the code, not `--help`, not any doc page — says anything about installing a + certificate into a system trust store. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何 + 「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. The trust store is the + developer's own business; this feature's whole job is to *use* the certificate + they already have. + + ```bash + objectstack dev --cert ./localhost.pem --key ./localhost-key.pem + ``` + + Both flags are required together — half a pair is refused by name — and an + unreadable file is refused rather than degraded to a plain-http listener. + + **What follows the listener.** With both flags given, everything this boot + advertises is `https://localhost:`: the two `/.well-known/*` discovery + documents, the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the + `🤖 MCP server` connect hint, and the runtime state file the `os dev` parent and + external supervisors dial. Only the built-in default at the end of the base-URL + chain moves — `OS_AUTH_URL`, `BETTER_AUTH_URL` and `OS_BASE_URL` keep winning, + an `http://` value included, because they name where a deployment is *reached* + rather than what this process *bound*. + + **Without the flags nothing changes**, byte for byte — pinned by ablation legs + rather than asserted. + + `@objectstack/plugin-hono-server` gains the option this is built on: + `HonoPluginOptions.tls` (`{ cert, key }` PEM bytes) makes the adapter bind a TLS + listener with the same fetch handler, the same route table and the same graceful + drain. Absent, the listener is plain http exactly as before. +- ed5a1e7: `os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent process when one holds the channel. A script that spawns a dev server can finally wait for the boot to finish without reading the child's output. + + `✓ Server is ready` is true about the HTTP server and says nothing about the app. Seeding races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) and past it finishes in the background, so the banner can be a minute ahead of the seed's own result — measured downstream at **82 seconds of silence after the banner, then 120 `ERROR` lines**. The same command on the same corpus settles before the banner on a machine where the seed fits its budget, so the defect is invisible on exactly the boxes that would have caught it. Everything that distinguishes the two cases arrives on the child's inherited stdio, and reading that costs the boot its TTY. + + - **The producer is not new.** `@objectstack/runtime` already declares every seed source and settles it at the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This is the hop outward: the CLI subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. No service is registered and no tally is mutated — the contract is read-only by design. + - **Sent once, and never before `objectstack:listening`.** Seeding that settles during `runtime.start()` is latched and released after the bound port is published, so a parent that waits for the listening message and only then listens for the settle cannot miss it. + - ⛔ **Keyed on `inFlight`, not `pending`.** Multi-tenant replay and `skipSeedData` register a seed source and deliberately never run it, keeping `pending` above zero for the life of the process. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this closes, one level up. Those boots get the message with `suppressed` reasons attached instead, so a consumer can say *why* no rows landed. + - **Failure settles too.** A seed that failed has still come to rest; withholding there would recreate the hang. `ok` is a verdict on the per-source counts the boot recorded, and the message carries those counts. + - **The over-budget banner no longer omits seeding.** `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was ABSENT and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing`, with a line saying seeding continues in the background; suppressed sources are named rather than reported as pending. + + ⛔ An ipc channel is **not** made a requirement of either command: `process.send` is undefined under an ordinary terminal boot, both sends are no-ops there, and no byte of that transcript changes. Nothing in the existing `objectstack:listening` publication moves. + + Note that `os dev` consumes `objectstack:listening` itself (it is how the bound-port readout and the MCP connect hint learn the real port) and relays only `objectstack:seed-settled`. Spawn `os serve` directly to receive both in one place. +- 49cd715: feat(cli)!: `os generate` refuses a name whose barrel alias no consumer could import by name (#17410) + + `os generate view class` exited **0** and wrote `export { default as class } from './class.view';`. That line parses — an ES module export clause admits a reserved word as a `ModuleExportName` — so both landed layers admitted it, each correctly by its own terms: the #16726 charset gate because every character of `class` is a lowercase letter, and the #16541 parse check because the bytes really are parseable TypeScript. The import side is not: `import { class } from './views'` needs an `ImportedBinding`, and a reserved word is not one. So the command reported success and produced a barrel entry nothing can name, with the failure deferred into the author's own file where it reads as their mistake. + + A third layer now stands behind those two. After the identifier is derived and before anything is written or previewed, the barrel alias is put through TypeScript **in the exact position a consumer must write it**, and the command refuses when the compiler will not take it — naming the constraint, showing the line that would have been written, and writing nothing. This delivers the #16726 ruling's own closing sentence, 「`os generate view class` is therefore refused at the door rather than emitting a barrel line that binds a reserved word.」, which the charset mechanism specified in that same ruling could not. + + ⛔ **No third charset** — the #16726 ruling forbids one and none is added: no character is judged. ⛔ **Nothing is rewritten.** Emitting a non-reserved alias while keeping the authored name was the other option and it loses on the reasoning that already refused option B: it decouples the name the author wrote from the name that gets emitted, silently. So this refuses, and the name you author stays the name that lands. + + **What this narrows:** 46 names — the 36 always-reserved words (`class`, `new`, `enum`, `default`, `import`, …) plus the ten reserved because a module is automatically in strict mode (`let`, `yield`, `static`, `implements`, `interface`, `package`, `private`, `protected`, `public`, and `await`, reserved at a module's top level). Every one is charset-legal and every one used to reach `exit 0` for the six generators that suffix their `const` binding (`view`, `action`, `flow`, `dashboard`, `app`, `skill`). The seventh, `object`, binds the bare identifier, so the parse check already refused **some** of them there — but only the always-reserved ones: `os g object let`, `os g object yield` and `os g object static` also exited 0, because a strict-mode reservation is a semantic diagnostic and that check is syntactic. Pick a name that survives as an import binding — `os g view order_line` works, and binds `orderLine`. + + **This is an observable change to accepted input:** those 46 names exit **0** today and will exit non-zero after this lands. Every one of them produced a barrel entry no consumer could name, so this is the fix rather than a break — but if you script `os generate`, a name in that set now stops the command instead of writing an unusable file. + + **One durability note.** The refused set is decided by the TypeScript compiler, asked in position, rather than by a list this package keeps — which is why it is right in both directions today. The consequence is that a TypeScript upgrade can move it: a word that becomes reserved starts being refused, and a word that stops being reserved starts being accepted. Both are correct, neither is a regression, and neither is predicted by a changeset. + + **What this deliberately does NOT narrow:** contextual reserved words. `type`, `as`, `from`, `async`, `get`, `set`, `of`, `keyof`, `readonly`, `satisfies`, `infer`, `declare`, `namespace`, `using`, `accessor`, `undefined`, `arguments`, `eval` and the rest are legal import bindings, they generate today, and they still generate. Refusing one of them would break a name that works — the expensive failure direction, and the one a hand-written keyword list gets wrong. There is no keyword list here for exactly that reason: a list is simultaneously too narrow (it stops at the obvious 36 and ships the defect for the other ten, which a syntactic-only check cannot even see, because the compiler reports strict-mode reservations as semantic diagnostics) and too wide (it swallows the contextual set). The judge is the compiler, asked in position. + + ⛔ Neither layer in front is relaxed or reordered. `os g object class` still meets the parse check's own diagnostic in the compiler's words, a name outside the charset still meets the schema's own pattern, and the new layer is asked last, so it can only narrow what all three would otherwise have admitted. + + +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d0f06ff: feat(cli)!: `os generate` refuses a metadata name outside the charset `packages/spec` declares for an object `name`, before it derives anything from it (#16726) + + Maintainer ruling, decision batch #82 (2026-09-08), option A — **a gate, not a sanitiser**. `os generate ` used to accept any name at all; since #16724 it has refused names whose emitted TypeScript does not parse. It now also refuses, ahead of that check and ahead of every derivation, any name the object-`name` declaration in `@objectstack/spec` rejects. The refusal names the value and quotes the schema's own rule, and writes nothing. + + ⛔ Nothing is rewritten. The rejected alternative was to derive a legal identifier the way `os create` does, which decouples the name the author wrote from the name that gets emitted with nothing announcing it — the failure mode that multiplies silently when metadata is written in bulk. So the name you author and the name that lands in the file are always the same string. + + **What this narrows:** kebab-case (`order-line`), uppercase (`Order`), dotted (`foo.bar`) and digit-initial (`2fast`) names were accepted before and are refused now — `order-line` used to generate `order_line.object.ts` binding `orderLine`. Write the snake_case name directly (`os g object order_line`). ⛔ No new charset was minted and no flag bypasses the gate; #16724's parse check is unchanged and stays as the backstop behind it (`class` passes the charset and is still refused for `object`, because `const class:` is not a declaration). + + +- e2c2620: fix(cli): `os i18n check` counts the coverage an app actually owns, so `--strict` / `--threshold` can gate an app package (#16681) + + ## What was wrong + + `collectExpectedEntries` walks the Studio metadata-form registries + unconditionally — identically for every config, an empty one included — so + every stack's expected set carries ~773 `metadataForms.*` keys that + `@objectstack/platform-objects` translates and the runtime already serves. + + Two of the three commands that see that family already knew it is not the + author's. `os lint` hides it and says so ("platform built-ins: 773 i18n + issue(s) hidden — rerun with `--include-platform`"); `os i18n extract` has + `--no-metadata-forms`. `os i18n check` is the one command that publishes a + **percentage**, and it carried the baseline in its denominator: + + ``` + Coverage by locale + en ████████████████████████ 100.0% (1265/1265, missing 0) + zh-CN █████████░░░░░░░░░░░░░░░ 38.9% (492/1265, missing 773) + ``` + + That is an application with every key it owns translated. `--strict` and + `--threshold` — the two flags whose entire purpose is CI gating — therefore + could not gate an app package at all, and the only way to move the number was + to ship a copy of the platform's bundle, which would *override* the platform's + own and go stale at the next upgrade. The workaround was worse than the defect. + + ## What it does now + + **Ownership is observed, not assumed.** The baseline counts toward coverage + when the stack under examination ships those translations itself, and does not + when it does not — read from the config's own `translations` bundles, requiring + a non-empty string leaf so an `--fill=empty` scaffold is not mistaken for a + claim of ownership. An app gets a number about its own surface with no flag; + `platform-objects`, which does ship the family, stays gated on it with no flag + either. An unconditional exclusion would have turned the app side green by + deleting the platform's own gate, and is what the negative-control tests forbid. + + **The flag is `os lint`'s, spelling and all.** `--include-platform` forces the + baseline in; `--no-include-platform` forces it out, for a package that ships a + partial baseline and does not intend to own the rest. Absent, the decision is + the observed one — three states, not two. + + **Both output faces carry the decision.** `--json` gains + `platformMetadataForms: { mode, excludedKeys }`, and the console prints + `platform built-ins: N key(s) not counted — rerun with --include-platform to + gate them here` under the coverage table, rendered from those same two numbers. + + `os lint` is unchanged. The shared `computeI18nCoverage` seam still counts the + baseline by default, because lint folds it away one seam later and counts what + it folded for its own hint line. + + ## Compatibility + + Additive on the command surface; an invocation that was refused is now + accepted, and no flag is removed or renamed. The behaviour that changes is the + **default coverage number for a stack that ships no `metadataForms` bundle** — + it stops reporting a debt that stack must not pay. A run that wants the old + numbers back asks for them with `--include-platform`, on the same argv. +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + +- fb39b38: fix(cli): `os migrate meta --from N` — the invocation every tombstone prescribes — lists the conversions it was sent to list, and an empty range stops reading as success (#17134) + + `--to` defaulted to `PROTOCOL_MAJOR`, the major the runtime implements. But retirements land throughout a major's line, and their ADR-0087 conversions are registered under the NEXT one: `@objectstack/spec@17.4.0` tombstones `dashboard.refreshInterval` while the conversion that renames it is `toMajor: 18`. The `retiredKey()` house sentence names the major the source was **authored** against — `Run \`os migrate meta --from 17\` …` — so the prescribed invocation composed the range `17 → 17`, which `composeMigrationChain` selects **no step** for, and the command answered: + + ``` + ✓ Nothing to migrate — the metadata is already canonical for this range. + ``` + + exit 0, printed immediately under the five refusals that named that exact command. **29 shipped tombstones across 15 source files prescribe it.** + + Two changes, both in `packages/cli`: + + - **`--to` now defaults to the highest major this build of `@objectstack/spec` carries a migration step for** (`Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS)`), so the tombstone template's presumption holds in every window rather than only after the next major has shipped. Nothing is migrated "past" the runtime: every registered conversion maps a shape the installed schemas already **refuse** onto the one they accept, which is why the terminus is the only target for which the command's own `schemaValid` verdict is reachable. `Math.max` keeps the runtime's major as the floor for the reverse case. + - **A range holding no step is answered as one.** `already canonical` was a green verdict on a check that never ran, so the empty-range case now says so, names the range that would list the conversions (`--to N`), and no longer returns past the schema verdict that contradicted it — the same run used to report `schemaValid: false` in `--json` while the human output claimed the metadata was canonical and stopped. + + **What changes for you.** `os migrate meta --from ` with no `--to` now replays one hop further than it did, so a cross-major run prints that hop's semantic TODOs as well — the same wall a `--from N-1` run has always printed, one major on. The mechanical rewrite list is still first. `--to` is unchanged when you pass it, `--stored` is untouched, exit codes are unchanged (this command reports findings, it does not exit on them), and a range that holds real steps and rewrote nothing still answers `Nothing to migrate`. +- cca1dc0: + + feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) + + **BREAKING** — two published machine surfaces change a key name. There is **no alias + and no dual-key transition window**: one axis, one name. + + | Surface | Was | Now | + |:--|:--|:--| + | `os migrate meta --json` payload | `runtime` | `protocolVersion` | + | `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | + | `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | + + The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol + major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload + moves — no other key is added, removed or reshaped, and both text faces are byte-identical. + The parameter rename is positional, so no call site changes. + + ## Why the name had to move + + `PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed + `@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* + it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or + a stale install, next to the real package versions of the same upgrade session. + + The human line was repaired first and now reads + `Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the + worse half and was left standing, because a key on a published payload is a contract + change: an agent scripting an upgrade has no prose to disambiguate at all, and the + diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer + does not parse. + + ## What a consumer should do + + Read the new key. The old one is absent, so a consumer that does not move reads + `undefined` rather than a wrong value. + + ```diff + - const v = payload.runtime; // os migrate meta --json + + const v = payload.protocolVersion; + + - const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE + + const v = err.diagnostic.protocolVersion; + ``` + + The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` + spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it + from the package install boundary, and `@objectstack/services-package` reads it during + hydration — so a consumer reading it from any of those reads the new name too. + + `runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer + protocol major, not a semver in a version position, and it does not carry the ambiguity + this rename closes. + + The breaking surface was measured before the rename and is closed inside this repository: + the only reader of the `--json` key was this repo's own e2e pin and the only reader of the + diagnostic member was `metadata-core`'s own unit test, both of which move in this same + change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever + naming the field. **Zero external consumers were found.** Graded `minor` rather than + `major` for the launch window; the banner above carries the breaking-ness the level cannot. +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 87ad73b: + + feat(cli)!: the `--json` payload key `specVersionGap` is renamed to `protocolVersionGap` (#14261) + + **BREAKING** — a published machine surface changes a key name. `os validate --json` and + `os build --json` emit **`protocolVersionGap`** where they emitted `specVersionGap`. A + consumer reading `specVersionGap` reads `undefined` after this release and must switch to + the new name. There is **no alias and no dual-key transition window**: one axis, one name. + + The value shape is unchanged — `null` when the app's declared compatibility range admits + the installed `@objectstack/spec`, otherwise the same advisory record with the same + members. Nothing else on either payload moves: no other key is added, removed or + reshaped, and the text faces of both commands are byte-identical. + + ## Why the name had to move + + The axis this advisory reports moved in **#13860**: it used to read the undeclared + `manifest.specVersion` and now reads `manifest.engines.protocol`, which is declared + (`PluginEnginesSchema`), stamped by every scaffold, and enforced at boot. The published + key name stayed behind for one release, deliberately — renaming a machine face with + pinned consumers is a break, and no ruling covered it at the time. + + Leaving it is a correctness problem, not untidiness. A key spelled `specVersion*` invites + the reader — an AI agent above all — to infer that a writable `manifest.specVersion` + exists. `ManifestSchema` is not `.strict()` and **silently drops unknown keys** (#14192), + so acting on that inference does not produce an error: it produces a manifest that looks + entirely normal and whose `specVersion` line never took effect. That is the same + ghost-key breadcrumb mechanism that caused #13860 in the first place, left standing on + the output side. + + ## What a consumer should do + + ```diff + - if (payload.specVersionGap) { … } + + if (payload.protocolVersionGap) { … } + ``` + + The breaking surface was measured before the rename and is closed inside this repository: + the only consumers of the old key were three in-repo e2e suites, which move in this same + change; **zero external consumers were found**. Graded `minor` by the maintainer's + explicit grading of 2026-09-02; the banner above carries the breaking-ness the level + cannot. +- 0aa88eb: `os package publish` no longer publishes under a manifest id the author did not write. A `manifest.id` the artifact declares is now used or refused — never silently swapped for a derived one. + + Before this, `deriveManifestId` adopted `manifest.id` only when it parsed as `PackageSchema.manifestId`, and any other declared value fell through to `local.`. Nothing said so: the substituted id appeared in the ordinary progress line, byte-identical to the run where the artifact declared no id at all. + + ``` + manifest.id = 'crm' before: → Registering package 'local.acme-crm'... (exit 0) + manifest.name = 'Acme CRM' + after: ✗ Invalid manifest-id 'crm'. … (exit 1) + ``` + + `sys_package.manifest_id` is **immutable once set** — "renaming a package requires creating a new package" — so the value chosen there is a permanent, globally unique identifier. Choosing it silently, against the author's own declaration, is the one field that must not be rewritten without a word. + + - **A declared `manifest.id` reaches the existing preflight gate.** If it is not a manifest id the control plane accepts, the publish refuses before any network call, quoting the schema's own issue and description and naming where the id came from. No second rule is introduced in the CLI: the judgement is still `PackageSchema.manifestId`, which is the same schema node `CreatePackageRequestSchema.manifestId` declares for the `manifest_id` this command POSTs. + - **Honouring the declared value instead was not available.** The values that used to fall through are, by construction, exactly the ones that schema rejects, so forwarding one would only move the same refusal to the server, later and with a worse message. + - **Absent, blank and non-string `manifest.id` are unchanged** — none of those is a declaration, and each still derives from `manifest.name`, then the artifact filename. + + What to do if a publish that worked now refuses: the message names the three ways out. Fix `manifest.id` in `objectstack.config.ts` to a reverse-domain id and rebuild; remove the key to keep publishing under the derived `local.…` id (the value the previous release was already using); or pass `--manifest-id`. Every id the control plane accepts publishes with unchanged bytes. +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. + +### Patch Changes + +- bd25e89: `bin/run.js` — the entry `os` / `objectstack` names — resolves its commands from `dist/` whatever an ambient `NODE_ENV` says, so an exported `NODE_ENV=development` no longer kills the CLI in a project whose tsconfig maps a package to TypeScript source (#12271). + + `@oclif/core` skips its TypeScript path lookup only when `isProd()` — a negated `['development', 'test'].includes(NODE_ENV)`. Under either value it resolved the CLI's **own** command modules from `src/` and registered tsx on the way, and tsx honours the tsconfig of the **current working directory**. An application that maps a CommonJS workspace package to its TypeScript source for *type* resolution — `"@objectstack/formula": ["../../packages/formula/src/index.ts"]` — therefore steered this CLI's *runtime* module graph into `.ts` files, after which Node's CommonJS resolver walked their extensionless siblings and found nothing: + + ``` + [MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts: + Cannot find module './registry' + ``` + + Measured at two example apps with `NODE_ENV` as the only variable: `os compile`, `os dev --compile --fresh`, `os serve --dev` and `os start` each exited 1 on that signature under `development`, and each compiled or booted cleanly under `production`. The app with no `paths` block was the only one unaffected. + + - **The fix is one declaration**: `settings.enableAutoTranspile = false`, checked by oclif ahead of `isProd()`. `bin/run.js` is the built entry and `bin/run-dev.js` is the source entry — a division `check:cli-test-child-env` already enforced on every test that spawns the CLI; the entry simply never asserted it about itself. + - ⛔ **Not a child-environment scrub.** `os serve --dev` and `os start` are top-level processes with no parent to scrub, and the casualty was the CLI's own command table rather than the user's config, so no per-spawn `NODE_ENV` handling could reach it. + - **`NODE_ENV=development objectstack start` works again** — the debugging mode `os start` has advertised in a comment all along, and did not deliver. + - ⚠️ **What it costs, measured**: the only thing oclif keeps its TypeScript lookup alive for in production is a **linked** plugin, so a `plugins link`ed TypeScript plugin would no longer be auto-transpiled through the published entry. That path is not reachable today — `@oclif/plugin-plugins` sits in `devDependencies` and oclif's core-plugin loader only matches names under `dependencies`, so `os plugins` is not a registered command (`os --help` lists 34 topics and none is `plugins`), which is what `content/docs/plugins/index.mdx` already documents. On an unbuilt checkout the entry now answers oclif's `command not found` under `development`/`test` exactly as it already did with `NODE_ENV` unset. +- 2d5945a: `serve.ts`'s observability knob block points at the cloud mirror in the house style, keeps the sync duty, and names the package that owns the list (#15295) + + The block above `buildServeObservability()` instructed the reader to *"keep the + two in sync"* with `apps/cloud/server/observability.ts` — a path that has not + existed in this repository since `apps/cloud` moved to `objectstack-ai/cloud` + (`git ls-tree origin/main -- apps/` returns exactly `apps/docs`, the positive + control that makes that a reading rather than a broken query). A reader was + being sent to a file they cannot open, with no hint that it lives in another + repository. + + **The duty is live, so it stays.** The cloud file still exists and still reads + these names as `process.env` lookups (measured on `objectstack-ai/cloud` and + recorded on #15295, with that file's own `process.env` hit count as the firing + control) — for every knob in the block except `OS_OTLP_FLUSH_MS`, which was + added on this side after that measurement and is therefore unverified rather + than mirrored. The comment states that boundary rather than a bare count, so a + reader counting six entries under a claim about five cannot be misled about + which of them the reading covers. Deleting the clause would have dropped a real + obligation whose failure mode is quiet: the two exporters drift and the cloud + host stops reading the variables an operator set. + + Three things change, all inside one comment block: + + - the path is re-spelled in this repo's settled style for a cloud-repo + reference — ``(`apps/cloud/server/observability.ts`, cloud repo)``, the form + at `packages/services/service-cluster/src/multi-node-gate-mount.ts:9`; + - the duty is narrowed to what its own words say — **names, not defaults**. + `OS_OBS_SERVICE_NAME` defaults to `objectstack` here and to + `objectstack-cloud` there *deliberately*, because two deployments are two + services; a future reader "tidying" that into one value would merge both + deployments into a single telemetry series. The comment now says so, which is + the point of writing it down rather than leaving it to be rediscovered; + - the canonical home for the variable list is named as + `@objectstack/observability` — the package **both** consumers already import + — instead of two consumers pointing at each other. That mutual pointing is + the decay mechanism itself, and it is still one-sided today: the cloud file + carries no reciprocal sentence, so nobody renaming a name over there is + prompted to come back here. + + ⛔ No behaviour changes, and no observability code path was touched. No env var + is added, removed or renamed; no default moves. + + **This ships, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/cli`'s published `files[]` is + `["dist","README.md","CHANGELOG.md"]`, and this package builds with plain `tsc` + (no `removeComments`), so the block is emitted verbatim into the tarball — + measured on the rebuilt artifact: the new clause is present in + `dist/commands/serve.js` (1 occurrence, and the knob-list line as control + resolves to that one file), the old spelling is absent from all of `dist`, and + `dist/commands/serve.d.ts` carries 0 of it because the block sits above a + non-exported helper. So the published JS bytes move while the declaration + surface does not. +- 9c577c1: fix(platform-objects,cli): the generated i18n staleness predicate judges every section a run generated, not two fixed names + + `os i18n extract --no-objects-only --fill=default --source-hashes` emits + `apps` / `dashboards` / `pages` leaves and fills them from the source locale — + leaves carrying exactly the property the GENERATED staleness predicate exists to + judge — but the population that predicate walked was the fixed + `GENERATED_SECTIONS` list (`['objects', 'metadataForms']`). So no provenance + record was written for such a leaf, none was read back, and a `--fill=default` + copy left behind by a revised source kept being served as a superseded draft + with every i18n gate green. The hand-authored predicate does reach those paths, + but it judges against `LOCALE.source-hashes.ts`, which by construction carries + no entry for a leaf a generator produced. Neither mechanism covered them. + + The population now follows the RUN, at both ends: + + - **write** — `collectFilledFromHashes` takes a new **optional** fourth + parameter, `sections?: readonly string[]`, defaulting to `GENERATED_SECTIONS`. + `collectGeneratedLeaves` takes the same optional second parameter. Every + existing call site compiles and behaves exactly as before; `os i18n extract` + passes the sections it actually built. + - **read** — `findStaleFills` walks the sections the recorded table itself + names. One run wrote that table, so the table is the record of what that run + emitted, and the two ends cannot disagree about it. For every table committed + today this resolves to `['objects', 'metadataForms']`, so no served byte moves. + + Adding `'apps'` to `GENERATED_SECTIONS` was the other available shape and is + deliberately not taken: it would make `collectSourceLeaves` and + `collectGeneratedLeaves` walk one section — two predicates permanently on one + path — and it would assert `apps` is always generated, which is false for every + bundle set that ships. Both constants are unchanged and pinned unchanged. + + Widening the generated population is safe in a way widening the hand-authored + one would not be, because the rule is self-discriminating per leaf: a record is + written only when `value === currentSource` or `previous[path] === hash(value)`, + so a leaf someone actually translated satisfies neither and stays + legacy-trusted however wide the walk. The section list was the only part of the + mechanism that could not tell a fill from a translation. + + No committed bundle or companion byte moves in this repository. All nine + `--source-hashes` configs run the default `--objects-only`, whose commit layer + already narrows the run's table to the sections it emits a bundle for. The 387 + hand-recorded digests across `zh-CN` / `ja-JP` / `es-ES` are neither read, + written, shadowed nor lost — `apps` stays in `HAND_AUTHORED_SECTIONS`, + `collectSourceHashes` still walks it, and the extractor still never writes that + file. Its header now states which table a maintainer keeps for a path that can + appear in both, and why the overlap cannot serve wrong text. +- f721ef0: fix(cli): the boot banner's `🔑 Dev admin` says what that account will and will not see (#17081) + + `--seed-admin` (on by default in `os dev`) prints one credential, and it is the + **only** one a first-run operator is given. It is also, by construction, the + account with every *platform* capability and no *app-declared* one: its standing + is `admin_full_access`, whose `systemPermissions` are `setup.access`, + `studio.access`, `manage_users`, `manage_metadata`, `manage_platform_settings` + and `manage_sharing` — all platform built-ins — plus the `'*'` + view-all/modify-all record bits. + + So in any app that gates its apps, tabs or nav entries on + `requiredPermissions` — the filter `/me/apps` and `/meta/app` apply, and a + first-class platform feature the docs teach — the credential the terminal hands + over is the account that resolves to an **empty navigation**. A downstream + maintainer ran `pnpm dev`, signed in with it, and read the empty shell as a + broken product. The app was correct. The banner had asserted a login and said + nothing about its audience, and it outranks whatever the app's own README says, + because it sits directly under the command that was just run. + + FROM → TO, on a boot that seeds: + + ``` + 🔑 Dev admin: admin@objectos.ai / admin123 + seeded on empty DB · dev only — do not use in production + + platform admin — Setup, Studio and every record, but NO app-declared capability, so + + an app that gates navigation on requiredPermissions may show it an empty menu; grant + + it a permission set under Setup → Users, or sign in as an account your app seeds + ``` + + **Nothing about the seed changes.** What the first run creates — the account, + its address, its password, its promotion to platform admin — is a product-shape + decision and is untouched; only the banner's words move. The three lines print + only inside the branch that already prints the credential, so a boot that seeds + nothing is byte-identical to before. + + Dim continuation lines rather than a warning, deliberately: ADR-0115's + `OS_ALLOW_DEV_PLUGIN` amendment excluded the dev-admin seed from that hazard set + because "a warning about a non-event spends the attention the real ones need". + That exclusion is kept — this qualifies an event that just happened, on the line + that already announces it, and adds no new line where there was none. + + The route the sentence names is asserted against the declarations that make it + reachable, not re-spelled: `SETUP_APP.requiredPermissions` is a subset of what + this account holds, the `Users` entry is ungated, and the `sys_user` detail page + carries the "Grant permission set" related list. A rename on any of those reds + the pin instead of leaving the banner pointing at nothing. +- fce7cd4: The scaffolded `pnpm-workspace.yaml` records the retired `@better-auth/scim>better-call` peer rule instead of advertising it as live + + `objectstack init` wrote a paragraph into every project it scaffolds explaining + an `@better-auth/scim>better-call` suppression that is not in the map it + annotates — the entry retired with objectstack#3653, and `init.test.ts` pins its + absence. All three of its claims were false on today's tree as well: + `@better-auth/scim` is not "held at a release candidate deliberately" (it is + pinned at exact stable `1.7.3`), and stable `@better-auth/scim@1.7.3` declares + `peerDependencies["better-call"]` as the exact string `1.4.0` — the single copy + `better-auth@1.7.3` itself depends on — so the `1.3.7` skew the paragraph + described does not exist. + + It now records the retirement, in the shape `create-objectstack`'s bundled + `blank` template already used, and dates the measurement the way the + neighbouring `better-sqlite3` paragraph in the same block does. Both scaffold + paths previously named `1.7.1` as the current pin; both now name the measured + `1.7.3`, so the two paths tell a user the same thing. + + Comments only — no declaration moves. The rendered `allowedVersions` map is + byte-identical before and after, so no resolution, lockfile or suppression + changes. +- d07fc17: `os i18n extract` reaches a `screen` node nested inside an ADR-0031 flow region + + `walkScreenFlows` (`packages/cli/src/utils/i18n-extract.ts`) iterated + `flow.nodes` flat, so a `type: 'screen'` node inside a region — + `loop.config.body`, `parallel.config.branches[].nodes`, + `try_catch.config.try` / `.catch`, nesting arbitrarily — was never reached. It + emitted **no** `flows.NAME.screens.NODE_ID.title` / `.fields.*` skeleton entry + and **no** coverage row. + + **Why that pairing is the defect and not just a missing translation.** A nested + wizard step is a real screen: the executor pauses on it and the client receives + its `ScreenSpec.nodeId`, so `translateFlow` overlays the bundle onto it and the + key is live. With no entry emitted, a translator was never shown the key AND + `os lint` / `pnpm check:i18n-coverage` had no row to demand — the gap was + invisible to the mechanism built to report gaps. A green i18n gate on a tree + whose nested steps render source-locale text was green because the surface was + unreachable, not because the app was translated. + + The node universe now comes from a region-aware descent that reads the one + shared declaration of WHERE a region lives, `FLOW_REGION_SLOTS_BY_TYPE` from + `@objectstack/spec/automation` — the same table `packages/lint`'s + `walkFlowNodes` reads. No local copy of the slot list is introduced: a second + region table in a fourth package is the very shape this defect is an instance + of. + + **Depth deliberately does not enter the key.** Entries stay + `flows.NAME.screens.NODE_ID.*` at every depth, because `lookupFlowScreenCopy` + is keyed by node id alone and the bundle schema knows nothing about depth; a + region path segment would offer a key nothing resolves. A node id repeated at + two depths therefore addresses one bundle slot and collapses to a single entry + (first emission wins, outer before inner) — one slot can serve only one string, + and the resolver overlays that string onto both nodes. + + Seeding is unchanged and applies at every depth: a screen `title` falls back to + the node `label` (what `ScreenSpec.title` draws), and a field `label` falls back + to its `name` as a *derived* seed, so the skeleton stays usable while the + coverage gate demands no translation of a string nobody authored. + + ⛔ No authorable key, bundle shape or export moves — an author who wrote a + nested screen now gets scaffolding and a coverage row where both were silently + absent. Existing keys are byte-unchanged. +- 56103b7: `os validate`, `os build` and `os info` count the objects an ADR-0130 D4 / option-B project actually declares, so `--strict` stops refusing a conforming stack + + `collectMetadataStats` — the one reader behind the metadata summary all three + commands print — counted every collection at the **top level only**. On an + option-B project (every definition inside `packages[]`, none flattened up) the + summary reported `Data: 0 Objects`, and `os validate` raised + `No objects defined — this stack has no data model` on a stack that declares a + data model. + + Under `--strict` that warning is not cosmetic. Measured through the real + binaries on the card's repro, before: + + ``` + os validate exit 0 Data: 0 Objects + ⚠ No objects defined — this stack has no data model + ⚠ No apps or plugins defined — this stack may not do much + os validate --strict exit 1 ✗ Strict mode: warnings treated as errors + os build exit 0 Data: 0 Objects + os info exit 0 Data: 0 Objects + ``` + + and after, on the same stack: + + ``` + os validate --strict Data: 1 Objects 2 Fields + ⚠ No apps or plugins defined — this stack may not do much + ``` + + A conforming project that also declares an app now exits **0** where it exited + **1**. + + **The fix reuses the existing fold, and that is what keeps the count a union.** + `authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one + resolution rule for a package-owned collection, and it is strictly additive: a + key the top level already carries wins, because in today's additive shape that + array already *is* the union. So an object reachable from both the top level and + a `packages[]` entry is counted once, never twice — a corrected number that + over-counts would be the same defect with the opposite sign. + + **One behaviour change beyond the counts, in `os info` only.** The fold resolves + package order through `resolveArtifactPackageOrder`, whose ADR-0112 refusals are + deliberately not swallowed. `os validate` and `os compile` already drove that + seam on the same config above their summary call, so they are unchanged; `os + info` did not, and now reports a stack whose `packages[]` repeats a package id + as a named `422` (`DUPLICATE_ARTIFACT_PACKAGE`) instead of printing + `Data: 0 Objects` for an artifact it could not read. + + ⛔ No authorable key, spec schema or published export moves. A stack whose top + level carries its collections — every stack the platform emits today — gets a + byte-identical summary: the seam returns it by identity. +- 8305ad6: `os lint`'s own rubric and `os lint --score` judge the stack an ADR-0130 D4 / option-B project actually declares, instead of reporting `✓ All checks passed` on a stack they never opened + + `lintConfig` runs two families: the shared author-time rule registry and + `os lint`'s **own** hand-written checks — naming, labels, empty field maps, the + intra-package duplicate advisory, hook-body lowering and the data-model + conventions. The registry learned to resolve `packages[]` earlier; the + hand-written family and `scoreMetadata`, which reaches the same function, still + read the **top level only**. On an option-B project (every definition inside + `packages[]`, none flattened up) they were handed an empty stack. + + Measured through the real binary, on one object authored two ways — the same + metadata, differing only in where it is declared: + + ``` + packages[] os lint exit 0 ✓ All checks passed + Metadata quality: 100/100 (A) + + top level os lint exit 0 ⚠ Label "order" should start with an uppercase letter + convention/label-case at objects[0].label + ℹ Object "ob_order" has no nameField and no name-like field … + object/missing-name-field at objects[0].fields + Metadata quality: 96/100 (A) + ``` + + and after, on the same two projects: + + ``` + packages[] os lint exit 0 ⚠ convention/label-case at objects[0].label + ℹ object/missing-name-field at objects[0].fields + Metadata quality: 96/100 (A) + + top level os lint exit 0 — byte-identical to before + ``` + + The score is the sharper half. `100/100 (A)` with every count at zero is + byte-for-byte the verdict a genuinely clean project gets, on a rubric that had + judged nothing — the same indistinguishability a swallowed linter crash used to + produce, arriving through the input instead. + + **The fix folds once, at `lintConfig`'s entry, with the existing helper.** + `authoringRuleUnionStack` (`utils/stack-collections.ts`) is this package's one + resolution rule for a package-owned collection and it is present-wins: a key the + top level already carries wins, because in today's additive shape that array + already *is* the union. So a multi-package artifact is judged once, never twice, + and a stack whose top level carries its collections — every stack the platform + emits today — is returned by identity and lints byte-identically to before. + + **This does not change what `scoreMetadata` scores.** It already scored the whole + project: its schema half reports `packages.0.manifest.objects.0: …` on an + option-B stack with no fold anywhere, and on today's additive multi-package shape + its lint half already read the flattened union across every package. The fold + makes the option-B shape agree with the additive one. + + ⛔ No authorable key, spec schema, published export or accept set moves. + `os build` rejects and accepts exactly what it did; `os lint`'s own `error` + severity remains a lint verdict, not a publish gate. +- fd8b2c0: `os info`'s **detail** reads now resolve a package-owned collection through the seam the package already has for it, so an ADR-0130 D4 / option-B project (every definition inside `packages[]`, none flattened up) stops contradicting itself. + + Measured through the real binary on the card's own repro, before the change: + + ``` + os info --json exit 0 stats.objects = 1 · objects[] length = 0 + os info exit 0 Data: 1 Objects 2 Fields (no `Objects:` section, no `Apps:` section) + ``` + + `stats` had learned to resolve `packages[]`; the four reads beside it had not, so one `--json` payload asserted `stats.objects: 1` next to `objects: []` — and nothing in the payload distinguished *this project has no objects* from *this reader could not see them*. `--json` is the face a machine reads, so a consumer could not recover from it. + + - **The four reads** — the `--json` `objects` array and the `Objects:` / `Agents:` / `Apps:` text sections in `commands/info.ts` — go through `resolveStackCollection` (`utils/stack-collections.ts`), the one place this package resolves a package-owned collection. + - **Strictly additive.** That seam answers the caller's original expression FIRST and consults `packages[]` only when the top level does not carry the key at all, so **every stack the platform emits today reports exactly what it reported before** — pinned by a control run whose definitions are the same literals, authored at the top level instead. + - **No new failure mode.** `collectMetadataStats` on the line above already resolves the same package list through the same seam, so a malformed `packages` has already answered its ADR-0112 `422` before these reads run. + + ⛔ **Not decided here:** whether an option-B project's detail listing should be this flat union or grouped per package. Each entry keeps the shape and the key set it has always had — no package attribution is added — so that published-output-shape question stays exactly as open as it was. +- b06b2db: `os generate migration` gives the file family — `file` / `image` / `avatar` / `video` / `audio` — the **same column width in both formats**. The typescript format emitted a bare `table.string(name)`, knex's `varchar(255)`, while `--format sql` emitted `VARCHAR(2048)` for the same field, so one command answered one field with two widths depending on the flag (#17883). + + 2048 is not a new number: ADR-0104 ruled the generator's `VARCHAR(2048)` the end-state for this family, `driver-sql` moved to it (`MEDIA_ID_VARCHAR_CHARS`, #15989), and `os migrate files-to-references --apply` retypes the column to `varchar(2048)`. The typescript format was the one producer left at 255 — so a deployment scaffolded from it declared a width the migration it will later run retypes away from. + + ```diff + - table.string('cover_image').nullable(); + + table.string('cover_image', 2048).nullable(); + ``` + + - **No regeneration is required of anyone.** `syncSchema` / `initObjects` are additive and never alter an existing column's type, and a `sys_file` id is far shorter than 255, so nothing stored today is at risk either way. What moves is the **declared** width of tables generated from now on. + - **The width is now read from the sql format's own entry** instead of being retyped beside it, so the two formats cannot drift apart again; `generate-file-reference-width.pin.test.ts` measures both against `driver-sql`'s constant, which is what stops the two halves from "meeting in the middle" at some third value. + - ⛔ **Nothing outside the family moved.** The `text` family, the reference types the file family used to share an arm with (`lookup` / `master_detail` / `user` / `tree`), `autonumber`, and every `--format sql` answer are byte-identical. +- ca9d9d3: `check:app-nav-i18n` now judges the PLATFORM APPS' navigation — Setup **and Account** — instead of narrowing to `setup` at every site. + + The gate is named "every id labelled in every locale" and was structurally blind to one whole app: it printed a byte-identical `OK (10 contributor(s), 54 merged setup nav id(s), 4 locale(s), …)` line before and after the Account app's contributed `nav_connect_agent` label landed, so nothing it printed could tell you it had skipped an app. + + Six sites narrowed it, only three of which were the obvious filters: + + - the contribution filter, the app-shell filter and the merged-app lookup; + - the **locale-file lookup** (`data.apps..navigation`) — widening the first three without this one yields a gate that collects `account` ids and then hunts for their labels under `apps.setup.navigation`; + - the **build prerequisite**, a package path hard-coded to `@objectstack/setup`; + - the **contributor roster**, which booted no package that registers the Account shell — so `account` had no merged app to judge at all. + + Behaviour now: + + - the population is declared with its criterion (an app is judged iff the ADR-0048 platform-app loop registers its shell by default **and** at least one package contributes navigation into it at runtime), which is why `studio` and `crm_app` are out; + - the per-contributor "landed at least one nav id" invariant is applied **per app**, never over a union across apps — a union would let a contributor serving two apps keep passing on one of them after the other silently stopped; + - every verdict, the refusal advisory and the pass line name the app they are actually about, and the pass line carries a per-app id count; + - `--self-test` gains negative controls for the union softening, for a verdict that names the wrong app subtree, and for a pass line that cannot notice an app leaving the population. + + The `setup` judgement is unchanged: the same 54 merged ids, the same verdict, and the same count in the pass line. +- f6b7c53: fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) + + A tree containing `@objectstack/cli` reports an unmet peer on every fresh + resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares + `^13.0.3` — and the reading that decides what to do about it lived only inside + the scaffold generator's prose. No range moves here and no resolution moves: + what changes is the recorded reason, which had two measured errors in it, plus + a gate that now holds the declaration to that reason. + + **The declaration is correct and stays at `^13`.** Three readings, taken rather + than inherited: + + - The peer is `optional`, and it governs exactly one configuration — a raw + better-sqlite3 `Database` passed to better-auth's `database` option. + `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or + `undefined` for better-auth's in-memory adapter. Never a `Database`. + - better-auth cannot be incompatible with better-sqlite3 13, because it never + touches it: of the 464 files in the published `better-auth@1.7.2` tarball, + exactly one names better-sqlite3 — `package.json`, the peer declaration + itself — and no code file references it (positive control: `kysely` names 9). + It accepts a `Database` the caller constructs; its own sqlite test path uses + node's built-in `node:sqlite`. + - Pinning back to `^12` is not a neutral alternative. Measured on a bare + project depending on `@objectstack/cli@17.3.0`, it clears the report only by + resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that + nothing loads. The scaffold's existing `allowedVersions` entry clears the + same report with the lockfile byte-identical. + + **Two corrections to the record.** It credited `@objectstack/driver-sql` for + the 13.x copy; on the chain that actually reports + (`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the + CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. + And it was measured on better-auth 1.7.1 while the family has been pinned at + 1.7.2 since — re-measured, with the empirical reading replaced by a structural + one. + + The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both + producers (`objectstack init` and the `create-objectstack` blank template); the + declarations, the widening entry and the resolution are untouched. +- 010c48a: fix(cli): `os register` requires a name, and the request-side `as any` that hid the mismatch is gone (#16932) + + `os register` prompted **"Name (optional)"**, typed its own payload with `name?`, and guarded `email` and `password` but not `name` — three places agreeing the field was optional. The route it actually posts to does not agree: on a fresh environment (no human user yet, so the audience gate's bootstrap bypass admits the request and the route's own validation is the only judge left), `POST /api/v1/auth/sign-up/email` answers `400 VALIDATION_ERROR` — `[body.name] Invalid input: expected string, received undefined`. The same run with a name supplied answers `200` and creates the account. + + So the first-use path failed on exactly the answer the prompt invited, and `RegisterRequestSchema`'s required `name` was right all along. + + - the prompt now reads `Name: `; + - an empty answer is refused by the CLI itself (`Name is required`), beside the existing `Email is required` / `Password is required` guards, before any request goes out; + - the payload is annotated with the declared `RegisterRequest` instead of a hand-written twin; + - the `as any` at the call site is removed, so the next divergence between this command and the declared request type is a compile error rather than a `400` a user meets on their first command. + + No behaviour change for anyone already passing a name, by flag or at the prompt. +- df8a16d: fix(cli): `resolveConfigPath` throws its two refusals so the ten `--json` faces emit their envelopes, and `os verify` gains the catch-all it never had (#15547) + + Every `--json` face in this CLI declares that it answers an error path with a + payload. `resolveConfigPath()` was the one path that bypassed that declaration: + it wrote its refusal and then called `process.exit(1)` **directly**, so nothing + was thrown and the catch-all each command already carries — all of which sit + downstream of a throw — never ran. Ten published faces answered a missing config + file with an empty stdout. + + Measured before this change on the published entry `packages/cli/bin/run.js`, + `NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces + (`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` · + `migrate meta` · `validate` · `verify`) across both branches of the helper, 19 + runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B + (auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the + same 19 runs answer **exit 1 with a parseable document on stdout**, stderr + unchanged byte for byte. + + The refusals now throw `ConfigRefusalError`. That is not a new contract — it is + this path being pulled back onto the one its callers had already published, so + it adds **zero** accept-set members and **zero** error codes. + + Three properties hold it in place: + + - **No face becomes a crash dump.** `os verify` had no `try` at all — measured, + a throw through it produced an oclif error line and no payload where every + sibling emitted an envelope — so it gains the catch-all its nine siblings + already had, in this same change rather than after it. + - **The text face does not narrow.** The refusal and both hint lines are still + written by the helper, to stderr, byte-identical: all 19 non-`--json` runs + compare equal before and after on stdout, on stderr and on exit status. The + catch-alls skip re-rendering the sentence a second time on stdout. + - **No error code is minted.** The thrown error carries neither `code` nor + `httpStatus`, so `errorCodeFields()` contributes nothing and each face emits + its own bare `{ error }`. Whether that shape is right is **#15549**'s open + question, and this change deliberately does not answer it. + + The `--json` stdout-purity instrument is widened with the fix rather than after + it: the pre-boot family's discovery moves into a shared module, the pin that + drives it now demands a document (empty stdout no longer passes) and compares + the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts` + — whose own discovery is `bootSchemaStack`-based and cannot see a command that + fails above the kernel — reconciles against that population so neither half can + be lost silently. +- 3c5f3c5: fix(cli): `os generate migration` emits the field-level unique index the driver creates (#16317) + + ## What was wrong + + Both migration formats emitted the table and none of the object's declared + uniqueness. Measured on live PostgreSQL 16.13 — one object driven through all + three producers into three schemas, `pg_indexes` read back per schema: + + ```ts + { name: 'probe', fields: { keyed_unique: { type: 'text', unique: true, maxLength: 100 } } } + ``` + + | producer | before | after | + |:--|:--|:--| + | `driver-sql` via `initObjects` | `probe_pkey`, `uniq_probe_keyed_unique` | unchanged | + | `--format sql` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | + | `--format ts` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | + + Two rows with the same `keyed_unique` value were refused by the platform's table + (`23505 ... violates unique constraint "uniq_probe_keyed_unique"`) and accepted + by both generated ones, with nothing reporting it: a scaffold that creates the + table for an object silently dropped a uniqueness guarantee the object declares. + After the change the duplicate is refused by all three, each naming the same + constraint. + + The key set was not missing — it was already computed here to size the keyed + text family's columns; only the index it implies was never emitted. + + ## What it does now + + - **`--format sql`** emits an inline `CONSTRAINT "" UNIQUE ()`. + That is what knex's `table.unique(columns, { indexName })` — the driver's own + call — compiles to on PostgreSQL, so a generated table and a platform-created + one agree in `pg_constraint` as well as in `pg_indexes`; and it stays inside + the statement's `IF NOT EXISTS`, which a following `ALTER TABLE ... ADD + CONSTRAINT` has no spelling for. + - **`--format ts`** emits that knex call itself, `indexName` included — which is + what makes the driver recognise the constraint as already present on its first + boot against a generated table, instead of adding a second one under its own + name and then reporting the generated one as an orphan to drop. + - Names come from a transcription of `driver-sql`'s `buildIndexName`, pinned + against the driver's own export (a CLI production module may not statically + value-import a driver package). + + ## What it deliberately still does not emit — and now says so + + Both formats print a `NOT EMITTED:` line naming the index, its key parts and the + reason, instead of dropping it silently: + + - the **organization-scoped composite** (`unique: true` / `'organization'` on an + object with an organization column), whose key part is + `COALESCE(, '__global__')`. Emitting the bare composite + instead would be worse than emitting nothing: under SQL's NULL-distinct + `UNIQUE` it constrains no row that has no organization, which on a + single-tenant deployment is every row. + - an index over a column no field materialises (a virtual `formula` field) — + the same skip the driver performs, where the driver logs a warning. + + Object-level `indexes[]` remains unemitted by both formats; it is normalized by + a different driver-side rule and is not covered by this change. +- 559e531: fix(cli): a generated migration carries the column DEFAULT `driver-sql` puts on the same field (#16294) + + ## What was wrong + + Neither `os generate migration` format read a field's `defaultValue`, so a table + created from a generated migration had no column DEFAULT where the platform's + own table has one. A row inserted out of band — by a database client, a seed + script, anything that does not go through the engine — got NULL where the + declared value belonged. + + Driven on live PostgreSQL 16.13: one object, three schemas, one producer each + (`driver-sql` through `initObjects`, `--format sql` through `db.raw`, + `--format ts` by importing the emitted module and calling `up(db)`), with + `information_schema.columns` read back per schema. + + ``` + field driver sqlgen verdict + f_default null=YES default='hello'::text null=YES default=- DIVERGED + f_default_required null=YES default='hello'::text null=YES default=- DIVERGED + ``` + + After: `diverged: 0 of 6` on the card's probe, and 22 of 23 on a wider one + covering every `defaultValue` shape. + + ## What changed + + Both formats now render one shared verdict, taken from + `SqlDriver.applyDeclaredColumnDefault` — the single place a `defaultValue` + becomes DDL on the platform side: + + - a **literal** is emitted, quoted the way knex binds it (`DEFAULT '42'`, not + `DEFAULT 42` — PostgreSQL keeps those two textually apart forever in + `column_default`, and the driver's column carries the quoted form); + - **`'NOW()'`** becomes the driver's own translation, which is type-branched: + `CURRENT_TIMESTAMP` on a timestamp column, and a UTC-pinned expression on + `date` / `time`, because a bare `CURRENT_TIMESTAMP` resolves those in the + server's timezone; + - **any other runtime token** (`current_user`), an **Expression envelope** and + an **option-level `default: true`** emit nothing, each because the driver + emits nothing — the engine owns those, and a column DEFAULT would override a + decision it makes deliberately; + - a **`multiple: true`** field gets neither, because `createColumn` returns + before both questions. + + No authorable key, export or accepted-input set changes: `defaultValue` was + already declared, already parsed and already honoured by the driver. The + generators simply now read it. +- e958468: fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) + + `hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` + (`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / + `hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` + (`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, + because that is the shape they parse. For a hook authored as an inline + `handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), + `hooks[i].body` is not something the author wrote at all — `lowerCallables` + mints it from the handler before `os build` / `os lint` hand the stack to + these rules (#16095). The reported `path` therefore named a key that does not + exist in the author's own source file; grepping for `body.source` there finds + nothing. + + **What changed.** `lowerCallables` now records, per `lowerCallables()` call, + which `hooks[*].handler` ref strings got their `body` minted this way (as + opposed to a `body` the author wrote directly). The CLI's four lowering doors + (`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) + pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two + hook write-set rules use it to redirect a finding on a lowered hook to + `path: hooks[i].handler` — the key that replaced the function the author + wrote — with a message suffix ("judged on the metadata body lowered from the + inline handler") explaining why. A hook whose `body` the author wrote directly + is unaffected: `path` stays `hooks[i].body.source`, unchanged. + + **No verdict changed.** Which hooks are flagged, at what severity, and why is + untouched — #13653 and #4271 are unmoved by a word. Only the location a + finding points at, and the wording explaining it, are different. `os build` + and `os lint` continue to report the identical `path` and message for the + same hook (#16095's "one implementation, both commands agree" — now including + this). + + No `--json` field was added or removed: `path` and `message` keep their + existing shape (string), and this is a within-type value correction for the + one subclass whose old value could never be resolved against the author's + source in the first place. +- 45b90b6: `os lint`: evaluate the `naming/namespace-prefix` duplicate advisory per package. + + The advisory read one flattened array per collection key with no package boundary, so on a + composed multi-package project two packages that each legitimately declare the same bare name + (e.g. `home`) were reported as one package declaring it twice — prescribing a rename of a name + that was already correct, with the OTHER package's namespace as the suggested prefix, under a + closing sentence saying distinct packages may reuse a name freely. Both ADR-0130 D4 stack shapes + were affected (flattened-plus-`packages[]`, and `packages[]`-only). + + ADR-0130 D4/D5 registers artifacts per package, so the advisory now runs once per package — + the same shape `os build` has used for the author-time rule table — and a genuine duplicate + inside one package still warns, with the suggestion taken from that package's own namespace and + a path written whole (`packages[1].manifest.apps[1].name`) so it resolves in either shape. A + single-package project is judged exactly as before. +- f89dd33: `object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` + + `FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. + + The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: + + 1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; + 2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; + 3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; + 4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. + + Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). + + ## Migration + + **A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. + + **A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. + + **A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 50bc9c7: Operator-facing text no longer tells an open-source install that multi-organization + operation requires a subscription. + + ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` + is Apache-2.0, carries no licence check, and declares both walled postures (`group` and + `isolated`) as its own constant. The messages an operator actually reads had not followed: + + - `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and + is NOT on the public npm registry ... Without one this bullet is not followable" — it now + says the runtime is Apache-2.0 and on the public registry, and notes that a commercial + deployment resolves the same package name to its own private, licence-gated build. + - The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the + runtime "enterprise". + - `os verify`'s `--org-scoped` flag description drops the same word. + - The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe + the package as the enterprise runtime. + + Text only — no control flow, no identifiers, no behaviour change. +- 3a2d2b5: `os explain query` now teaches the two keys `QuerySchema` actually declares. + + The entry's example and its two optional-table rows named `filters` and `sort`. + Neither is a key of `BaseQuerySchema`, which is a plain `z.object` — so both + were dropped silently: an author who copied the example got a query that parsed + clean and ran with no filter and no ordering, with nothing in the output saying + so. + + Both faces now read the schema's own spellings: + + - `where` — one condition **tree**, not a `Filter[]`. A field-keyed entry is a + condition on that field (a bare value is implicit equality, an object is a map + of `$` operators), and `$and` / `$or` / `$not` combine conditions. + - `orderBy` — sort nodes, each `{ field, order }`. The direction key is spelled + `order`; `direction` is rejected by name. + + No schema changed, and no accept set moved: the correction is to the catalog + entry only. The `os explain` catalog sweep also gains a key-retention assertion + — an example must parse **and** come back with every key it declares — so the + next entry whose schema strips a key is named instead of passing. +- 6e3462d: `serve`: the multi-org runtime's stage-1 refusal no longer prints its own install remedy for a `declared-unresolvable` failure — it defers to the importer's message, which the same refusal already prints as its `cause:` line. + + Driven on both shapes that kind covers, the minted bullet ("Repair the INSTALL … run `pnpm install`, check that a production prune did not drop it, and that its dist is actually built") was wrong twice over. For a genuinely broken install it repeated, word for word, the three remedies the cause line four lines below already carried. For a location install the finder cannot tie to the declaration, the cause says outright that re-running `pnpm install`, un-pruning a deploy and rebuilding a dist all change nothing — so one screen contradicted itself. + + The arm now says only what it uniquely knows (the app DOES declare the package, so re-reading `package.json` will not help) and names the cause as the authority on the remedy — the same deferral the `declared-no-loadable-entry` arm has had since it landed. +- eadcde6: `os generate schema` can now reach its own `fs.writeFileSync`. + + `runSchemaGeneration` called `z.toJSONSchema(ObjectStackDefinitionSchema, { target: 'draft-2020-12' })` + bare — the one `toJSONSchema` call site in this repository that neither fell back nor used the + `unrepresentable` convention. That call has no JSON form in either io direction on today's tree (a + transform in the output direction, a function type in the authoring direction), so the `catch` below + it printed and exited 1 for every repository and every flag combination: the command could never + write the IDE schema it exists to write. + + It now runs the same three-tier ladder `packages/spec/scripts/build-schemas.ts` already runs for + every schema it publishes — output, then the authoring (`io: 'input'`) direction, then that direction + with `unrepresentable: 'any'` as `packages/metadata-protocol` spells it — and each tier re-raises any + error the known-unsupported predicate does not recognise, so a real conversion failure is still loud. + + No new flag, no new key and no new exported symbol: the change is confined to the body of a + module-private function. + + The published document lands on the third tier today. It is the authoring derivation, so a property + carrying a `default` is not reported as required; the nodes that have no JSON form in any direction — + `onEnable`, and the inline-callable branch of each `handler` under `hooks`, `functions` and + `packages` — are published as unconstrained, which means an IDE validates everything else in + `objectstack.config.ts` and asks nothing about those. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- edaf3b2: `os validate` and `os lint` now judge the same stack `os build` judges when a project declares its metadata only in `packages[]`. + + A project in the ADR-0130 D4 artifact shape — every definition inside `packages[]`, no collections at the top level — was handed to the author-time rule table as an **empty stack** by both commands, so all 44 rules reported nothing and both exited 0 having read none of the project. `os build` folds the packages back in first (`authoringRuleUnionStack`) and refuses the same stack. Two of the three authoring gates were certifying an unread project as clean, and `os validate` is the check an author runs before shipping. + + Both commands now hand the rule table the stack that same helper returns — one fold, shared with `os build`, not a second implementation. It is a rule **input** only: neither command's output, `--json` payload nor `os lint`'s metadata score changes, and a stack that still carries its top-level collections is returned by identity, so single-package projects are unaffected by construction. + + ⚠️ **A project that was silently passing may now fail.** That is the defect surfacing, not a new rule: the finding was always there and `os build` was always reporting it. Run `os build` on the same tree to see the identical diagnostic. +- 5865b02: `os create plugin` names the standalone scaffold `plugin-` and marks it `private` + + The default (standalone) emission wrote `"name": "@objectstack/plugin-"` into a + project scaffolded for a developer outside this monorepo — a scope they cannot publish + to — and did not mark the manifest `private`. Nothing failed at scaffold time: the name is + never resolved from a registry inside the project, so `pnpm install`, the type-check and + the scaffold smoke were all green on it, and the cost landed later at `npm publish`. The + emitted README compounded it by instructing `pnpm add @objectstack/plugin-`. + + The standalone default now emits: + + - `"name": "plugin-"` — unscoped, and the same string as the directory the + scaffolder prints and creates; + - `"private": true` — the line that actually stops an accidental publish, whatever the + name says; + - a README whose install instruction is a local reference (`pnpm add link:../plugin-`) + and whose import specifier matches the emitted package name. + + `os create plugin --in-repo` is unchanged: it still emits a publishable + `@objectstack/plugin-` with no `private` flag, because that placement lands under + `packages/plugins/` where every sibling genuinely carries that scope. + + No action is needed for a project already scaffolded. If you generated one with the old + name and have not published it, rename `package.json`'s `name` to `plugin-` (or a + scope you own) and update the README's install line; the exported symbol and the plugin's + runtime `name` are unaffected. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [c8a006f] +- Updated dependencies [9fca8eb] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [e526556] +- Updated dependencies [305e7fc] +- Updated dependencies [216b066] +- Updated dependencies [c88fa2c] +- Updated dependencies [ee6fbd7] +- Updated dependencies [4f1a56b] +- Updated dependencies [f19dbcf] +- Updated dependencies [6059b29] +- Updated dependencies [89a652b] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [a370073] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [fce7cd4] +- Updated dependencies [4af758d] +- Updated dependencies [86c5052] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [dc709b2] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [bdb247d] +- Updated dependencies [7baf04a] +- Updated dependencies [6b2ec3b] +- Updated dependencies [c9246fa] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [0fb6f97] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [5741ff1] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [6ec467b] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [bea41f6] +- Updated dependencies [2eb4724] +- Updated dependencies [e743fb5] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [690f083] +- Updated dependencies [e7fea46] +- Updated dependencies [310760d] +- Updated dependencies [7e74af3] +- Updated dependencies [a9096af] +- Updated dependencies [2b6a207] +- Updated dependencies [4be4e04] +- Updated dependencies [7c2c5ae] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [400167a] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [40098a4] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [86f4246] +- Updated dependencies [4ef8247] +- Updated dependencies [ea4d164] +- Updated dependencies [ab48938] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [a36b526] +- Updated dependencies [dd2fd20] +- Updated dependencies [f6b7c53] +- Updated dependencies [92865f6] +- Updated dependencies [01388fe] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [e66da5c] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [2266438] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [e958468] +- Updated dependencies [288fe9c] +- Updated dependencies [611795e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [522f612] +- Updated dependencies [6e3462d] +- Updated dependencies [31064ca] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [f89dd33] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9540590] +- Updated dependencies [ae6dcf6] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [775e5ec] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [0ced0aa] +- Updated dependencies [8d4690b] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [e4fd55d] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [ba17017] +- Updated dependencies [4062aef] +- Updated dependencies [6ff5b56] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [6465cc0] +- Updated dependencies [4280055] +- Updated dependencies [032452a] +- Updated dependencies [131851f] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [7173d7d] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [4ecfd2b] +- Updated dependencies [7cd5874] +- Updated dependencies [a2509d7] +- Updated dependencies [6058cb2] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/service-automation@17.5.0 + - @objectstack/plugin-approvals@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/lint@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-messaging@17.5.0 + - create-objectstack@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/verify@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/plugin-audit@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/cloud-connection@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/service-settings@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/plugin-email@17.5.0 + - @objectstack/trigger-schedule@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/driver-mongodb@17.5.0 + - @objectstack/plugin-pinyin-search@17.5.0 + - @objectstack/account@17.5.0 + - @objectstack/setup@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/plugin-reports@17.5.0 + - @objectstack/plugin-webhooks@17.5.0 + - @objectstack/service-cache@17.5.0 + - @objectstack/service-job@17.5.0 + - @objectstack/service-package@17.5.0 + - @objectstack/service-queue@17.5.0 + - @objectstack/service-realtime@17.5.0 + - @objectstack/service-sms@17.5.0 + - @objectstack/trigger-api@17.5.0 + - @objectstack/trigger-record-change@17.5.0 + - @objectstack/console@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d84eb3ea8..9f5d97667c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.4.0", + "version": "17.5.0", "description": "Command Line Interface for ObjectStack Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/client-react/CHANGELOG.md b/packages/client-react/CHANGELOG.md index bd557777a0..2b34ab1c6f 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,142 @@ # @objectstack/client-react +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [3c86008] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [7baf04a] +- Updated dependencies [6b2ec3b] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [400167a] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [01388fe] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [032452a] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index d4bc7e15ea..b320e538db 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "React hooks for ObjectStack Client SDK", "main": "dist/index.js", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 1eeb4b07a5..1dc9533a1b 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,604 @@ # @objectstack/client +## 17.5.0 + +### Minor Changes + +- 6b2ec3b: fix(client): `oauth.applications.register` declares `redirect_uris` optional, matching the body schema of the route it posts to (#17215) + + `ObjectStackClient.oauth.applications.register` declared `redirect_uris` **required**. `POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider`, and that route's body schema declares the member **optional** — so a request the route accepts had no spelling through this SDK. The caller never got a wrong answer; they got a call they could not write. + + ## What changes for a caller + + Nothing they have to do. Every existing call still compiles — this only *adds* spellings: + + ```ts + // now expressible, and accepted by the route: + await client.oauth.applications.register({ client_name: 'My App' }); + + // unchanged, and still the right call when you have redirect URIs: + await client.oauth.applications.register({ + client_name: 'My App', + redirect_uris: ['https://app.example.com/cb'], + }); + ``` + + ⛔ Not breaking in this direction — relaxing a required member to optional keeps every existing call valid. Tightening it back later would be breaking, which is why the parity is now pinned. + + ## Measured at runtime, not read off a `.d.ts` + + The vendor body schema was re-introspected the way the card's original measurement was taken: instantiate `oauthProvider()`, walk `endpoints`, find the endpoint whose `path` is `/oauth2/create-client`, read `options.body`. At the installed **1.7.3** (the card measured 1.7.2; the package has since moved) the object still declares **21 members and every one of them is optional**, and `body.safeParse({ client_name: '…' })` succeeds with `redirect_uris` absent. + + ⚠️ Optional does **not** mean an empty array will do: the vendor refuses `[]`, so when the member is present it must be non-empty. Omitting it and passing `[]` are different requests and only the first is legal. Nor does it mean a client registered without redirect URIs is *usable* — it cannot complete an `authorization_code` flow. The type states what the route accepts, never that every accepted call yields a client fit for every grant; the docblock now says both. + + ## Why it was required, for the record + + Not as a guard. It is residue from the method's first commit, which declared `client_name` required too; the same-day follow-up relaxed `client_name` and left this one behind. No comment, test, ADR or review thread ever asserted a reason for it — which is exactly why it read as a defect to the next auditor. + + Nothing else on the signature moves: the other ten members are byte-identical. +- d61139f: feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534) + + Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in `Set-Cookie` (and, through `bearer()`, in the `set-auth-token` response header), and DELETE the row the caller was presenting: + + | route | where the new credential is | + | --- | --- | + | `auth.twoFactor.verifyTotp()` on the enrolment lane | body — `token`, and it is the LIVE one (plugin-auth's `two-factor-rotated-token-echo` repairs the vendor's stale echo) | + | `auth.changePassword({ revokeOtherSessions: true })` | body — `token` | + | `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` | + + A browser is carried across all three by its own cookie. A bearer client — this SDK's own mode — kept presenting the DELETED session's token, so its very next call answered `401 UNAUTHORIZED`. Measured against a real `AuthManager` (better-auth 1.7.2) over a real driver, driven through the real `ObjectStackClient`, `login → enable → verifyTotp → disable → deleteUser` could not run to the end without the caller re-seating `client.token` by hand between the steps. + + The three methods now adopt the rotated credential themselves, the way `login()` already adopts the token it is handed. The `token` members stay on the wire and stay declared, so a caller that keeps its own credential store is unaffected; what changes is that it no longer has to. + + **No public surface moves.** No new export, no new option or flag, no new key on any declared request or response type — the SDK stores a token the server already sends and this package already declares. Graded `minor` rather than `patch` because the published runtime behaviour of three methods moves for existing callers. + + ## What does NOT change, deliberately + + The adoption is on those three routes only, never in the shared `fetch` wrapper. `set-auth-token` rides **every** response that stages a session cookie — `POST /update-user` stages one to carry the updated user without rotating anything — and it carries the SIGNED `.` spelling while every JSON `token` echo carries the UNSIGNED one. A wrapper-level read would therefore rewrite the stored credential into a different spelling of the SAME session on ordinary traffic. `auth.me()`, `auth.sessions.list()`, `auth.updateUser()` and `auth.twoFactor.verifyBackupCode()` (which does not rotate — the vendor echoes the session it resolved at entry) all leave the stored credential byte-identical, and that is pinned. + + A cookie-only deployment sends no `set-auth-token`; there is then nothing to adopt and `twoFactor.disable()` leaves the stored credential exactly as it was. `changePassword` without `revokeOtherSessions` answers `token: null` and likewise stores nothing. + + The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change. +- 1c4270f: feat(client): `environments.delete` gains `purge` and documents the hosted control plane's two-step delete (#17636) + + The hosted control plane's `DELETE /api/v1/cloud/environments/:id` follows cloud ADR-0014: a live environment is **archived**, and only a second call with `?purge=1` on the now-archived environment tears it down. `?force=1` confirms a production environment and is never a purge. The SDK sent `force` only, so an SDK caller could archive an environment but never purge one. + + - `opts.purge?: boolean` sends `?purge=1`. It combines with `force`: a production environment is torn down with `{ force: true }`, then `{ force: true, purge: true }`. Calls that pass no options, or `force` alone, build exactly the URL they built before. + - The return type declares the two answers the route actually sends, discriminated by `deleted`: + - archive: `{ environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings, message }` + - teardown: `{ environmentId, deleted: true, purged: true, warnings }` + + Both members carry every key the old declaration named (`deleted`, `environmentId`, `warnings`), so existing reads still compile. + - The JSDoc no longer describes a one-call cascade delete: a live environment is archived, `purge` acts only on an archived environment, `force` is the production confirmation, and a `failed` environment is torn down in one call. + - `organizations.delete`'s JSDoc no longer claims that server-side hooks tear down the organization's environments. No hook does; delete each environment first. + + Graded `minor`: a purely additive widening of a published method's accepted options and declared answer (the "WHICH LEVEL" rule in `.github/workflows/pr-automation.yml`). Nothing is removed or renamed. +- f904e61: fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568) + + **BREAKING** — the answer this published method gives moves for existing inputs. The signature, the declared return type and the export are byte-identical; what changes is the response an existing call observes, stated below as a before/after pair per input. + + The method built `GET /organization/get-active-member?organizationId=…`, and better-auth 1.7.2's handler for that path reads `session.session.activeOrganizationId` and never looks at `ctx.query`. The query string was dead on arrival: a client doing a permission check for organisation B while A was active got **A's** membership row back, with a 200 and no diagnostic — the wrong-but-plausible answer, silently. The SDK's own JSDoc promised "the calling user's membership row in the given organisation", so this was a declared capability the runtime did not deliver. + + It now asks the question honestly, in two requests: + + 1. `GET /get-session` — the caller's own user id; + 2. `GET /organization/list-members?organizationId=…&filterField=userId&filterValue=&limit=1` — the row, unwrapped from the one-entry page. + + `list-members` reads `ctx.query.organizationId`, and its rows carry the identical shape (`OrganizationMemberWithUserWire`, user projection included), so the signature and the declared return type are unchanged and no caller's types move. + + ## What an existing call observes, before and after + + Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`. Each bullet is one input, with the response it drew before and the response it draws now. + + - **An organisation id other than the session's active one.** Before: a 200 carrying the **active** organisation's membership row, whatever id was named. After: a 200 carrying the **named** organisation's row. An input that named the active organisation's own id drew that organisation's row before and draws the same row after — `auth.me()` is where that id is readable, on `session.activeOrganizationId`. + - **An organisation the caller is not a member of.** Before: the named organisation was never consulted, so the answer was about the **active** one — a 200 carrying the active organisation's row, or `400 MEMBER_NOT_FOUND` when the caller had no row there either. After: `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`, the server's own refusal, about the organisation that was actually named. + - **Any id, on a session with no active organisation.** Before: `400 NO_ACTIVE_ORGANIZATION`. After: a 200 carrying the caller's row in the named organisation. `setActive` has stopped being a precondition, which is the point of naming the organisation. + - **An empty `organizationId`.** Before: a 200 carrying the **active** organisation's row — better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`, so an empty string fell through to session state and the wrong-but-plausible answer survived on that one input. After: the SDK refuses it before the wire, with a thrown `[ObjectStack] organizations.getActiveMember: organizationId is required`. + + Two things do not move: an anonymous caller still draws `401 UNAUTHORIZED`, thrown by the same session middleware that guarded the old route; and the row's shape is the same on both sides. The method now makes two HTTP requests where it made one. + + Graded `minor` rather than `patch`: the method's published behaviour moves for existing callers, which is the same clause-② judgement this PR declares, and the maintainer's ruling of 2026-09-04 (decision batch #35) holds that a change to a published package's public surface takes at least `minor` — a commit type may raise a bump, never lower it below what the act requires. The banner above carries the breaking-ness that the level cannot, per the ruling recorded on #16568 on 2026-09-08. + + The auth route ledger's `GET /api/v1/auth/organization/get-active-member` row is rebooked from `sdk` to `server-only` in the same change: `sdk` means "expressed by the SDK", and no SDK method builds that URL any more. The `get-session` and `list-members` rows gain the method in their notes, since it now builds both. Ledger-internal, nothing published moves with it. + + +- fb6a2de: fix(client): `packages.get` binds the bare `InstalledPackage` row on both the global and the environment-scoped client, replacing a `{ package }` envelope no surface emits (#12034) + + `client.packages.get(id)` and `ScopedEnvironmentClient.packages.get(id)` now resolve to **`InstalledPackage`** — the row itself — instead of an object wrapping it. + + **Migration — read the row directly, not `.package`:** + + ```ts + // before + const { package: pkg } = await client.packages.get('com.acme.crm'); + const pkg2 = (await scoped.packages.get('com.acme.crm')).package; + + // after + const pkg = await client.packages.get('com.acme.crm'); + const pkg2 = await scoped.packages.get('com.acme.crm'); + ``` + + FROM `{ package: any }` (global) and `{ package: InstalledPackage }` (scoped) TO `InstalledPackage` on both. + + This is a **narrowing**: a `.package` read compiles today and stops compiling after this change. That is the point of the change rather than a side effect of it — the wrapper was never what the wire sent, so every one of those reads was already `undefined` at runtime, and on the global method the `any` member is what kept the falsehood invisible. Nothing about the request or the wire changes; only the declaration moves to match what the server has been sending. + + Why it can be bound now, when #11925 deliberately left it erased: this route used to be served by two implementations that disagreed — the runtime dispatcher sent the bare row, the `@objectstack/rest` registrar sent `{ package }` — so no declaration was true on both. The registrar's read routes were removed in #16628, leaving the dispatcher's `/packages` domain as the single implementation. It builds the detail body with the same expression it maps over every `list` row, which is why this type now agrees with the `InstalledPackage[]` that `packages.list` has already declared, and with `GetInstalledPackageResponseSchema` in `@objectstack/spec`, which has declared `data: InstalledPackageSchema` all along. + + The environment-scoped method is the sharper half of the change: its member was a real `InstalledPackage`, not `any`, so `.package` reads there looked type-safe while returning `undefined` against every surface that has served that path since #16628. +- bccf311: fix(client): `oauth.applications.register` declares only the members `/oauth2/create-client` accepts — `name`, `scopes` and `metadata` are removed (#15447) + + **BREAKING** — three members leave a published request type. A caller who sets one compiles today and gets a type error after this release. That is the point: the route never honoured any of them, so what the compiler now refuses is code that was already having its value thrown away. + + ## What a caller passing these members should do instead + + | you were passing | pass instead | why | + |---|---|---| + | `name: 'My App'` | `client_name: 'My App'` | same `string`, and `client_name` is the member the route reads | + | `scopes: ['openid', 'profile']` | `scope: ['openid', 'profile'].join(' ')` | ⚠️ **not** a rename — `scope` is one space-delimited string; posting an array is refused with `400 [body.scope] Invalid input: expected string, received array` | + | `metadata: { tenant: 'acme' }` | nothing — delete the member | no door this SDK can reach accepts it (see below) | + + ## ⚠️ These were the vendor's RECORD vocabulary, not typos + + `client_name` writes the DB column literally named **`name`**; `scope` writes the DB column literally named **`scopes`**, as a JSON array. The removed members were the *column* names offered next to the *wire* names in the same declared type — an author picking the adjacent one of two got a success receipt and no value. Treating them as misspellings would be the wrong reading of what they were; the prescription above is still the wire member either way. + + ## Why they had to go rather than be honoured here + + `POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider@1.7.2`. Its body schema declares 21 members and sets no `catchall`, so it is zod's default **strip**: an unknown key is dropped, not refused, and the caller gets **HTTP 201 and a client that quietly does not have the value**. Driven end to end against a real `betterAuth` + `oauthProvider` over a real ObjectQL engine on a real socket, through this client: each of the three came back absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, and `null` in the `sys_oauth_application` row. + + A second, independent barrier stands behind that strip — the handler funnels the parsed remainder into the opaque-metadata envelope, and all three names sit in `OPAQUE_METADATA_RESERVED_FIELDS` — so no amount of loosening on the SDK side could ever have made them arrive. `metadata` in particular is honoured only by `PATCH /admin/oauth2/update-client`, which is `SERVER_ONLY` and therefore not an HTTP route at all: over the wire it answers 404 with a zero-byte body. + + Nothing else on the method moves. The two members the route does honour, `client_name` and `scope`, are declared exactly as before and still reach the server byte for byte; the method's return type, its URL and its request-building step are unchanged. + + Graded `minor` rather than `patch` because a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch #35) that such a change takes at least `minor`; the banner above carries the breaking-ness the level cannot. + + +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. + +### Patch Changes + +- 3c86008: `client.ai`'s docblock says the AI slot answers 501, names the 401-first and `GET /ai/agents` arms, and stops promising a 404 + + The `ai` namespace docblock described the pre-`capabilityUnavailable` + behaviour: that this repo's dispatcher *"404s `AI service is not configured` + when the service is absent (the open-source default)"*. The dispatcher has + answered **501** since the shared exit landed. `/ai/*` is registered + **unconditionally** (`createAiDomain`, plus the host wildcard across four + methods in every branch of the scoping conditional), so a request reaches a + handler with nothing behind it — which is 501 Not Implemented, not 404. + `packages/runtime/src/domains/unavailable.ts` exists to draw exactly that line: + 404 means *the route is not there*, and for `/ai/*` that is false. + + **Why the replacement is narrower than "`/ai/*` answers 501".** That sentence + is not true either, and a caller branching on status needs both exceptions. + Verified against the unserveable-slot branch in + `packages/runtime/src/domains/ai.ts`, in its own evaluation order: + + ``` + FROM any /ai/* with no AI service -> 404 `AI service is not configured` + + TO anonymous caller -> 401 (ANONYMOUS_DENY_STATUS; the 501 and + the courtesy below are capability + disclosures, owed to nobody who has + not authenticated) + GET /ai/agents -> 200 { agents: [] } under the envelope's + `data` — a console polls it on every + navigation to decide whether to show + AI affordances + every other /ai/* route -> 501 serviceUnavailableMessage('ai') + ``` + + All three arms are already test-pinned in + `domains/ai-anonymous-deny-ordering.test.ts` — this changeset moves no + behaviour, only the sentence describing it. + + **The `GET /ai/agents` courtesy was mentioned nowhere in this docblock**, which + is the one an SDK reader actually opens, so it is added rather than merely + corrected. Also stated now: the 501 body is not a local string — it comes from + the shared `serviceUnavailableMessage`, the same sentence + `discovery.services.ai` reports for the slot, so the two cannot drift into + naming different remedies. + + ⛔ No behaviour changes. This is a docblock; no export, authorable key, accept + set or response byte moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/client`'s published `files[]` ships `dist`, + and this TSDoc is emitted into all four built artifacts — `dist/index.d.ts`, + `dist/index.d.mts`, `dist/index.js` and `dist/index.mjs` — measured on the + built tree, with the stale `AI service is not configured` sentence absent from + every built file afterwards and the docblock's own neighbouring sentence + present as the lit control. The declarations are what a consumer's editor shows + on hover and what an upgrading agent greps, and they change. + + The two sibling corrections in the same change do **not** publish and are not + named here: `packages/runtime/src/route-ledger.ts` is a CI-audit ledger that is + not exported from the runtime entry (`ROUTE_LEDGER` is absent from + `packages/runtime/dist` entirely), and the `domains/ai.ts` implementation + comment is not emitted — three pre-existing comments from that same file were + probed as controls and none appears in the built output. +- 7baf04a: `oauth.applications.register`'s docblock says where a plain `name` IS honoured, and that it is not this route + + A caller who wants to name an OAuth client reaches for `name`. On the route this + method posts — the provider's `/oauth2/create-client` — that member is not in + the body schema and is stripped: driven on a real socket, the call answered + **201** and the value was absent from the response, from `applications.get`, + from `applications.list`, and `null` in the `sys_oauth_application` row's `name` + column. Nothing in the answer says so. + + The spelling is not wrong everywhere, which is what made it worth writing down: + `POST /api/v1/auth/sys-oauth-application/register` — the session-required + ObjectStack mount behind the Console's *Setup → OAuth Applications* form — + answered **200** to the same body, mapped `name` onto `client_name`, and set + that column. That mount is `disposition: 'server-only'` in the auth route ledger + and objectstack#17210 ruled it stays that way, so no SDK method builds its URL. + + The docblock now states both halves where the caller reads them: post + `client_name` to name a client from here, and `redirect_uris` must arrive + pre-split — the newline-separated-textarea split is the Console wrapper's, not + this route's. + + Docblock only. No method is added, no request or response type changes, and the + ledger row is untouched — but the text ships inside `dist/*.d.ts` as editor + hover, so it is a `patch` rather than a no-publish change. +- 400167a: `environments.update`'s JSDoc no longer advertises writes the control plane refuses, and `environments.updateVisibility` carries a current-state note. + + The `update` comment listed `display_name, plan, status, is_default, metadata` as the updatable set, and the namespace route table listed `plan` and `status` too. `plan` and `status` are read-only columns on the control plane; the generic `PATCH /api/v1/cloud/environments/:id` route answers an unknown or read-only key with a **400** rather than dropping it, so the comment was actively teaching a call that fails. The same prose implied `visibility` was writable while it is server-owned. + + Three prose sites move, all in `packages/client/src/index.ts`: + + - **The namespace route-table docblock.** The PATCH accept-set now reads `display_name, is_default, metadata`, with the redirects stated: plan changes go through the billing routes, status changes through the lifecycle actions (archive / restore / suspend / resume), and `visibility` is server-owned. + - **`update`'s JSDoc.** The same accept-set with per-field detail, and — the sentence that matters most to a caller — that an unknown or read-only key is answered with a 400 and is **not** dropped silently. Silent-drop is the assumption a caller reasonably makes today, and it is the wrong one. + - **`updateVisibility`'s JSDoc.** A note that the call is refused today, so the paragraph describing what `public` does describes a capability that does not exist yet. The 2026-09-12 maintainer ruling keeps `visibility` server-owned and forced to `private` until the public-listing feature ships, at which point it gets its own endpoint rather than this generic update. + + ⛔ **No signature, type or runtime byte moves.** `patch` stays `Record` and `updateVisibility`'s signature and body are byte-for-byte unchanged (verified by hash, before and after). Narrowing a published accept-set is as much a breaking change as widening one, and retiring, re-signing or throwing from a published SDK method is a maintainer ruling — neither is a doc fix's to make. What reaches consumers is the hover text in `dist/index.d.ts`. + + ⚠️ The 400 is an **inherited reading**, not one measured from this repo: `/api/v1/cloud/*` is served by `objectstack-ai/cloud`, which is not readable from here, so no gate here can check it. The comments say so at the point of the claim rather than leaving a later reader to try. +- 01388fe: `auth.login` and `auth.register` now deliver the `SessionResponse` envelope they declare. + + Both methods annotate their return as `SessionResponse`, whose base `BaseResponseSchema` declares + `success` as a required boolean. Both carried an inline lift that filled `data` and never wrote + `success`, so neither delivered the type it advertises and every consumer keying on the envelope + flag — `ObjectStackClient.unwrapResponse` keys on exactly this — read `undefined` rather than + `true` or `false`. They now run the same lift `auth.me` / `auth.refreshToken` use, so the family + cannot deliver two different envelopes again. + + The credential is unchanged: `data.token` is still the token the route puts in the response body, + byte-identical, and `login` / `register` still arm the client's bearer token from it. + + Known residue, unchanged by this release: `data.session` is still absent from what these two + methods return. `POST /sign-in/email` and `POST /sign-up/email` serve no session object, id or + expiry in the body or in any header, so the member is not obtainable without a second + `GET /get-session` call — read it from `auth.me()`. Nothing is synthesized in its place. +- 5de9372: fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760) + + Both methods annotate their return as `SessionResponse` — ObjectStack's REST + `{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth + owns those bytes and answers **bare**. Measured against a real `AuthManager` + (better-auth 1.7.2, organization plugin) over a real driver: + + ``` + GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} + GET /api/v1/auth/get-session (anonymous) -> 200 null + ``` + + So `(await client.auth.me()).data.user` type-checked and was `undefined` at + runtime, while `.user` — the real payload — did not type-check. The annotation + pointed every caller at the wrong key. + + ## What changed + + - The bare answer is now lifted into the declared envelope, the same lift + `auth.login` has always carried for `/sign-in/email`. `SessionResponse` is + **unchanged** and so is each method's published return annotation: the fix is + in what the methods produce, not in what they promise. + - The lift fills `success` as well as `data`. `SessionResponseSchema` is + `BaseResponseSchema.extend(…)` and that base declares `success` as a required + boolean, so a body carrying `data` alone still would not parse as the declared + type. + - The raw `.user` / `.session` keys are **kept** alongside `data`. They are what + callers were pushed onto while the declared shape was unreachable; dropping + them would trade one silent breakage for another. + - `auth.refreshToken` now reads `data.session.token`. It used to read + `data.data?.token` — a field this route does not produce at any nesting, so + the method returned successfully having captured nothing. A bearer-mode client + calling it to refresh kept whatever credential it already had, silently. + + ## The read was not a consequence of the envelope + + Worth stating because the reverse is the natural assumption: enveloping the body + does **not** put a token at `data.token`, because the route serves no top-level + `token` to lift. The only credential in the body is `session.token`, and that is + now the read. Fixing the shape alone would have left `refreshToken` exactly as + inert as it was. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `(await client.auth.me()).user` | still works — kept deliberately | + | `(await client.auth.me()).data.user` | now populated (was `undefined`) | + | `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` | + + `refreshToken` stores the **unsigned** session token, which is the spelling + `/get-session` serves; `bearer()` accepts it and the signed + `token.signature` form interchangeably, so a client that held the signed form + stays signed in across the call. + + Two answers stay outside the declared type and are **not** addressed here: the + anonymous `null`, which would need the published return annotation to widen, and + `SessionUser.image`, declared `z.string().optional()` against a route that + serves `null` (#17235). The sibling `auth.login` / `auth.register`, which + normalize into `data` but set no `success`, are #17234. +- f8fea00: fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582) + + `organizations.invite` declares `role?` as **optional** and forwarded the caller's object to better-auth verbatim. better-auth 1.7.2's body schema for `POST /organization/invite-member` makes `role` **required**, so the documented-looking minimal call was refused before it reached any ObjectStack code: + + ``` + client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR) + ``` + + Omitting `role` now sends `'member'`. **No published type moves** — `role` stays optional, and a caller who names a role still gets exactly that role on the wire (including `role: undefined`, which is treated as omission rather than dropped). + + The default is `'member'` because the sibling `organizations.invitations.resend` has always substituted exactly that over the **same** vendor endpoint. That asymmetry is why the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. It is also the least-privileged name in the closed membership vocabulary (ADR-0108 D1 — `orgRoleGrade` floors at `member` and rises only for `owner`/`admin`), and an invitation is a pending row the invitee must still accept, so the implicit choice cannot confer reach the caller did not ask for. + + Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after: + + ``` + before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"} + after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...} + ``` + + No caller had to change: the census found no in-repo or Console caller using the two-argument form, so this repairs a path that was declared and unreachable rather than one that was in use. +- 032452a: fix(client): the scoped SDK reads `metadata.prefix` off the advertised routes instead of restating `/meta` + + `metadata.prefix` is a live `RestServerConfig` key: REST mounts every metadata + route under `metaPath = ${basePath}${metadata.prefix}` and the discovery handler + advertises the same value as `routes.metadata = ${realBase}${metadata.prefix}`. + Three surfaces describe one set of paths — the mounts, the discovery document, + and this SDK. + + `ScopedEnvironmentClient` restated `/meta` as a literal in all six of its + metadata methods — `getTypes`, `getItems`, `getItem`, `saveItem`, `deleteItem`, + `getHistory` — so on a deployment that moved the prefix, every one of them + called a path the server does not mount. The unscoped twin of each method was + already correct (it builds `${baseUrl}${getRoute('metadata')}`), so one SDK + disagreed with itself: the unscoped half read the advertised value while the + scoped half guessed. Measured on a live server booted at + `metadata: { prefix: '/metadata' }`, all six went to + `/api/v1/environments//meta`, which that deployment answers 404. + + The six now build through `metaUrl()`, which takes its base from `_apiBase()` + and its prefix from the new `_metaPrefix()` — the exact sibling of the + `_dataPrefix()` derivation that fixed `crud.dataPrefix`, fallback discipline + included. `_metaPrefix()` prefers the advertised `routes.metadata`, recovers the + prefix from `routes.data` as a second equation over the same `realBase` when the + advertised value is not the conventional one, and **declines to `/meta`** + whenever the document does not determine the answer: an SDK must not become + unusable because a server's discovery document is missing a key. + + Deployments on the default prefix are unaffected, by construction and by + measurement: the conventional-suffix rule is taken first, so a default + deployment is answered from `routes.metadata` alone, and a client that never + connected never reaches a rule at all. The pinned negative control asserts the + six request URLs of a default deployment byte for byte, for a connected client + and for an unconnected one, and that the unconnected client puts no discovery + request on the wire. + + The unscoped metadata methods are untouched. +- ab1c585: `POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. + + On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. + + `two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. + + - **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. + - **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. + - **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. + + `@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index cdcd20c91a..e4c73ff43d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Official Client SDK for ObjectStack Protocol", "main": "dist/index.js", diff --git a/packages/cloud-connection/CHANGELOG.md b/packages/cloud-connection/CHANGELOG.md index 47ccef2900..abb11abce6 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,194 @@ # @objectstack/cloud-connection +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [ea4d164] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index f3b0791e82..0d87d01bfa 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (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 a27c6f133b..323e8d4e8f 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/connector-mcp +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 8e7ce78ceb..9caf0ad15f 100644 --- a/packages/connectors/connector-mcp/package.json +++ b/packages/connectors/connector-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-mcp", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Model Context Protocol (MCP) connector for ObjectStack — a generic adapter that turns any MCP server's tools into a connector's actions on the automation engine's connector registry (ADR-0024).", "main": "dist/index.js", diff --git a/packages/connectors/connector-openapi/CHANGELOG.md b/packages/connectors/connector-openapi/CHANGELOG.md index de7a065683..152ec2fd54 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/connector-openapi +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index e1b630c683..7a330dab9c 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-openapi", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).", "main": "dist/index.js", diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md index 195702e5c3..23b1fa77da 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/connector-rest +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 8813fc217f..cd700b8a44 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-rest", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).", "main": "dist/index.js", diff --git a/packages/connectors/connector-slack/CHANGELOG.md b/packages/connectors/connector-slack/CHANGELOG.md index 192dbc70c7..e150f33f3c 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/connector-slack +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 5eb1c7b7ae..a35beec817 100644 --- a/packages/connectors/connector-slack/package.json +++ b/packages/connectors/connector-slack/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-slack", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Slack Web API connector for ObjectStack — registers `chat.postMessage` / `chat.update` / `call` actions on the automation engine's connector registry (ADR-0018 §Addendum, ADR-0022).", "main": "dist/index.js", diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md index 073d4fc2af..2b80d8f424 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/console +## 17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 8e4edce2a2..c64eb6b068 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.4.0", + "version": "17.5.0", "description": "Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: @object-ui/console (https://github.com/objectstack-ai/objectui).", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/console", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 807bb1869d..4301cd38aa 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,626 @@ # @objectstack/core +## 17.5.0 + +### Minor Changes + +- e7ff9c2: `dateRange`'s array arm has ONE arity everywhere: a two-element window, or the ADR-0112 refusal (#17596) + + The shared conformance kit + (`analyticsDateRangeConformanceFindings`) had exactly one array case — a + two-element window — so the ARITY of the array arm was governed nowhere and + every analytics face was free to invent a meaning for `dateRange: + ['2026-01-01']`. Four faces in one package had invented three (#17124), and a + fifth — `driver-memory`'s cube face — had invented a fourth. + + **The kit** now exports `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and holds every + registered face to the rule the `service-analytics` faces already carry: an + array that is not two non-empty string bounds is refused with + `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. No new rule was invented for it, and + the existing two-element window case is untouched — it is this case's control, + so "refuse every array" cannot pass. + + **`driver-memory`** now answers that refusal instead of dropping the window. + MEASURED end to end over four rows spanning 2020…2099: `['2026-01-01']`, `[]` + and `['2026-01-01', '2026-01-31', '2026-02-01']` each emitted a pipeline + byte-identical to one with **no `dateRange` at all** — every row selected, the + "plot all of history" failure #3650 was filed about — and `[null, null]` + compared instants against the string `'null'` and selected none. + + **Levels.** `@objectstack/core` is `minor`: it gains a new exported symbol on + its index (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`), and a purely additive widening + of a published package's public surface takes at least `minor` whatever the + commit type says. `@objectstack/driver-memory` is `patch`: its public surface is + byte-unchanged — no new export, no new accepted key or value. Its behaviour does + change, from selecting every row to refusing with `400 + ANALYTICS_DATE_RANGE_UNRECOGNIZED`, and that is a `patch` because the old + behaviour was a defect and never a contract: the spec's own refusal wording + already said an explicit window is the two-element array, and the #16322 + migration table already told authors to write a single day as two bounds. A + release that stops answering a shape the contract never admitted is a fix, not a + feature — and the shapes it now refuses had no correct answer to lose. + + **If you wrote a one-element array**, write both bounds: `['2026-01-01']` + becomes `['2026-01-01', '2026-01-01']`, which selects exactly that day on every + face and did so before this change too. The refusal names the shape that + arrived, the two-element contract and that spelling. +- 98bd798: feat(spec)!: the three `kernel/plugin-lifecycle-advanced.zod.ts` duration keys carry their unit in the key name (#17780, ruling A on #15939) + + + + **BREAKING** — the health-check period, the health-check deadline and the hot-reload debounce + now carry `Ms` in the key name. + + | | before | after | + |:--|:--|:--| + | `PluginHealthCheck` | `interval: 30000` | `intervalMs: 30000` | + | `PluginHealthCheck` | `timeout: 5000` | `timeoutMs: 5000` | + | `HotReloadConfig` | `debounceDelay: 1000` | `debounceDelayMs: 1000` | + | values, defaults, min bounds | ms; 30000 / 5000 / 1000; min 1000 / 100 / 0 | **unchanged** | + + ## Migration + + ```diff + const health = PluginHealthCheckSchema.parse({ + - interval: 30000, + - timeout: 5000, + + intervalMs: 30000, + + timeoutMs: 5000, + }); + + hotReload.registerPlugin('my-plugin', { + - debounceDelay: 1000, + + debounceDelayMs: 1000, + }); + ``` + + Rename the keys. Every value is the same number of milliseconds it always was, and the + 30000 / 5000 / 1000 defaults are unchanged; nothing else on either def moves. + + ## Why + + Each key named milliseconds in a source JSDoc — "Health check interval in milliseconds", + "Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — + and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. + Measured by the `check:duration-unit-keys` census on this tree, all three read + `[name: -] [prose: -]`: no unit in the name and none in the published prose either. + `interval` was the sharpest of the three — its describe carried one unit-shaped token, the + parenthetical "(default: 30s)", naming SECONDS for a value the schema bounds and defaults in + MILLISECONDS. Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, + decision batch #115), the per-file remediation of the #14478 rule. + + The suffix is the family's own spelling, counted on this tree: 100 key-position `*Ms` + declarations across `packages/spec`, `timeoutMs` 29 of them and `intervalMs` 3. + `debounceDelay` takes the plain suffix rather than a shortened form because it is the only + debounce-shaped key spelling in the repo (no `debounceMs` variant anywhere) while the + Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, + `delayMs`) — so unlike the `Ttl`-versus-`TTL` question the sibling round settled, there was no + competing family spelling to choose between. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value + reaching the parse raises the rename prescription instead of being silently stripped — + neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, and here the + stripped value would land on a `setInterval` period, a race deadline and a `setTimeout` delay + - the ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` and + three `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: neither def is an authorable + surface — both are library parameters a host passes to `PluginHealthMonitor` / + `HotReloadManager` in TypeScript — so the chain has no seam that runs on them, the same + reading `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` + recorded for keys on these two defs + - `@objectstack/core` moves with the rename: `PluginHealthMonitor` and `HotReloadManager` read + the suffixed keys, and each class's registration-time refusal table gains a row so a host + still passing an old spelling is answered with an ADR-0112 `VALIDATION_ERROR` / 400 naming + the rename, rather than getting `undefined` where a duration belongs + - pin tests on both schemas and both classes: the refusal carries the rename prescription, the + suffixed keys parse at the magnitude the retired ones carried with the same defaults, and the + describes publish the unit. The two minimum-bound pins were rewritten rather than left: spelled + through the bare keys they would have stayed green off the tombstone's refusal instead of the + bound, so they now assert the `too_small` issue code on the suffixed keys + - `HotReloadConfig.shutdownTimeout` is deliberately NOT renamed with them — its JSDoc reads + "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape the + #14478 gate leaves outside its verdict, not part of this row set +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- 07150b3: `PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. + + Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: + + | Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | + |---|---|---| + | `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | + | `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | + + SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. + + **The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. + + **`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. + + **`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: + + - **`id`** — a non-string, or the empty string. + - **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. + - **`staticPath`** — a non-string. + - **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. + - **`default`** — a non-boolean. + - **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** + - **`description`** — a non-string. + - **`author`** — a non-string. + - **`homepage`** — a non-string, or a string that is not a URL. + + **`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. + + ⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. + + **What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. + + **What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. + + ⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. + + **Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. + + + +### Patch Changes + +- baf9745: Three source comments now state the registered position for the `door: 'none'` boot-refusal codes instead of the pre-#16404 one + + `SERVICE_NOT_REGISTERED`, `PLUGIN_CONTRACT_VIOLATION` and — as the worked + example the `driver-sql` comment cites — `MONGODB_MULTI_TENANT_UNSUPPORTED` are + all registered in `ERROR_CODE_LEDGER`. #16649 registered fourteen `door: 'none'` + codes under the #16404 door-or-no-door ruling, and re-registered the MongoDB one + that #8035 had removed. Three TSDoc comments still asserted the position that + preceded that ruling — that these codes are deliberately not wire vocabulary, + and that registering one is "not something to start doing at a door" — and each + was false the moment #16649 landed. They also pointed at + `dispatcher-error-vocabulary.ts`'s `boot-refusal` verdict, which the same PR + ratcheted from fourteen rows to zero, so the pointer dangled. + + These docblocks ship inside each package's `dist/*.d.ts`, which is why this is a + published change rather than an internal one: the sentence is what an agent or + an IDE reader sees at the point it decides whether the code needs registering. + + ⛔ No behaviour changes. Every reachability sentence is kept verbatim — none of + these codes reaches an HTTP door on this tree — no code is added, removed or + re-registered, and no gate moves. With every comment character removed by + `scripts/js-comment-mask.mjs`, all three files' executable token streams are + byte-identical to the commit this branched from. +- aaacf1d: Say what the install-time granted permission set actually does: it is REGISTERED at load and refuses nothing. + + Four shipped sentences claimed the structured `manifest.permissions` / `granted_permissions` set was enforced. Measured on `9bd4344e4`: `SecurePluginContext` — the only reader of `PluginPermissionEnforcer`'s service and hook gates — has zero production construction sites, and `enforceFileRead` / `enforceFileWrite` / `enforceNetworkRequest` are called by nothing at all, `SecurePluginContext` included. So #13457's binding registers a consented set that nothing queries, and the `fs` and `network` classes have no enforcement surface even in principle. + + Corrected, each to the same truthful split ("registered at load · queried by nothing · refuses no operation"): the `registerGrantedPermissions` docblock, the `PluginPermissions` schema docblock, the `manifest.loading` tombstone prescription, and the ADR-0087 D3 entry that ships that prescription into `docs/protocol-upgrade-guide.md`. The hand-written plugin development guide gains the same note beside its permission table. + + `plugin-runtime-tier-truthful-text.test.ts`'s coordination pin — which held the permissions half verbatim so it would go red the day that half was corrected — has been discharged and replaced by pins on the truthful text, in both carriers, each with the negative assertion that keeps the retracted sentence from returning beside it. + + New in `@objectstack/core`: `granted-permissions-not-enforced.pin.test.ts` pins the MEASUREMENT as well as the words, so the claim cannot rot in either direction. It fails the day a production `SecurePluginContext` construction site appears — i.e. the day the ADR-0025 materialize seam lands — and names every text that then becomes false. + + No behaviour changes: no accept/reject, no registration, no gate is added or removed. +- 6548118: Sweep the retracted "enforces exactly the consented surface" phrasing repo-wide, not just in the file it shipped on. + + The #17147 pin read one file, and a post-merge sweep found what that missed: `artifact-granted-permissions.test.ts` carried the retracted sentence as a CASE TITLE — "a CONSENTED entry enforces exactly the consented surface" — beside a sibling titled "registered, and denies". Neither case asserts a refusal; both read a permission bag and check what it answers. But a case title is read as evidence (ADR-0033), and those two said the platform confines plugins while nothing on the tree queries the registry at all. + + Both titles now name what they assert, the file carries a verb-discipline note (`answers` / `registered` / `bound`; ⛔ never `enforces` / `denies` / `gates` / `refuses` / `blocks` until the seam exists), and the pin's negative assertion is a repo-wide `git grep` excluding only its own specimen — with an anti-vacuity limb so a broken scan cannot read as a clean one. + + No behaviour, no assertion semantics, and no accept/reject changes. +- 4c42fd1: fix(core): an absent or empty path is no longer exempt from the ADR-0069 auth gate (#7898) + + `isAuthGateAllowlisted` answered `true` for a falsy path — it treated "no path" + as allow-listed. That is a fail-OPEN default on an authorization seam: any + caller that reached the ADR-0069 gate with an absent or empty `path` was exempt + on **every** route, and a transport author who simply forgot to populate `path` + disabled the gate with no diagnostic of any kind. + + ``` + FROM isAuthGateAllowlisted(undefined) -> true // exempt, on every route + isAuthGateAllowlisted('') -> true + + TO isAuthGateAllowlisted(undefined) -> false // exemption must be earned + isAuthGateAllowlisted('') -> false + ``` + + Exemption is now something a path has to EARN by naming an allow-listed route, + so the failure mode of omission is a `403` rather than a bypass. The predicate + is split in two so it carries exactly one meaning: a private + `matchesAllowlistedRoute` answers the route question for a real, non-empty path + — its body is unchanged, the #16839 anchoring rules included — and the exported + predicate answers "is this request exempt", which a request with no path is not. + + **No current caller's behaviour moves.** The caller census was re-run: the same + four production call sites, and no fifth. Two of them (`RestServer.enforceAuth`, + `shouldDenyAnonymous`) already guard for a non-empty path and so only ever reach + the predicate with a real string; a corpus differential against the pre-flip + predicate over more than 10,000 paths moves exactly one input — the empty string + — and nothing else, in either direction. + + **The one exemption that remains for a genuinely pathless caller is explicit**, + and lives at the one seam that really routes by body: `shouldDenyAnonymous` + declares `path` optional and decides the no-path case itself (it denies), ahead + of this predicate. That guard is deliberately kept rather than collapsed into + the now-agreeing default — a seam's contract should not be re-derived from what + a predicate happens to do with a falsy argument. + + **Known follow-up, tracked as #17625.** The dispatcher's bare-root + `` `${prefix}/` `` arrives as `cleanPath === ''` (the trailing slash is + stripped), which was exempt via the fail-open default and is not exempt now, so + a *gated* session — one carrying an `authGate`, i.e. an expired password or a + required MFA enrollment — reaching the bare root gets a `403` instead of the + discovery payload. Every named remediation route (`/auth/*`, `/health`, + `/ready`, `/discovery`, `/me/apps`, `/me/localization`) is unaffected, so + remediation itself stays reachable. Normalising that empty `cleanPath` is step 2 + of the same ruling and is **not** a tolerance re-added here. +- cf79182: `isAuthGateAllowlisted` matches allow-listed routes at a mount boundary, so an object named `auth` or a record whose id is `health` no longer bypasses the ADR-0069 authentication-policy gate. + + The predicate that decides which paths are exempt from the password-expiry / enforced-MFA gate matched with two UNANCHORED tests: `path.includes('/auth/')` matched at any position, and an `endsWith` test over `['/health', '/ready', '/discovery', '/me/apps', '/me/localization']` matched at any depth. A path segment whose VALUE merely spelled one of those tokens therefore carried the exemption — and object names and record ids are tenant-controlled. Both transport seams hand the predicate a data-plane path directly (`HttpDispatcher.enforceAuthGate` passes `cleanPath`, `RestServer.enforceAuth` passes `req.path`), so these were reachable requests. Measured on the built package before the repair: `/data/auth/123`, `/meta/auth/objects`, `/data/x/health` and `/data/xyz/me/apps` were all exempt, while `/auth/me` (exempt) and `/data/contacts/1` (gated) held as controls. + + - **What replaced them.** The path is read as segments and each test is anchored to a mount base — `/api/v1`, `/api`, or the empty base the dispatcher sees (the hono adapter hands `dispatch()` the app prefix already stripped) — plus at most one environment scope immediately after that base (`/environments/`, or ADR-0006's superseded `/projects/`), because the dispatcher evaluates the gate before its scoped-URL strip. `/auth/…` at that position stays exempt; the five bootstrap reads are EXACT routes there instead of suffixes. The scope is only recognised immediately after a base, which is why `/data/environments/x/health` is not a scoped `/health`. + - **This only ever removes exemptions.** Measured, not asserted: over a generated corpus of 111,152 paths, the number that are newly exempt is **0** and 25,979 stopped being exempt. The check is kept as a test, with the pre-anchoring predicate transcribed beside it, so a later widening cannot arrive quietly. + - **Every genuinely-exempt shape still is**, pinned in both directions: `/auth/sign-out`, `/health`, `/ready`, `/discovery` (dispatcher shapes); `/api/auth/sign-in`, `/api/v1/auth/change-password`, `/api/v1/auth/me/permissions`, `/api/v1/health`, `/api/v1/me/apps`, `/api/v1/me/localization`; and the scoped `/api/v1/environments//auth/sign-out`. + + **If you serve the API from a non-default mount,** an allow-listed route reached as `${basePath}/${version}/…` with `basePath`/`version` moved off `/api` and `v1` is no longer named by the allow-list. That price cannot be avoided: `/rest/v2/health` and `/data/xyz/health` are the same shape, so a rule that accepts an arbitrary base is the defect itself. It costs nothing at either live seam — the dispatcher's path arrives base-stripped, and REST registers its control-plane routes without `enforceAuth` at all — but if you gate a custom mount through this predicate, mount the remediation routes under one of the named bases. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index e08798270f..561b3281e8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Microkernel Core for ObjectStack", "type": "module", diff --git a/packages/create-objectstack/CHANGELOG.md b/packages/create-objectstack/CHANGELOG.md index ea590320fe..a22434c6ee 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,70 @@ # create-objectstack +## 17.5.0 + +### Patch Changes + +- fce7cd4: The scaffolded `pnpm-workspace.yaml` records the retired `@better-auth/scim>better-call` peer rule instead of advertising it as live + + `objectstack init` wrote a paragraph into every project it scaffolds explaining + an `@better-auth/scim>better-call` suppression that is not in the map it + annotates — the entry retired with objectstack#3653, and `init.test.ts` pins its + absence. All three of its claims were false on today's tree as well: + `@better-auth/scim` is not "held at a release candidate deliberately" (it is + pinned at exact stable `1.7.3`), and stable `@better-auth/scim@1.7.3` declares + `peerDependencies["better-call"]` as the exact string `1.4.0` — the single copy + `better-auth@1.7.3` itself depends on — so the `1.3.7` skew the paragraph + described does not exist. + + It now records the retirement, in the shape `create-objectstack`'s bundled + `blank` template already used, and dates the measurement the way the + neighbouring `better-sqlite3` paragraph in the same block does. Both scaffold + paths previously named `1.7.1` as the current pin; both now name the measured + `1.7.3`, so the two paths tell a user the same thing. + + Comments only — no declaration moves. The rendered `allowedVersions` map is + byte-identical before and after, so no resolution, lockfile or suppression + changes. +- f6b7c53: fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) + + A tree containing `@objectstack/cli` reports an unmet peer on every fresh + resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares + `^13.0.3` — and the reading that decides what to do about it lived only inside + the scaffold generator's prose. No range moves here and no resolution moves: + what changes is the recorded reason, which had two measured errors in it, plus + a gate that now holds the declaration to that reason. + + **The declaration is correct and stays at `^13`.** Three readings, taken rather + than inherited: + + - The peer is `optional`, and it governs exactly one configuration — a raw + better-sqlite3 `Database` passed to better-auth's `database` option. + `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or + `undefined` for better-auth's in-memory adapter. Never a `Database`. + - better-auth cannot be incompatible with better-sqlite3 13, because it never + touches it: of the 464 files in the published `better-auth@1.7.2` tarball, + exactly one names better-sqlite3 — `package.json`, the peer declaration + itself — and no code file references it (positive control: `kysely` names 9). + It accepts a `Database` the caller constructs; its own sqlite test path uses + node's built-in `node:sqlite`. + - Pinning back to `^12` is not a neutral alternative. Measured on a bare + project depending on `@objectstack/cli@17.3.0`, it clears the report only by + resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that + nothing loads. The scaffold's existing `allowedVersions` entry clears the + same report with the lockfile byte-identical. + + **Two corrections to the record.** It credited `@objectstack/driver-sql` for + the 13.x copy; on the chain that actually reports + (`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the + CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. + And it was measured on better-auth 1.7.1 while the family has been pinned at + 1.7.2 since — re-measured, with the empirical reading replaced by a structural + one. + + The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both + producers (`objectstack init` and the `create-objectstack` blank template); the + declarations, the widening entry and the resolution are untouched. + ## 17.4.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 80f367d026..32ee03a6b5 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.4.0", + "version": "17.5.0", "description": "Create a new ObjectStack project — npx create-objectstack", "bin": { "create-objectstack": "./bin/create-objectstack.js" diff --git a/packages/drivers/driver-memory/CHANGELOG.md b/packages/drivers/driver-memory/CHANGELOG.md index 6c40a06b5e..0fe2128b91 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,539 @@ # @objectstack/driver-memory +## 17.5.0 + +### Minor Changes + +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- 555a89c: fix(driver-memory): refuse a call the engine tenant-scoped, instead of silently answering with every organization's rows (#16589) + + **BREAKING** for a `driver-memory` deployment that holds more than one organization's rows: an operation the engine tenant-scoped now refuses loudly instead of answering. Shipped as `minor` under the launch-window convention, the same grading the driver's `update()`/`upsert()` type-surface narrowing used. + + Two predicates decided "is this object tenant-scoped", and they disagreed on the default case. The engine scopes an object **unless** it opts out (`buildDriverOptions`: `execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated`), while this driver's boot guard refused only an explicit opt-**in** (`declaresTenantScope`: `tenancy.enabled === true`). An object that **omits the `tenancy` block entirely** — the common case — therefore fell between them: the engine scoped it, the guard never saw it, the deployment posture really was `single` so the posture check passed, and the driver then discarded the scope and returned every organization's rows. A SQL driver refuses the same read. + + This driver still implements **no row-level tenant isolation**, and deliberately does not gain any: it declines to answer rather than answering correctly. `assertCallNotTenantScoped` is a third seam beside the two boot seams, and it judges the scope the engine actually handed over (`DriverOptions.tenantId` / `tenantIds`) rather than re-deriving the engine's predicate from object metadata — a driver that re-derived it would drift from the engine the first time that reasoning changed, and drift here is silent exposure. It runs first in every driver door that accepts a `DriverOptions`, so a refusal leaves the store exactly as it found it. + + **⚠️ Every isolation measurement previously taken on the memory driver is void and must be re-taken.** A suite asserting "tenant A cannot see tenant B's rows" passed here trivially — not because isolation worked, but because both tenants' rows came back to every caller and the assertion was written against a single tenant's fixture. An app that proved out its isolation model on this driver measured nothing. + + What is unaffected, and why: an object declaring `tenancy: { enabled: false }` is never scoped by the engine (ADR-0066), so the driver never sees a scope for it and serves it unchanged; a caller with no organization context is never scoped either, which is the ordinary dev, example-app and single-organization path. Only a call that actually arrives carrying a tenant scope is refused. A deployment that needs organization-scoped reads in development uses `@objectstack/driver-sql`, whose `:memory:` connection is the closest in-process replacement; a deployment whose data genuinely is platform-global can say so with the ADR-0066 posture, which stops the engine scoping it at all. + + The refusal reuses the existing `MemoryMultiTenantUnsupportedError` and its `MEMORY_MULTI_TENANT_UNSUPPORTED` code rather than introducing a second error family: the cause is identical, so a host that already recognises the boot refusal recognises this one with no new code and no second code to learn. + + Also corrects `declaresTenantScope`'s docstring, which closed on a false sentence — "every object in a single-tenant deployment omits the block". A `single` posture constrains the **wall**, not the number of organizations: a `single`-posture run was measured holding 13 `sys_organization` rows, with each row carrying whichever `organization_id` it was written with. The sentence is recorded as superseded rather than deleted, because it is what justified the predicate being an opt-in test. + + +- b90aff8: fix(driver-memory): a scalar comparand against a stored ARRAY is read as membership on both filter faces, so a filter written to narrow stops returning rows it never selected (#16838) + + `memory-matcher.ts`'s equality arm ended in `value == condition`. Loose `==` converts a stored ARRAY to a primitive — `['a','b']` becomes the string `"a,b"` — so this package's reference matcher and its live query path (`InMemoryDriver.find`, through mingo) answered the same filter two different ways, in both directions at once: + + | filter | stored value | reference matcher, before | live query path | + |---|---|---|---| + | `{ tags: 'a' }` | `['a','b']` | no row | the row | + | `{ tags: 'a,b' }` | `['a','b']` | the row | no row | + | `{ tags: 'a' }` | `['a']` | the row | the row | + + The second row is the sharper one: a **false positive**, a filter written to narrow returning a row it should not, which on a read scope is a permission concern rather than a degraded filter. The first is fail-open in the other direction and just as silent — `if (!rows.length)` cannot tell "genuinely none" from "the predicate asked the wrong question". + + **What changes.** A stored array is now read as its elements, and each is asked the question the arm asks of a scalar: the answer for a row storing an array is the OR of the answers for the rows storing its elements. That is MongoDB's array semantics and therefore mingo's, so the reference face converges on the path this package's users actually run rather than on a third reading nobody wrote. One level only — a nested array is not descended into, matching mingo. `$eq` and `$ne` take the same equality as the implicit spelling, so `$ne` stays the exact complement. + + **What does not change.** An array in the **comparand** position is still refused (`INVALID_FILTER` / 400) by the shape gate every face of this package runs; this is the VALUE side, which that door does not judge. The live query path is untouched — it already answered membership — so a caller who only ever used `find()` sees no difference. Callers who compared results against the reference matcher, or who ran it directly as a driver double, will see a stored array select on membership instead of on its joined string. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. +- 9c44eed: fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296) + + + + ## ADR-0087 disposition + + `second`, `minute` and `hour` leave a published closed enum that reaches TWO authored sites: an analytics request body's `timeDimensions[].granularity`, and an analytics cube dimension's `granularities[]`, which is stored metadata (`defineCube()` / `defineStack({ analyticsCubes })`). The stored half is rewritten by the D2 conversion `cube-sub-day-granularities-removed`, which strips the retired members from `analyticsCubes[].dimensions..granularities` and drops the key entirely when nothing coarser remains (an empty list would read as "offers none", the absent key as "offers all"). The semantic entry `time-update-interval-sub-day-retired` carries the half no transform can decide: a dimension that offered ONLY sub-day intervals needs an author to say what it actually serves. `day`, `week`, `month`, `quarter` and `year` are untouched and parse byte-identically. + + **BREAKING** for anyone authoring or sending `granularity: 'second'`, + `'minute'` or `'hour'`, and for anyone importing the `TimeUpdateInterval` + TYPE. Landing in the + launch window as `minor` under the lockstep convention this cluster's siblings + already use. + + ## What was wrong + + `TimeUpdateInterval` declared **eight** intervals. The rest of the contract + never carried three of them, and this is the measurement rather than the + argument: + + | layer | declares | + |:---|:---| + | `TimeUpdateInterval` (`data/analytics.zod.ts`) | **8** — the five below plus `second`, `minute`, `hour` | + | `DateGranularity` (`data/query.zod.ts`) — what a `groupBy` entry and every driver bucket expression are typed by | 5 | + | `@objectstack/core`'s `BUCKET_GRANULARITIES` — the canonical bucket-KEY output contract a drill-down crosses | 5 | + | `driver-mongodb`'s `MONGODB_DATE_GRANULARITIES` | 5 | + + `DriverCapabilitiesSchema.supports.queryDateGranularity` — the one mechanism a + backend has for saying which granularities it buckets natively — is a + `z.record(DateGranularity, boolean)`. Measured: `{ day, week, month, quarter, + year }` parses; the same record plus `hour` raises `unrecognized_keys: ["hour"]`. + **No driver could advertise sub-day bucketing even if it had one.** That is what + makes this a retirement rather than a capability gap: a declared value one + backend cannot serve is a gap and the contract has a place to say so, but a + declared value *no* backend can even claim has no counterpart anywhere in the + contract that carries it. + + Driven against the built packages, two rows fourteen hours apart on one UTC + calendar day, before this change: + + | face | `granularity: 'hour'` | `granularity: 'day'` (control) | + |:---|:---|:---| + | `driver-memory` analytics | `NOT_IMPLEMENTED` / 501 | 1 group, `2026-09-06` | + | `driver-mongodb` bucket builder | `NOT_IMPLEMENTED` / 501 | `$dateToString` `%Y-%m-%d` | + | engine in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity | **200, 2 groups keyed on the RAW instant** | 1 group, `2026-09-06` | + + Two honest refusals and one silently wrong answer. No third behaviour, and no + backend that bucketed it. + + ## What changed + + - `TimeUpdateInterval` is now `z.enum(DateGranularity.options, …)` — the members + come from the single source instead of a second literal list that disagreed + with it by three members for as long as both existed. + - A refusal message splits two populations that are not the same mistake: a + **retired** sub-day name gets the retirement and the `os migrate meta --from + 17` line; anything else gets the vocabulary. `driver-memory`'s own analytics + door carries the same split. + - `driver-memory`'s `NOT_IMPLEMENTED` / 501 answer for these three is **not + silenced** — the declaration it announced is gone, so the class moves to the + 400 the retirement makes correct. The 501 arm stays, and a pin measures that + its population is now empty (`TimeUpdateInterval.options` equals + `BUCKET_GRANULARITIES`), so the day one of the two is widened alone it lights + up again instead of a freshly declared value being called undeclared. + + ## What this does NOT decide + + Sub-day analytics bucketing as a **capability**. Offering it means widening + `DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key + vocabulary and every driver's bucket expression together — new capability, + decided as such, rather than a name that parses in one enum and resolves + nowhere. + +### Patch Changes + +- e7ff9c2: `dateRange`'s array arm has ONE arity everywhere: a two-element window, or the ADR-0112 refusal (#17596) + + The shared conformance kit + (`analyticsDateRangeConformanceFindings`) had exactly one array case — a + two-element window — so the ARITY of the array arm was governed nowhere and + every analytics face was free to invent a meaning for `dateRange: + ['2026-01-01']`. Four faces in one package had invented three (#17124), and a + fifth — `driver-memory`'s cube face — had invented a fourth. + + **The kit** now exports `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and holds every + registered face to the rule the `service-analytics` faces already carry: an + array that is not two non-empty string bounds is refused with + `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. No new rule was invented for it, and + the existing two-element window case is untouched — it is this case's control, + so "refuse every array" cannot pass. + + **`driver-memory`** now answers that refusal instead of dropping the window. + MEASURED end to end over four rows spanning 2020…2099: `['2026-01-01']`, `[]` + and `['2026-01-01', '2026-01-31', '2026-02-01']` each emitted a pipeline + byte-identical to one with **no `dateRange` at all** — every row selected, the + "plot all of history" failure #3650 was filed about — and `[null, null]` + compared instants against the string `'null'` and selected none. + + **Levels.** `@objectstack/core` is `minor`: it gains a new exported symbol on + its index (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`), and a purely additive widening + of a published package's public surface takes at least `minor` whatever the + commit type says. `@objectstack/driver-memory` is `patch`: its public surface is + byte-unchanged — no new export, no new accepted key or value. Its behaviour does + change, from selecting every row to refusing with `400 + ANALYTICS_DATE_RANGE_UNRECOGNIZED`, and that is a `patch` because the old + behaviour was a defect and never a contract: the spec's own refusal wording + already said an explicit window is the two-element array, and the #16322 + migration table already told authors to write a single day as two bounds. A + release that stops answering a shape the contract never admitted is a fix, not a + feature — and the shapes it now refuses had no correct answer to lose. + + **If you wrote a one-element array**, write both bounds: `['2026-01-01']` + becomes `['2026-01-01', '2026-01-01']`, which selects exactly that day on every + face and did so before this change too. The refusal names the shape that + arrived, the two-element contract and that spelling. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 0ce5cec066..2bb55234d0 100644 --- a/packages/drivers/driver-memory/package.json +++ b/packages/drivers/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "In-Memory Driver for ObjectStack (Reference Implementation)", "main": "dist/index.js", diff --git a/packages/drivers/driver-mongodb/CHANGELOG.md b/packages/drivers/driver-mongodb/CHANGELOG.md index 329093f028..eb3a73ffc5 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,134 @@ # @objectstack/driver-mongodb +## 17.5.0 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index 5fb6a0b0d4..26c955fdde 100644 --- a/packages/drivers/driver-mongodb/package.json +++ b/packages/drivers/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "MongoDB Driver for ObjectStack - Native document database driver via official mongodb client", "main": "dist/index.js", diff --git a/packages/drivers/driver-sql/CHANGELOG.md b/packages/drivers/driver-sql/CHANGELOG.md index da91d9f31c..c17fe46c00 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,737 @@ # @objectstack/driver-sql +## 17.5.0 + +### Minor Changes + +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- e04a0af: `$contains` on a multi-valued / JSON column is a MEMBERSHIP test, compiled per dialect so SQLite, MySQL and PostgreSQL answer the same rows. + + `$contains` is the membership spelling on a `multiple: true` field or a `JSON_COLUMN_TYPES` member — the one operator that kept working on a JSON column after the scalar-comparison family was refused there, and the spelling that refusal's own message prescribes. It was lowered like any other text operator, so each backend was asked about the SERIALIZATION rather than about the members, and the three answered three different things: SQLite matched a substring of the stored array text, MySQL coerced its `json` column for `LIKE` and matched the same substring, and PostgreSQL raised SQLSTATE 42883 (`operator does not exist: json ~~ text`) — a `DATABASE_ERROR` 500 for a filter the spec accepts. + + `driver-sql` now compiles a real membership construct per dialect: `jsonb` containment on PostgreSQL, `JSON_CONTAINS` on MySQL, a `json_each` scan on SQLite. `$notContains` moves with it as its exact complement. + + **Behaviour change on SQLite and MySQL, in the narrowing direction.** Where the substring reading matched ACROSS element boundaries it no longer does: `{ tags: { $contains: 'red' } }` stops answering a row whose only tag is `redwood`, and `{ nums: { $contains: '1' } }` stops answering a row holding `[10, 21]`. Those rows were wrong answers, not a contract — a filter that needs the old reading is asking for a substring search over a serialization and should be written against a scalar column. On PostgreSQL the same filters change from a 500 to the member rows. + + Unchanged: `$contains` on a scalar string column is still the case-sensitive substring test, and the rest of the text family (`$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) keeps the lowering it had on every column. + + `packages/spec`'s `StringOperatorSchema` docblock — published source — now states the membership reading and records, per face, which runtimes answer it. +- be5c602: fix(driver-sql,driver-turso): eight more `IDataDriver` doors publish their declared return type, not a nested `any` (#17690) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (PR #15280 for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, PR #17258 for the five `SqlDriver` doors of #15267, PR #17689 for `aggregate()`). No runtime behaviour changes. + + Eight doors published an annotation whose `any` sat **inside** a wider type, while `packages/spec/src/contracts/data-driver.ts` had already declared each one narrower. A consumer holding one of these classes got `any` back and the compiler stopped checking: + + | class | door | published | now | + |---|---|---|---| + | `SqlDriver` | `find` | `Promise` | `Promise[]>` | + | `SqlDriver` | `upsert` | `Promise>` | `Promise>` | + | `SqlDriver` | `bulkUpdate` | `Promise[]>` | `Promise[]>` | + | `SqlDriver` | `temporalFilterValue` | `any` | `unknown` | + | `TursoDriver` | `find` (override) | `Promise` | `Promise[]>` | + | `TursoDriver` | `upsert` (override) | `Promise>` | `Promise>` | + | `TursoDriver` | `bulkUpdate` (override) | `Promise[]>` | `Promise[]>` | + | `RemoteTransport` | `beginTransaction` | `Promise` | `Promise` | + + The `TursoDriver` rows are separate sites, not consequences: an override re-declares the door in that package's own `.d.ts`, so the `@objectstack/driver-sql` narrowing does not reach a consumer holding a `TursoDriver`. + + **What a consumer does.** A cell read off a row now arrives as `unknown` and is typed before use (`String(row.name)`, `Number(cell)`, or a `typeof` narrowing); `Array.prototype.find` over a result set answers `… | undefined` and the absent arm is separated rather than asserted past. Measured across the whole consumer closure of both packages at this change's tree — 115 `typecheck` tasks — the repo-wide cost is **11 sites**, all inside `@objectstack/driver-sql` (9) and `@objectstack/driver-sqlite-wasm` (2), and **zero** outside the driver packages. + + `TursoDriver.beginTransaction` is deliberately NOT narrowed here and stays `Promise`. It overrides `SqlDriver.beginTransaction(): Promise` — narrower than the contract, the honest direction, and the binding declaration for an override — so the contract's `Promise` does not compile there (TS2416). That `any` masks an LSP violation, not an un-narrowed door, and closing it is a separate decision. + + +- 88a9330: feat(driver-sql): `aggregate()` publishes its declared return type — the contract's own, not `any` (#17277) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for this class of change on `@objectstack/driver-memory`, and PRs #15280 and #15267 followed on this very class). `SqlDriver.aggregate()` carried an EXPLICIT `Promise` over a door `IDataDriver` had already declared narrower: `aggregate?(object, query, options?): Promise[]>`. An explicit `any` satisfies that structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that an aggregate row is whatever they like. + + The door is now declared as the contract declares it. A caller that read a cell straight off an aggregate row through the `any` now types what it reads — an aggregate cell arrives as `unknown` — and a caller that indexed the result array, or took `.find()` on it, now narrows the absent arm first. No runtime behaviour changes. + + `aggregate()` is OPTIONAL on the contract (`aggregate?`) where the five doors #15267 moved are required. That governs whether the member EXISTS, not what it returns once it does: a consumer that has already guarded `typeof driver.aggregate === 'function'` — the engine's own dispatch — holds a function whose published return was `any` and is now the contract's record array. The narrowing reaches it either way. + + `@objectstack/driver-sqlite-wasm` does not override this door and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides it and carries its own entry. + + +- 3cbcedb: feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for the same class of change on `@objectstack/driver-memory`, and PR #15280 followed for `update()` on this very class). `SqlDriver` carried an EXPLICIT `Promise` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record | null` — it has always answered `results[0] || null`), `create()` (`Record`), `bulkCreate()` (`Record[]`), `execute()` (`unknown`) and `explain()` (`unknown`). An explicit `any` satisfies all five structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that `findOne()` never returns `null` and that `create()` returns whatever they like. #15280 un-masked `update()` and filed the census of what was left; this is that remainder. + + Each door is now declared as the contract declares it. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members off `create()` / `bulkCreate()`, or to dereference a raw `execute()` / `explain()` result, now types what it reads. No runtime behaviour changes. + + `@objectstack/driver-sqlite-wasm` overrides none of these five and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides four of the five and carries its own entry. + + Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations. + + +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- 77c801e: feat(driver-sql)!: the file family's physical column holds the bare `sys_file` id, per deployment (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`, under the maintainer ruling on #15041 (decision batch #49 item 1), verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」. The physical column for the file family — `file` / `image` / `avatar` / `video` / `audio` — holds the **actual `sys_file` id**, a bare id string in a string column, rather than a JSON-quoted id in a JSON column. The SQL generator already emitted `VARCHAR(2048)` for the family and does not move; the driver is the side that moves. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + **The switch is per DEPLOYMENT, and its default is today's encoding.** The ADR-0104 addendum forbids keying it on the `adr-0104-file-references` flag alone: every creation-attested store since 17.0 and every deployment that ran `os migrate files-to-references --apply` before a column step existed holds that flag *and* JSON-quoted ids in a JSON column. The evidence is `sys_migration.columns_moved_at`, which reaches this driver as the new published option `SqlDriverConfig.fileColumnsMoved` — a boolean or an async resolver, resolved once at `initObjects` and memoized. **Every way of not knowing answers "not moved"**: the option omitted, a resolver that throws, a resolver that never runs, a host that never calls `initObjects`. Absence is the JSON arm because every flag row that exists in the world today lacks the field, and a driver that guessed the other way would write bare ids into a JSON column. + + **What an UNMOVED deployment gets** — which is every deployment until something supplies that option — is today's driver, with exactly one answer changed: + + - the column is still `json` / `jsonb` / SQLite `TEXT`, the write still JSON-encodes, and `isJsonField` still answers `true` for the family; + - a media cell whose bytes are a JSON-quoted id **sitting in a character column** now reads back as the id instead of as the id with its quotes. That population is not hypothetical: a database built by `os generate migration --format sql` has a `VARCHAR(2048)` media column, and MEASURED on live PostgreSQL 16.13, the driver wrote `"file_01HXYZ"` into it and handed it back verbatim — every consumer that matches the raw stored form (file resolution, ownership claims) refused it. SQLite never had this defect: its read arm parses the cell and keeps the raw string when the parse fails, which is why the gap was a server-dialect one. + + **What a MOVED deployment gets**: the family leaves `JSON_COLUMN_TYPES`, so `isJsonField` / `formatInput` / `formatOutput` stop treating a single-value media field as JSON; `createColumn` builds `varchar(2048)` — the generator's own width, mirrored by `varcharColumnChars` so the drift detector reads the column the emitter actually builds; and the id on disk is the id. Throughout the window the read path accepts **both** encodings on every dialect, so a cell a column step has not converted still reads correctly. The decode is deliberately narrow — it engages only on a leading `"`, `{` or `[`, none of which can begin a `sys_file` id, a resolver URL or a `data:` URI — because an all-digit id would otherwise parse to a number. + + `multiple: true` media is unaffected on both arms: its value is a list of ids, it is a JSON column on every deployment, and `createColumn` decides `multiple` above its type switch. + + **The drift detector moves with the writer.** `JSON_COLUMN_FIELD_TYPES` no longer names the family, because the family is no longer a constant on either side; `diffManagedTable` takes a `fileColumnsMoved` input instead, and OMITTING it reproduces this module's previous verdicts exactly — an unthreaded caller keeps reporting a `varchar` media column as the corruption it still is on an unmoved deployment. Without this, a deployment that moved its columns would be told by its own tooling to convert them back to `json`, i.e. to undo the ruling. + + **Not shipped here, and named rather than implied:** the column step itself. `os migrate files-to-references --apply` does not yet retype or rewrite media columns, and nothing in this diff moves any deployment's storage. A deployment moves only when it runs that step and its host supplies the arm, and the two must be one act — MEASURED on SQLite: after the columns are converted, a driver still on the JSON arm reads the migrated column correctly but its next write re-quotes. +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- baf9745: Three source comments now state the registered position for the `door: 'none'` boot-refusal codes instead of the pre-#16404 one + + `SERVICE_NOT_REGISTERED`, `PLUGIN_CONTRACT_VIOLATION` and — as the worked + example the `driver-sql` comment cites — `MONGODB_MULTI_TENANT_UNSUPPORTED` are + all registered in `ERROR_CODE_LEDGER`. #16649 registered fourteen `door: 'none'` + codes under the #16404 door-or-no-door ruling, and re-registered the MongoDB one + that #8035 had removed. Three TSDoc comments still asserted the position that + preceded that ruling — that these codes are deliberately not wire vocabulary, + and that registering one is "not something to start doing at a door" — and each + was false the moment #16649 landed. They also pointed at + `dispatcher-error-vocabulary.ts`'s `boot-refusal` verdict, which the same PR + ratcheted from fourteen rows to zero, so the pointer dangled. + + These docblocks ship inside each package's `dist/*.d.ts`, which is why this is a + published change rather than an internal one: the sentence is what an agent or + an IDE reader sees at the point it decides whether the code needs registering. + + ⛔ No behaviour changes. Every reachability sentence is kept verbatim — none of + these codes reaches an HTTP door on this tree — no code is added, removed or + re-registered, and no gate moves. With every comment character removed by + `scripts/js-comment-mask.mjs`, all three files' executable token streams are + byte-identical to the commit this branched from. +- 82cb69f: A `multiple: true` boolean column keeps its `$contains` membership filter + + A `multiple: true` field is stored as a JSON TEXT array, and on such a column + `$contains` is not a substring test — it is the MEMBERSHIP spelling, the one + operator #7398 left working there after refusing the equality family. The + declared-type gate added in #14079 fired on the boolean limb regardless of + storage shape, so a membership filter over a `multiple: true` `boolean` or + `toggle` column compiled to the always-false constant: + + ``` + { flags: { $contains: 'true' } } + - select * from `probe_tbl` where 1 = 0 (matched nothing) + + select * from `probe_tbl` where `flags` GLOB '*true*' (matches the rows whose array holds it) + ``` + + That is the fail-CLOSED direction: the query returns a `200` with no rows, + byte-identical to a filter that legitimately matched nothing, so an author sees + "no matching records" and doubts their data rather than the filter. Both + registry fills — `initObjects` and `registerExternalObject` — were affected, and + both are fixed, because the repair is at the predicate they share. + + The same shape on a `multiple: true` NUMBER was already correct (its registry is + filled `!field.multiple`), and #15683 spelled the equivalent carve-out for the + temporal limb at the predicate. This change spells it on the boolean limb, the + one that had neither. `booleanFields` itself is deliberately unchanged: it is a + read-coercion registry, and the three other seams that read it — the Postgres + aggregate cast, the presentation-kind door and `formatOutput`'s row pass — are + about "this column holds a boolean", which a multi-valued column still does. + + ⚠️ Not a widening of the gate: a SCALAR `boolean` / `toggle` column still + answers the declared no-match for every positive text operator and `$notContains` + its exact complement, unchanged. What moves is exactly the JSON-column cell. +- d46deba: A `multiple: true` boolean/toggle column reads back as its stored array, not as a single inverted `true` + + `formatOutput` runs its `jsonFields` pass first, which `JSON.parse`s the cell + into a real array, and then its `booleanFields` pass did + `data[field] = Boolean(data[field])`. Every non-empty array is truthy, so a + `multiple: true` `boolean`/`toggle` column presented a single `true` whatever + the array held — a stored `[false]` read back as **`true`**, the opposite of + what is stored, with no error anywhere. `readPresentationKind` hands the same + presenter to the `aggregate()` / `distinct()` doors, so the collapse was not + confined to the row-read door. + + **Fixed at the registry fill.** `&& !field.multiple` is the condition the three + neighbouring pushes in both registration blocks already carry (`mediaCols`, + `numericCols`, `numericValueCols`); `booleanCols.push(name)` was the single + omission, in **both** fills (`registerExternalObject` and + `registerManagedObjectMetadata`). A `multiple: true` boolean/toggle is a JSON + column here, and its array is written faithfully — only the read collapsed it. + + **What moves for a caller.** A `find()` / `aggregate()` / `distinct()` read of a + `multiple: true` `boolean` or `toggle` column now returns the stored array of JS + booleans (`[false]`, `[true, false]`) where it previously returned `true`. Code + that consumed the old scalar was reading a value that did not reflect storage — + including for an all-`false` array. Scalar `boolean`/`toggle` columns are + unchanged and keep their stored-`1`/`0` → JS `true`/`false` coercion; the + `multiple: true` number and `tags` classes were already correct and do not move. +- 7c2c5ae: `distinct()` answers a backend refusal with the ADR-0112 envelope instead of leaking the dialect's own error + + `SqlDriver.distinct` awaited its query builder bare — no `try`/`catch`, no + envelope — so any refusal the statement raised left the driver as the backend's + own object: a raw SQLSTATE in `code`, `status` **undefined**, and the compiled + statement as the message. `@objectstack/rest` builds a wire status from the + envelope, so an error carrying no `status` and a `code` that is a raw SQLSTATE + is on no list it reads: an ordinary caller shape — *list the distinct values of + this column* — surfaced as an UNHANDLED server fault rather than a declared + `DATABASE_ERROR` 500. + + Measured on live PostgreSQL 16.13: this driver stores every `multiple: true` + column as `json`, and PostgreSQL's `json` defines no equality operator, so + `SELECT DISTINCT` over one is refused — + `code=42883 status=undefined`, `msg=select distinct "toggles" from "…" - could + not identify an equality operator for type json`. Class-wide across every JSON + column (`toggle`, `boolean` and `number` with `multiple: true`, and `tags`), + with a scalar `boolean` column in the same table answering normally. + + The third read door now routes through the same terminal + `backendStatementFault` that `find()` and `count()` have used since + objectstack#8931 and `aggregate()` since objectstack#11455: one catalogued + code, one status, the dialect's own text written to the server log for an + operator and withheld from the caller, and the original error kept as a + non-enumerable `cause` so `isMissingTableError` still reads through it. + + ⛔ No new export, no new error code, no new envelope field, and the accepted + input set does not move: `status` and `code` are fields this envelope already + declares. ⛔ This does not make `distinct()` ANSWER over a `json` column — the + call fails either way; what changes is whether the failure is classified. + Whether such a column should support a distinct read belongs with + objectstack#17590. +- 9ccc417: `SqlDriver.distinct()` now answers **one unresolvable column the way the other three read doors do** — a `400` that names it — instead of a `DATABASE_ERROR` / `500` server fault. + + The same condition (a column name the table does not have) asked at four doors used to get three answers and one server fault. Measured on `origin/main` at `dbea1756d9`, embedded SQLite, and identical on live PostgreSQL 16.13: + + | door | before | after | + |:--|:--|:--| + | `count(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 | unchanged | + | `find(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 | unchanged | + | `aggregate(t, { groupBy: ['nosuchcol'] })` | `INVALID_FIELD` / 400 | unchanged | + | `distinct(t, 'title', { nosuchcol: 1 })` | **`DATABASE_ERROR` / 500** | **`INVALID_FILTER` / 400** | + | `distinct(t, 'nosuchcol')` | **`DATABASE_ERROR` / 500** | **`INVALID_FIELD` / 400** | + + A caller's own mistake — a field name that does not exist — was served as a server fault naming nothing they could act on, one door away from a `400` that names the column. A picklist-populating `distinct()` sits beside the `find()` and `count()` of the same list view. + + **Attribution comes from the caller's own request, never from the backend's prose.** The dialect names the column but not the clause, so the clause is read off the call this driver just compiled — the shape `aggregateBackendFault` established for `aggregate()`: + + 1. the name **equals the `field` argument** ⇒ `INVALID_FIELD` / 400 naming the listed column, with the `field` and `object` riders the ingress door's refusals carry; + 2. it does not ⇒ the statement's only remaining column sources are the WHERE compiled from `filters` and the tenant-scope predicate, both filters, so the existing `INVALID_FILTER` refusal applies verbatim — the same sentence `find()` and `count()` give; + 3. the dialect wording yields **no name** ⇒ no attribution is supportable and the terminal `DATABASE_ERROR` / 500 envelope stands unchanged. + + Arm 2 is the **complement** of arm 1 rather than a search of the `filters` AST, which keeps a nested filter (`{ $or: [{ nosuchcol: 1 }] }`) on the same `400` as a flat one. + + ⛔ **No input that was refused before is accepted now, and no exported symbol moves.** The call fails either way; what changes is the refusal's code, status and words. No error code is minted — `INVALID_FIELD` is a standard-catalog member (ADR-0112) and already this repo's answer for a named column an object does not have. No new dialect recognizer is added: both predicates are the ones `find()`, `count()` and `aggregate()` already share. + + A caller that branched on `DATABASE_ERROR` / `500` for a mistyped `distinct()` field or filter key now sees `INVALID_FIELD` / `INVALID_FILTER` `400`s instead; that is the point of the change, and it matches what the same mistake already returned from every other read door. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index be45e44a66..7d86bd873f 100644 --- a/packages/drivers/driver-sql/package.json +++ b/packages/drivers/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "SQL Driver for ObjectStack - Supports PostgreSQL, MySQL, SQLite via Knex", "main": "dist/index.js", diff --git a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md index 7c8d9f6be7..972aa9f26f 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,237 @@ # @objectstack/driver-sqlite-wasm +## 17.5.0 + +### Minor Changes + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [7c2c5ae] +- Updated dependencies [be5c602] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index dbc85948e0..2174ef4de1 100644 --- a/packages/drivers/driver-sqlite-wasm/package.json +++ b/packages/drivers/driver-sqlite-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sqlite-wasm", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "WASM SQLite Driver for ObjectStack — runs in browser/WebContainer (StackBlitz) without native bindings", "keywords": [ diff --git a/packages/drivers/driver-turso/CHANGELOG.md b/packages/drivers/driver-turso/CHANGELOG.md index 845216d720..eb539f77ef 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,300 @@ # @objectstack/driver-turso +## 17.5.0 + +### Minor Changes + +- be5c602: fix(driver-sql,driver-turso): eight more `IDataDriver` doors publish their declared return type, not a nested `any` (#17690) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (PR #15280 for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, PR #17258 for the five `SqlDriver` doors of #15267, PR #17689 for `aggregate()`). No runtime behaviour changes. + + Eight doors published an annotation whose `any` sat **inside** a wider type, while `packages/spec/src/contracts/data-driver.ts` had already declared each one narrower. A consumer holding one of these classes got `any` back and the compiler stopped checking: + + | class | door | published | now | + |---|---|---|---| + | `SqlDriver` | `find` | `Promise` | `Promise[]>` | + | `SqlDriver` | `upsert` | `Promise>` | `Promise>` | + | `SqlDriver` | `bulkUpdate` | `Promise[]>` | `Promise[]>` | + | `SqlDriver` | `temporalFilterValue` | `any` | `unknown` | + | `TursoDriver` | `find` (override) | `Promise` | `Promise[]>` | + | `TursoDriver` | `upsert` (override) | `Promise>` | `Promise>` | + | `TursoDriver` | `bulkUpdate` (override) | `Promise[]>` | `Promise[]>` | + | `RemoteTransport` | `beginTransaction` | `Promise` | `Promise` | + + The `TursoDriver` rows are separate sites, not consequences: an override re-declares the door in that package's own `.d.ts`, so the `@objectstack/driver-sql` narrowing does not reach a consumer holding a `TursoDriver`. + + **What a consumer does.** A cell read off a row now arrives as `unknown` and is typed before use (`String(row.name)`, `Number(cell)`, or a `typeof` narrowing); `Array.prototype.find` over a result set answers `… | undefined` and the absent arm is separated rather than asserted past. Measured across the whole consumer closure of both packages at this change's tree — 115 `typecheck` tasks — the repo-wide cost is **11 sites**, all inside `@objectstack/driver-sql` (9) and `@objectstack/driver-sqlite-wasm` (2), and **zero** outside the driver packages. + + `TursoDriver.beginTransaction` is deliberately NOT narrowed here and stays `Promise`. It overrides `SqlDriver.beginTransaction(): Promise` — narrower than the contract, the honest direction, and the binding declaration for an override — so the contract's `Promise` does not compile there (TS2416). That `any` masks an LSP violation, not an un-narrowed door, and closing it is a separate decision. + + +- 88a9330: feat(driver-turso): the `aggregate()` override publishes its declared return type, not `any` (#17277) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit this door from `SqlDriver` — it OVERRIDES `aggregate()`, and the override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared the door as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()` and PR #15267 for four more doors. + + Both branches already answered the contract's type: the remote branch passes `RemoteTransport.aggregate()`, already declared `Promise[]>`, and the local branch forwards to `SqlDriver.aggregate()`, narrowed alongside (#17277). The override now declares what it has always answered. A caller that read a cell straight off an aggregate row through the `any` now types what it reads. No runtime behaviour changes. + + Out of scope and deliberately unmoved: `upsert()` and `beginTransaction()` keep their annotations. + + +- 3cbcedb: feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit these doors from `SqlDriver` — it OVERRIDES `findOne()`, `create()`, `bulkCreate()` and `execute()`, and each override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared four of the five doors as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()`. + + Both branches of every one of the four already answered the contract's type: the local branch forwards to `SqlDriver`'s door (narrowed alongside, #15267) and the remote branch passes `RemoteTransport`'s result — already declared `Record | null`, `Record`, `Record[]` and `unknown` respectively — through the generic `formatRemoteRow` / `formatRemoteRows`. Each override now declares what it has always answered. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first. No runtime behaviour changes. + + `explain()` is not overridden here and reaches these consumers through `@objectstack/driver-sql`. Out of scope and deliberately unmoved: `upsert()`, `aggregate()` and `beginTransaction()` keep their annotations. + + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- bdea10a: fix(driver-turso): remote mode materializes every declared object-level index, not only field-level `unique` (#17609) + + ## What was wrong + + In remote mode (`libsql://` / `https://`), `TursoDriver` provisions tables through `RemoteTransport`, and the only index DDL that path could emit came from field-level `unique`. An object's declared `indexes: [...]` — unique or not — had no consumer there, so no remote database ever carried one. The local face (`SqlDriver`) created all of them, so nothing failed and no local test noticed: on a remote tenant database `sys_notification_delivery` (five declared indexes) and `sys_job_queue` (three) held only their primary-key autoindex, and the delivery claim query answered every poll with a full table scan (`SCAN sys_notification_delivery` + `USE TEMP B-TREE FOR ORDER BY`). + + ## What changes + + - Remote mode now creates **every** declared index: field-level `unique` plus the object's own `indexes`, unique and non-unique, including `unique: 'organization'` with its NULL-safe `COALESCE(, '__global__')` key part. Names and keys come from the same shared normalizers `SqlDriver` and the drift differ use (`uniqueIndexesFromFields`, `normalizeDeclaredIndex`, `buildIndexName`), so both faces land the same index set — pinned by a new local/remote parity suite that compares `sqlite_master` on both. + - New tables get their indexes in the same batch as `CREATE TABLE`. + - **Existing tables are retrofitted on the next schema sync** with `CREATE [UNIQUE] INDEX IF NOT EXISTS`. No row is read-modified or rewritten. + - An index the retrofit cannot create is reported once at `error`, naming the index, the table and the database's own cause. A declared `unique` index over rows that already violate it is **not** forced and no data is repaired: de-duplicate the key's values and re-run schema sync. + - Steady-state cost goes down: a sync now reads the existing index names once (one statement, folded into the column-probe batch it already sends) and issues no index DDL when every declared index exists. Before, every boot re-sent one `CREATE UNIQUE INDEX IF NOT EXISTS` per field-level unique index on an existing table. + + ## Upgrading + + Nothing to change in metadata or configuration. The first kernel build after upgrading creates the missing indexes on each existing remote database — on a large table that one build pays the index build time. Watch the boot log for `could not create the declared` lines at `error`: each names an index that is still absent and why. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [7c2c5ae] +- Updated dependencies [be5c602] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 571c9abb9c..5dee92c472 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-turso", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas", "keywords": [ diff --git a/packages/formula/CHANGELOG.md b/packages/formula/CHANGELOG.md index ca6b37ce71..a1bdb430d5 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,219 @@ # @objectstack/formula +## 17.5.0 + +### Minor Changes + +- 5505646: fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on published + CHECKERS, in the same sense as a route that starts refusing a request it should + always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is + not the carrier of breaking-ness; this paragraph and the disposition above + are). Nothing that was already reported stops being reported, and no source + that is correct starts being reported. + + `firstUndeclaredReference` asks cel-js's checker for the first undeclared + identifier in a source. That checker returns exactly ONE error, and the helper + acts only on `Unknown variable: X`, so whenever the first error is of another + class every undeclared reference behind it in the same source went unjudged and + the helper answered `null` — which is also the value that means "every + reference is rooted". Four published call sites read that answer, and none of + them can tell the two readings apart. + + The widest way to reach that state was a disagreement between two environments + in this package about the same names. The strict env declared every + `SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, + `input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` + type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an + ordinary comparison on one of those names compiled clean and then faulted `no + such overload` in the strict env only — taking the single error slot and + silencing everything behind it. An author reaches it by naming an object field + or a flow variable after a namespace root and reading it bare, which on a + metadata-editing form is not even a coincidence: that layer binds the row under + edit as `data`. + + The strict env now declares those roots `dyn`, which is what the list's own + doc-comment already claimed it was for — member access, arithmetic and + comparison on a root all deferring to runtime — and which `map` delivered only + the first of. The two environments agree about these names, so the class cannot + arise rather than being compensated for downstream. + + What starts reporting, measured on each published surface: + + - `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare + reference behind a root name is the hard error it always was for the same + identifier written first (`ok` was `true` with zero errors; it is now `false`). + - `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the + did-you-mean warning reaches a misspelled field behind a root name. + - `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a + root name in a `visibleWhen` predicate is a finding. Per that rule's own + message the console otherwise falls open and the element renders + unconditionally. + - `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a + root name is warned. That rule's documented blind spot is now name-local, as + its wording always claimed: the colliding name itself is still not reported. + + ⚠️ One published answer also WIDENS, and it is not a reporting surface. + `inferExpressionType` (`@objectstack/formula`, re-exported from the package + root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a + formula's coarse value type through `inferCelType`, which shares this same + strict environment. While the roots were `map` there was no `==`, `<` or `+` + overload for them, so an expression using a namespace root as a DIRECT OPERAND + did not type-check at all and the answer was `'unknown'`. With the roots `dyn` + those expressions type-check and the answer is the truthful CEL type: + `result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, + `data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No + answer changes from one concrete type to another and nothing narrows to + `'unknown'` — `size(record)` and `"a" in record` still answer, and a root that + is only the base of a member access (`record.amount > 100`) never consulted this + declaration. A consumer that keys off a concrete type therefore sees strictly + more expressions classified, never a different classification; for the + motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is + now correctly seen as text rather than as unprovable. Pinned on both sides in + `validate.test.ts`. + + ⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL + TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no + declaration this package makes can reach it; measured on the strict env, the + message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root + declaration. And `has()` handed a non-select argument still faults its own + class, which `@objectstack/lint`'s visibility rule masks at its own call site + (#16118) and which nothing else masks. + + The narrowing this helper is built on is unchanged: it still acts only on + `Unknown variable`, so `type(record.x) == string`, comprehension macros, guard + idioms, optional chaining and stdlib calls report nothing, and a widening of + that regex onto the overload message remains refused. + +### Patch Changes + +- de62769: `SCOPE_ROOTS`'s docblock says it is a **baseline**, not a per-surface accept set, and points at where the per-surface verdict actually lives + + The exported `SCOPE_ROOTS` constant carried a docblock that made **a false statement about itself**. Its opening line read *"Namespace roots that a `record`-scoped CEL site may legitimately reference"* — which, read alone, is exactly the per-surface accept-set reading. Ninety lines below, the companion block asserted *"This list is a 'never faults' BASELINE, not a per-surface contract — **the doc-comment above says so**"*. The doc-comment above did not say so; it said close to the opposite. + + **This is not a docs nit, and the evidence is a card.** The accept-set reading is what a downstream seat took away, and it generated a cross-repo card filed against this package (this one) about a lint/runtime disagreement that is not a disagreement at all: the baseline declares a root, the per-surface gate refuses it, and both are correct. + + - **The opening line now states the contract it actually is**: the roots the strict check env declares, so that naming one is never itself a fault — and explicitly ⛔ *not* a claim that any surface **binds** the root. + - **It points at the per-surface authority by name**: `@objectstack/lint`'s `fieldRuleRootIssue`, judged against that surface's own closed `FIELD_RULE_BOUND_ROOTS` (`record` / `previous` / `parent`). A reader asking "may THIS surface reference this root?" is now sent one hop to the symbol that answers it, instead of reading the answer off this list. + - **It names `data` as the standing example** of a root this list declares and the field-rule surface does not bind — the two answers doing their separate jobs, ⛔ not something to repair by editing this list. + - **The self-reference is now true.** The companion block cites `SCOPE_ROOTS`'s own doc-comment, which now opens by saying exactly what the citation claims it says. + + ⛔ **Zero behaviour change.** `SCOPE_ROOTS` keeps all **27** members, byte for byte — no member is added, removed or reordered, and ⛔ `app` is not added (objectstack#16420 closed `not_planned` on that and this does not reopen it). Narrowing was refuted by measurement rather than by preference: six `*.form.ts` metadata-form modules in this repo carry live `data.` predicates. The diff is comment lines only. + + **This publishes, which is why it is `patch` rather than `skip-changeset`.** `@objectstack/formula`'s `files[]` ships `dist`, and this TSDoc is emitted into the built declarations — measured on the built artifact at three readings: the new text's distinctive phrase present at 1 in both `dist/index.d.ts` and `dist/index.d.mts`, an untouched neighbouring sentence from the same docblock present at 1 as the lit control, and a fabricated phrase at 0 as the dark control. The companion block is a plain `/* */` comment attached to no declaration and reads 0 in `dist` — it is the half that does not ship, and the half that does is the half that was wrong. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 66277a5621..cc111524e0 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry", "main": "dist/index.js", diff --git a/packages/lint/CHANGELOG.md b/packages/lint/CHANGELOG.md index 569a60ec5e..1c6e2f4828 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,935 @@ # @objectstack/lint +## 17.5.0 + +### Minor Changes + +- c88fa2c: fix(lint)!: an orphaned locale key now FAILS the run — `translation-target-unknown` is an `error` (#16310) + + `validate-translation-references` reported every orphan translation key precisely + — the id named, the locale named, the remedy printed — and failed nothing. + `os lint` exits 0 on warnings, the rule hard-coded `severity: 'warning'`, and no + per-rule severity is configurable by a consuming app. So a PR that deletes a + navigation entry, a form section or a view and leaves its locale keys behind was + green on every pipeline on the platform, and the dead keys are actively + misleading afterwards: grepping the id returns a confident-looking hit in every + locale, which reads as "this exists and is translated". + + The forward half of this parity — `i18n/missing-*`, an authored surface with no + translation — already fails, and apps already gate on it. The orphan half now + fails too, so the two halves of one parity have the same enforceability instead + of opposite ones. + + **BREAKING** — a stack carrying an orphan locale key stops passing `os lint`, + `os validate` and `os build`. Measured on one stack with 8 orphan keys planted, + `objectstack lint --json`: + + | `@objectstack/lint` | findings | errors | warnings | `passed` | exit | + | :-- | --: | --: | --: | :-- | --: | + | before this release | 20 | 0 | 18 | `true` | 0 | + | after this release | 20 | 8 | 10 | `false` | 1 | + + The findings themselves are unchanged — same count, same paths, same message and + hint text. Only the severity moves, and with it the exit code. + + **What an author does about it.** In a clean stack, nothing: a tree with no + orphan key reports exactly what it reported before, at the same severities, with + the same exit code (measured — the report is identical field for field apart + from its wall-clock `duration`). In a stack the rule already names findings on, + delete each locale key it names. The key resolves to nothing — the object, + field, view, section, tab, action, param, app, nav item, dashboard, widget or + flow screen it was written for is not in the stack — so removing it changes no + rendered string in any locale. Where the target was renamed rather than removed, + key the translation to the new name instead; the finding prints the declared + names to choose from. + + **This is ONE rule, not "warnings are errors now".** Measured on a planted tree + carrying findings from 13 distinct rules: exactly 1 changed severity, 12 did not, + and the finding set is identical modulo that one severity. + `translation-option-key-unknown` — raised by the same function — stays `warning` + on purpose: a mis-keyed option translation names something real and its remedy is + a rename, not a deletion. `validateTranslatableSections`, the sibling asking "is + there a key at all?", is untouched. + + **Unchanged: the runtime publish gate.** `validateTranslationReferences` reaches + the runtime door on a `flow` write, but the per-write snapshot carries only + `objects` / `permissions` / `books` / `datasets` — `RuntimeStackContext` has no + `translations` member for a host to fill — so the rule sees no bundle and returns + nothing there. Measured: a flow write through `runRuntimeAuthoringRules` yields + 0 errors and 0 advisories from this rule. No publish that used to succeed is + refused. + + `TranslationRefSeverity` widens from `'warning'` to `'warning' | 'error'` + accordingly. + + +- 23fc5d6: An action can now **declare which bulk dispatch contract its body is written for**, and a list view that wires it the other way is refused at authoring time instead of handing the body the opposite input in silence. + + A list view has always been able to wire the same declared action two ways, and the two deliver opposite shapes to the same body: `bulkActions: ['']` promotes the action to a def and dispatches it **once per selected row** (that row's `recordId`, no `_selectedIds`), while a `bulkActionDefs` entry with `execution: 'aggregate'` makes **one** dispatch for the whole selection (every id in `params._selectedIds`, no `recordId`). The action declared neither, so both mismatches failed quietly and in opposite directions — an aggregate body wired bare-string read `_selectedIds` as `undefined`, fell into its single-record branch and reported success for one row out of ten; a per-record body wired aggregate found no `recordId` and threw its own "nothing selected", which reads like a selection bug. Nothing caught either: `recordId` and `_selectedIds` are both built-in action params (ADR-0104), so the strict params gate admits either bag without a word, and the wiring lives on the view while the declaration would live on the action, so no single parse has both halves. + + - **`ActionSchema` gains `execution`**, and it is `bulkActionDefs`' own vocabulary — the same key, the same two values (`'perRecord' | 'aggregate'`), the def's `BulkActionExecutionSchema` **imported rather than re-declared**, so there is no second spelling to drift. The near-miss keys (`dispatch`, `dispatchContract`, `bulkExecution`, `bulkDispatch`) rename onto it; ⛔ `mode` deliberately does **not**, because on an action `mode` is a declared key of its own. + - **`@objectstack/lint` gains `action-dispatch-contract-mismatch`** (severity `error`), a member of the reference-integrity suite, so it runs on `os validate`, `os lint` and `os compile` at once. It names the action, the view and **both** contracts — the declared one and the wired one — and offers both ends of the fix, because which end is wrong is the author's call. It judges every list tier: a view's `list`, each `listViews.`, and an object's own `listViews`. + - **⛔ No silent default.** `execution` is optional and an action that omits it is *undeclared*, never defaulted to a contract — which is also the honest state of a body written to serve both (it reads `recordId` *and* `_selectedIds`), and why no third enum member was added. Existing sources are migrated by the new ADR-0087 semantic entry `action-bulk-dispatch-contract-undeclared`, which derives the declaration from the view wirings where they are unambiguous and hands back a structured TODO where one action is wired both ways. + + Nothing about dispatch changes: this release adds a declaration and a build-time refusal measured against it. Existing apps are unaffected until they declare the key — the new rule has nothing to judge on an undeclared action, by construction. +- 0fb6f97: fix(lint)!: `absolute-colspan-discouraged` is withdrawn — its premise was measured false in a browser, and the alternative it recommended measured worse than the thing it warned about (#17328) + + + + **BREAKING** — `@objectstack/lint` no longer exports `FORM_COLSPAN_ABSOLUTE`, and + `validateFormLayout` no longer emits the `absolute-colspan-discouraged` finding. A + TypeScript consumer that imported that constant (to suppress the rule, or to route it) + stops compiling on the import, and the compiler names the site — a more precise channel + than any release note. Authored metadata is untouched: `FormField.colSpan` is unchanged + and still valid. + + The rule fired on **every** authored `colSpan`, `colSpan: 1` included, and asserted a + rendering consequence: the form's column count is derived per surface (mobile 1 / modal 2 + / page 3-4), so a fixed span "only aligns at one width". Measured in Chromium on a real + authored 3-column section at all three of the widths that sentence names (390 / 720 / + 1700), that misalignment does not happen. The renderer emits one container-query-scoped + span class clamped to the section's declared column count, so the cell starts at a real + column boundary at every width and rendered overflow is 0px in every configuration — + including `colSpan: 4` in a 3-column section, the case that would overflow if the clamp + did not work. The clamp is precisely why the claim was false, and the rule's own file + already recorded the clamp a few lines above the claim. + + The hint was the sharper defect. It steered authors to `span: 'full'`, which compiles to + the same class as `colSpan: 4` (`@2xl:col-span-3`) and measures byte-identically: the rule + warned about one spelling and recommended the other, and they are the same thing. At the + modal width `span: 'full'` renders pixel-identical to authoring nothing at all, so an + author who complied was left worse off than one who ignored it. + + With no authored `colSpan` shape left that misbehaves there was nothing to re-ground, so + the rule is withdrawn rather than narrowed: `colSpan: 1` emits no class at all, a + `colSpan` within the column count renders exactly as authored, and one above it clamps. + Every test that pinned the rule's wording or its firing set was re-judged in place with + the reason recorded, never deleted, and each re-judged pin is paired with a live finding + on the same fixture so that a walk which stopped reaching the site could not pass as a + withdrawal. +- 6ec467b: feat(lint): `permission-retired-lifecycle-residue` — the retired `allowRestore` / `allowPurge` bits are now named at the authoring door (#17425) + + `ObjectPermissionSchema` accepts `allowRestore: false` / `allowPurge: false` as inert residue and strips them silently. That tolerance is #12840's class ruling and is unchanged here: the accept set does not move, no schema is touched, and every other value keeps the tombstone's loud refusal. + + The silence is deliberate — every artifact the published 17.x toolchain built has the retired default materialized in every permission entry, and a per-occurrence notice would be a storm. But `acceptRetiredDefaultResidue`'s own docblock names the channels that stay loud for authored sources — tsc `never`, `os migrate meta`, the ADR-0087 D2 conversion — and against a non-TypeScript author that list is one entry short. `tsc never` is a TypeScript channel. The conversion and `os migrate meta` are the same channel twice, and `permission-allow-restore-purge-removed` is declared `retiredFromLoadPath`, so it never fires while a stack loads. An author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent. + + `os validate`, `os build` and `os lint` now emit one advisory `warning` per carrying entry, on the raw pre-parse stack where the key is still present and still attributable to a line somebody wrote. The hint is the retirement's own prescription, read from the tombstone's published description rather than retyped, so it cannot drift from the parse-time wording the same author sees through the other door. + + It fires on the captured residue value and on nothing else: `true`, `"false"`, `0` and `null` are already refused at the parse with the prescription attached, and the surviving enforced lifecycle bit `allowTransfer: false` is not residue and is never named. + + New published exports on `@objectstack/lint`: `validateRetiredPermissionResidue`, `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` and the `RetiredPermissionResidueFinding` type. Nothing is removed and no existing finding changes shape or severity. +- 86f4246: `approval-approvers-may-resolve-empty` now covers the `manager` rung, not just the group-routed ones. + + The rule exists for the empty-slate dead-end (#3424): an approver slate that resolves to nobody, with `lockRecord` turning that into a stranded record. It reasoned about `position` / `team` / `department` and said nothing about `{ type: 'manager' }` — which has the same failure shape and a strictly worse cause. A `position` rung resolves empty because the position is unstaffed, and an operator can staff it. A `manager` rung resolves empty because `sys_user.manager_id` is unset, and an operator **cannot** set it: the managed-update whitelist for `sys_user` is exactly `{name, image, locale}` (ADR-0092), the auth admin endpoints do not accept the column, and the Console renders no field for it. So the rule warned about the rung an author can rescue and stayed silent on the one they cannot — and `manager` is the canonical first rung of a tiered approval ladder, so the silent case was also the common one. + + - **What fires.** A node whose approver slate is made up ENTIRELY of `{ type: 'manager' }` rungs now draws one `approval-approvers-may-resolve-empty` finding, at the same `info` tier as its `position` sibling. `manager` resolves through `sys_user.manager_id` of the record's owner and yields nobody when that column is unset; when nothing else is on the node, the request waits forever, and under the default `lockRecord` the record stays locked. + - **What it does not claim.** The message states in as many words that this is a static check which cannot read the column, and that it does not assert the slate IS empty — it reports that nothing else on the node can approve if it is. A lint rule must not claim a runtime fact it did not read. + - **The remedy it prescribes, with the routes graded rather than listed.** An exact diagnosis whose prescription cannot be carried out is worse than no prescription, so the hint separates what this platform provides from what it does not. A **seed, or any other system-context write**, populates the column here — both write guards gate on `isUserContextWrite` (`userId && !isSystem`), so a system-context write bypasses the managed-update whitelist by construction. **SCIM provisioning and directory sync** are named too, because a deployment running a real one may well populate the column through it — but named as a path the deployment itself supplies: this repo declares the SCIM Enterprise `manager` attribute without projecting it onto the column, and the admin bulk import does not write it either (`SYS_USER_IMPORT_UPDATE_FIELDS` is `{name, image, locale}` plus `phone_number` and `role`, and `manager_id` is listed there among the admin-surface-only columns). Editing the user in the Console is explicitly ruled out, since it cannot write the column at all. And the escape that depends on none of this stays on offer: add a fallback approver that cannot resolve empty, such as `{ type: 'org_membership_level', value: 'owner' }`. + - **When it stays quiet — and on which surface.** A stack whose own seed data wires `sys_user.manager_id` on any seeded row has shown the linter that it populates the column, and the advisory is suppressed. Seed rows are the only manager-chain evidence a stack can carry, so that is the whole of what this check reads on the question. ⚠️ That suppression is **CLI-side only**. The runtime publish gate hands rules a `RuntimeStackContext` whose collections are fixed — `objects`, `permissions`, `books`, `datasets`, `pages`, and no `data` — so a Studio publish of a manager-only flow carries no seeds to read and draws the advisory however the tenant's users are wired. That is a surface asymmetry, not a broken suppressor: an `info` finding never blocks a publish, it rides the 2xx `advisories`. Noted here so a reader who seeds correctly and still sees it fire on publish does not go looking for a bug in the rule. + + Existing verdicts are unchanged. The new arm is scoped to slates that are entirely `manager` rungs, which keeps it disjoint from the group-routed arm by construction — no node can draw both findings — and leaves every `position` verdict exactly as it was, mixed slates included: a `[position, manager]` node stays silent, as it is pinned to. + + This is a purely additive widening of a published package's public surface — the rule begins covering a case it was silent on — so it is graded `minor`, the floor that act carries regardless of the commit type. + + No severity moved. The finding is `info`, so it lands in the advisory channel on every consumer: `os lint` renders it as a suggestion and its exit code is unchanged (a suggestion does not fail a run even under `--strict`), and the runtime publish gate returns it on the 2xx `advisories` array rather than refusing the write. What changes is the report, not any verdict. +- 31064ca: fix(lint): a field-typed rule reads the registry's own type for an injected column, so `created_at` / `updated_at` stop escaping the preset-comparand refusal (#16340) + + `@objectstack/lint`'s object graph recorded the registry-injected system columns by NAME only. A path resolving to one came back `{ kind: 'ok', injected: true }` with no `meta`, so every rule asking a SECOND question about the leaf — "is it temporal?" — had to treat it as unanswerable and stay silent. That silence landed on the two most-filtered columns in the platform. + + Measured on `origin/main` `d57611dfd3`, one dashboard widget over one object declaring `close_date: date` and authoring no `created_at`: + + | authored filter | before | after | + |:--|:--|:--| + | `close_date: 'last_30_days'` (authored `date`) | refused | refused | + | `created_at: { $gte: 'last_30_days' }` (ordering — arm 1) | refused | refused | + | `created_at: 'last_30_days'` | **silent** | refused | + | `created_at: { $eq: 'last_30_days' }` | **silent** | refused | + | `updated_at: { $in: ['last_30_days'] }` | **silent** | refused | + | `stage: 'this_quarter'` (a `select` column) | silent | silent | + + The engine already refused all three of those at query time (`INVALID_FILTER` / 400, the registry's field map in hand), so the gap was purely author-time: `objectstack lint` and the runtime publish gate passed a filter the runtime then refused with a 400 on first render — and an AI author's correction loop only sees what fails the build. + + ## What changed + + `GraphObject.injected` is now a `ReadonlyMap` rather than a `ReadonlySet`: each injected column carries the registry's own definition. Both halves are DERIVED from one plan — membership from `resolveInjectedSystemColumns`, the slice from `injectedSystemColumnDefs` (`@objectstack/spec/data`, the same tables `applySystemFields` spreads at registration) — so lint never hand-copies "`created_at` is a datetime" and cannot drift from the runtime that provisions it. `resolveFieldPath` populates `meta` for an injected leaf accordingly, and `filter-preset-comparand`'s field-type oracle lost its `verdict.injected` bail: the marker says WHO wrote the column, and the ruling turns on what the column IS. + + `id` is the one addressable column with no definition behind it — the DRIVER provisions the primary key — so its slice is empty and a second question about it is still unanswered, truthfully and only there. The `select`-column reading arm 2 exists to protect is untouched: no injected column is a picklist. + + **Behaviour change for authors**: a stack that filtered an injected `date` / `datetime` column against one of the thirteen dashboard date-range preset names in an equality or membership position now fails `objectstack lint` and the runtime publish gate where it previously passed. Every such filter was already refused by the engine at query time; the error simply moves to where the filter is written. Write the `{date-macro}` window the message names, or an ISO date. + + **Type change for direct consumers of the seam**: `GraphObject.injected` changed from `ReadonlySet` to `ReadonlyMap`. `.has(name)` answers exactly as before; code that iterated the set or spread it into one needs `.keys()`. Shipped as `minor` under the repo's launch-window convention. + + ## Two more rules inherit it, in the same edit + + The type reaches every rule that asks a second question about a resolved leaf, which is the whole reason it was fixed at the seam rather than inside `filter-preset-comparand`: + + - **`list-view-field-dotted`** now refuses a dotted list-view filter key whose head is an injected column, on the same axis as an authored one. `created_at.x` reads as the `datetime` scalar it is (nothing beneath it for a path to reach) and `owner_id.name` as the `lookup` it is (it stores an id, not an embedded document). `assertFilterIsMaterializable` and the REST ingress have always answered `400 INVALID_FIELD` for both — the linter was silent only because the type was missing here. + - **`dataset-include-unknown`** now judges an `include[]` entry naming an injected column instead of bailing on the marker: `include: ['owner_id']` joins (it is the registry's `lookup`), `include: ['created_at']` is refused (a `datetime` derives no join, so every dimension written against that prefix addresses nothing). + + `id` falls through the untyped branch of all three rules — the DRIVER provisions the primary key and no definition table describes it, so an unreadable head is what the door sees too, and none of them invents a refusal there. + + A relationship HOP through an injected column stays a skip (`unknowable` / `injected-hop`), deliberately: the slice now carries `reference`, and traversing it would newly judge every path through a platform anchor wherever `sys_user` is compiled into the stack — a widening with its own findings to measure. +- f89dd33: `object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` + + `FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. + + The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: + + 1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; + 2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; + 3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; + 4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. + + Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). + + ## Migration + + **A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. + + **A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. + + **A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. +- 5b5bd36: fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) + + + + **BREAKING** for a non-system caller that CREATES a static `readonly` column on an + object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name + outside the reserved `sys_` namespace: the forged value used to be persisted and is + now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop + reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` + under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). + That is exactly what the same caller's UPDATE of the same column already did. Shipped + as `minor` under the repo's launch-window convention. + + ## The census, both halves — neither one is the whole reading + + **(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit + in the three now-judged buckets and carry a static `readonly` column between them — 64 + columns in all: + + - `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, + `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` + - `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, + `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` + - `system-data` (8 objects, 21 columns): `sys_approval_delegation`, + `sys_notification_preference`, `sys_notification_subscription`, + `sys_notification_template`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` + + **And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree + carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above + included — so the namespace test, which this change does not touch, keeps all of them + exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct + non-system `engine.insert` from the metadata repository, is doubly exempt + (`engine-owned` bucket **and** `sys_`) and is pinned as such. + + ⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the + population the narrowing reaches is real and named above, and an app that declares one + of those buckets on its own object gets the strip. The population on its own + understates it — not one shipped object changes behaviour on this release. What moves + is the contract for **app-authored** objects, which is the population the ruling is + about. + + ## What was wrong + + `staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, + carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / + #3004 grounds: those columns have their own 403 guards, and a silent strip must not + swallow the payload the guard exists to reject. The argument is sound and the bucket + list was not. `managedBy: 'system-data'` means "platform-defined schema, + **admin/user-writable data**" by its own definition, and `object.zod.ts` says in the + same breath that it "carries no such guard; its writes are adjudicated by the + delegated-admin gate / RLS / permission sets". So the create side skipped the strip on + objects whose data is the user's, while the update side stripped them — and #14147's + "one semantics, one enforcement point" was not literally true on that population. + + ## What it does now + + The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for + the three buckets whose columns really do carry a fail-closed refusal: + + | bucket | its own refusal | the create-side strip | + |:--|:--|:--| + | `engine-owned` | ADR-0103 engine-owned write guard | steps around it | + | `append-only` | ADR-0103, same guard (locked default) | steps around it | + | `better-auth` | ADR-0092 identity write guard | steps around it | + | `platform` | none — full user CRUD by default | judges it | + | `config` | none — admin-authored, writable by default | judges it | + | `system-data` | none — "admin/user-writable DATA" | judges it | + + An **unrecognised** bucket value is deliberately not read as platform-internal: the one + legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and + converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt + precisely the rows that conversion targets. The partition is pinned against + `@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing + silently on one side. + + The ruling's fallback ("leave it, if those buckets' readonly columns already carry + their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 + package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, + `drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / + `created_at` / `updated_at` stamps, which that guard does not reach. + + `@objectstack/lint` mirrors this predicate to decide which objects its create-verb + `flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, + and is narrowed in the same stroke — a lint that kept the wider exemption would go on + suppressing findings for a strip that now really happens. + + ⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. + The asymmetry is closed by moving CREATE toward UPDATE. +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- e4fd55d: Two new gating rules — `rls-predicate-unknown-field` and `rls-predicate-unknown-user-variable`: an RLS predicate that lowers correctly but names a column the object does not declare, or a `current_user.*` value nothing pre-resolves, is now an authoring-time `error`. + + The three shipped `rls-predicate-*` rules judge a predicate's **shape** — does it parse, does it lower, does it fit the platform's CEL bounds. Nothing judged what it **points at**. Measured as four injections at one site, in one run: `billing_address.country == "US"` reported `rls-predicate-unenforceable` and `is_private == = false` reported `rls-predicate-unparseable`, while `is_private_nope == false || owner_id == current_user.id` and `is_private == false || owner_id == current_user.nope` reported **nothing at all** — from the same site the linter had just reported twice. + + Both silent shapes are expensive rather than cosmetic, and they do **not** fail in the same direction — which is the part the card's own measurement did not reach. + + An unresolved `current_user.*` is refused by the pushdown compiler in **every** position, including under `!` and in a trailing `||` arm, so that half always fails **closed**: `RLSCompiler` drops the policy, the layer falls back to the `RLS_DENY_FILTER` sentinel, and the object disappears for every holder of the permission set — not because they were denied but because the narrowing they were granted resolves to nothing. + + An unknown **field** takes its direction from **position**, and one of the two is fail-**open**. `SecurityPlugin`'s field-existence safety net recognises only a *leading* `field ==` / `=` / `in` (`extractTargetField` is that shape match), so a miss there drops the policy and arms the deny sentinel — zero rows. A miss the net does not recognise — a negation (`nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])`) or any arm after the first — leaves the policy **kept**, and the phantom column lowers to a negated constraint that a row without that column *satisfies* (`noValueSatisfiesNegation`: `$ne` / `$nin` / `$notContains`). The authored narrowing is then **defeated rather than enforced**: measured at 3 of 3 rows, against 1 of 3 for the real narrowing and 0 of 3 for the same phantom column in a positive position, on the read path and on the write path's `matchesFilterCondition` alike. + + ⛔ That is **not** a cross-tenant leak — tenancy is a separate layer and it holds; what is defeated is the narrowing authored inside the wall. Measured on driver-memory; driver-mongodb follows the same shared ruling; **driver-sql is NOT MEASURED** and is expected to fail closed by raising `no such column`. The runtime repair is tracked separately as #17042 and is deliberately not attempted here — these rules report the miss, in both directions, and the diagnostic says which direction applies so an author is not told "this denies everything" about a predicate that in fact matches everything. + + - **Two rules beside the three, not a widening of them.** The existing ids say *unenforceable* / *unparseable* / *over-budget* and are correct inside that scope; they are untouched, and the two controls above still report under them and under neither new id. The prescriptions differ (rewrite the predicate / fix the column name / pre-resolve the variable), and an author who suppresses one must not thereby suppress the other. The guards are disjoint by construction: the reference pass runs only where `isSupportedRlsExpression` has already said yes. + - **Where the existence answer comes from.** Field paths are read off the pushdown compiler's **own output** — the lowered `FilterCondition`'s keys are the columns the driver will be handed — and resolved through `object-graph.ts`, the shared index every field-existence rule in this package already uses. No new input path, no second parse of the predicate. The rule therefore inherits that module's three skips, each the difference between a finding and a false one: an object this stack does not define, an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at`, which are real at runtime and appear in no authored `fields`. + - **The `current_user` set is derived, not transcribed.** It is `RESERVED_RLS_MEMBERSHIP_KEYS` from `@objectstack/spec/contracts` — the keys an `IRlsMembershipResolver` may never supply *because the kernel already owns them*. A key added there stops being reported the same day, with no edit in this package. + - **§7.3.1 membership keys are left alone, and that boundary is the reason this rule can exist.** An app stages arbitrary sets into `ExecutionContext.rlsMembership` and references them as `field in current_user.`; the spec documents the pattern and `rls-predicate-unparseable`'s own hint recommends it. In an `in` position an unknown key is indistinguishable from a correct one and is never reported. It is decidable in the other positions only because the merge is array-only — the sole value an app-staged key can ever hold is an array, which a scalar position cannot use on any request — so `owner_id == current_user.nope` is refused while `assigned_to_id in current_user.team_member_ids` stays silent. A key used in both positions takes the membership answer. + + **What moves for consumers.** A stack whose RLS predicate names a renamed column or an un-pre-resolved context value built clean before and now fails `os validate` / `os lint` / `os compile`. That is the point — the policy had already stopped doing what it was written to do, denying the whole object in one position and granting every row in the other. + + A stack whose predicates all resolve is byte-identically clean. The reading is the shipped showcase: 3 RLS clauses, all 3 judgeable against declared objects, **zero** findings — with three firing controls at the real site (an injected dangling column, an injected unknown variable, and an injected fail-open negation shape each produce exactly one finding) and two nonsense controls (an injected membership test against an unknown key, and a real-field/real-variable predicate, stay silent). `plugin-security`'s seed sets and hotcrm's built-permissions fixture also emit zero, but ⛔ **those two are not readings**: every policy target in the seeds is an object that package does not declare, and the hotcrm fixture carries no `objects` key at all, so all 71 and all 4 clauses respectively are skipped by construction. Declaring one of their objects makes the fixture report 2 — which is what a control is for. +- ba17017: `os lint` now refuses a `min`/`max` roll-up whose answer cannot be stored in the column it rolls up into — `rollup/non-numeric-aggregand`, at `error`. + + `FieldSchema.summaryOperations` admits `min`/`max` over ANY child field, and the engine's `aggregateSummaryValue` returns the driver's answer verbatim (only an empty-set fallback stands between the backend and the stored value). A `summary` field is a member of the spec's `NUMERIC_VALUE_TYPES`, so `valueSchemaFor` answers `z.number().finite()` for it and `driver-sql`'s `createColumn` emits a float column. An ordinary "latest shipment" roll-up — `max` over a `datetime` child field — therefore computes an instant into a column the value contract says holds a finite number, and nothing between author and driver correlated the two. It is refused at authoring time rather than tolerated in a consumer (Prime Directive #12). + + - **The accept set** is the numeric class union the boolean class, read from `NUMERIC_VALUE_TYPES` and `BOOLEAN_VALUE_TYPES` rather than typed out. The first is the set that DEFINES the criterion — it is the membership `valueSchemaFor` consults to answer `z.number().finite()`, so a type joining it moves the value contract and this door together. The second is admitted on the authority of the `min(flag)=0` / `max(flag)=1` ruling pinned by the spec's own `AGGREGATION_CASES` (#11152): the answer is a number, so it fits. + - **It is NOT `isAggregateCompatibleWithFieldType`.** That table deliberately accepts `min`/`max` over the temporal class, because there the answer is returned to a caller and "return[s] a value of the field's OWN type" (#15768). Reusing it here would accept the very declaration this rule exists to refuse. The two questions look alike and are not — "can every backend give one answer" versus "does that answer fit the column this roll-up is stored into" — so this predicate is that table's `min`/`max` row narrowed by exactly the temporal class, and a test pins the disagreement. + - **Scope.** `min`/`max` only. `count` reads no value off the field; `sum`/`avg` over a non-numeric child is a different shape, whose accept set the aggregate table's own rows already exclude, and is not widened into here. + - **Silent where it cannot resolve.** An unknown child object, a field the child does not declare, or a field with no declared type produce no finding — the aggregate table's own consumer tier ("a consumer that cannot resolve a field's type must NOT call the predicate with a guess"). A partially-loaded model cannot draw a false refusal. + + No export moves: the rule id is an inline literal inside the already-exported `lintDataModel`, beside `rollup/missing-summary`. Measured across this repository, no declaration trips the new refusal — all three `min`/`max` roll-ups aggregate a `number` child field — so this adds a door rather than migrating anything. +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +- 131851f: New gating rule `security-fls-unknown-field`: an object-qualified field-permission key naming a field the object does not declare is now an authoring-time `error`. + + `security-fls-unqualified-key` has always caught the *bare* spelling — `fields: { budget: … }` — because the runtime evaluator matches FLS keys by their `.` prefix and a bare key matches nothing. The qualified-but-dangling spelling (`fields: { 'crm_account.description_nope': { readable: false } }`) has the identical runtime consequence and was reported by nothing: `PermissionEvaluator.getFieldPermissions` strips the prefix and looks the remainder up as a column, so a remainder no column answers to contributes nothing to the merged permission map. The masking the author declared **never enforces**, and the field stays as readable and as editable as the object-level grant leaves it — for every holder of the set. + + The failure direction is **fail open**, and this spelling is the one that accumulates: unlike a bare key it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind. + + - **A second rule, not a widening of the first.** `security-fls-unqualified-key` is correct inside its declared scope and is untouched; the two defects have different prescriptions (add the object prefix / fix the field name) and suppressing one must not suppress the other. Two ids, two messages. + - **Where the existence answer comes from.** The rule resolves through `object-graph.ts`, the shared index every field-existence rule in this package already uses — no new input path. It therefore inherits that module's three skips, each of which is the difference between a finding and a false one: an object this stack does not define (it may be another installed package's), an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at` or `owner_id`, which are real at runtime and appear in no authored `fields`. + - **A truncated key is the same defect and is reported by the same rule.** `fields: { 'crm_account.': … }` passes the runtime's prefix test and resolves to the empty column name, so it matches nothing exactly as a dangling name does. `PermissionSetSchema.fields` is `z.record(z.string(), FieldPermissionSchema)` — a bare string key with no pattern and no refinement — and this rule is the only reader of those keys, so before this change nothing reported it at all. A key naming an object this stack does not declare still falls to skip 1, truncated or not. + - **It mirrors the evaluator, including on a multi-dot key.** Only the first dot separates object from field, because `ObjectSchema.name` is `/^[a-z_][a-z0-9_]*$/` and cannot contain one. `'crm_account.owner.name'` therefore asks for a column literally named `owner.name` and is reported: FLS keys address columns, never joins, and resolving that as a relationship hop would have been a fail-open divergence from the gate the rule mirrors. + + **What moves for consumers.** A stack carrying a dangling FLS key built clean before and now fails `os validate` / `os compile`, and is refused at the runtime publish door for `permission` and `object` writes (this rule joins the existing `validateSecurityPosture` registration; no new registry entry). That is the point — the key was never enforcing anything. A stack whose FLS keys all resolve is byte-identically clean: measured on the shipped showcase, whose six authored keys emit zero findings, with a firing control (one injected dangling key produces exactly one finding) beside the zero. +- 5505646: fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on published + CHECKERS, in the same sense as a route that starts refusing a request it should + always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is + not the carrier of breaking-ness; this paragraph and the disposition above + are). Nothing that was already reported stops being reported, and no source + that is correct starts being reported. + + `firstUndeclaredReference` asks cel-js's checker for the first undeclared + identifier in a source. That checker returns exactly ONE error, and the helper + acts only on `Unknown variable: X`, so whenever the first error is of another + class every undeclared reference behind it in the same source went unjudged and + the helper answered `null` — which is also the value that means "every + reference is rooted". Four published call sites read that answer, and none of + them can tell the two readings apart. + + The widest way to reach that state was a disagreement between two environments + in this package about the same names. The strict env declared every + `SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, + `input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` + type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an + ordinary comparison on one of those names compiled clean and then faulted `no + such overload` in the strict env only — taking the single error slot and + silencing everything behind it. An author reaches it by naming an object field + or a flow variable after a namespace root and reading it bare, which on a + metadata-editing form is not even a coincidence: that layer binds the row under + edit as `data`. + + The strict env now declares those roots `dyn`, which is what the list's own + doc-comment already claimed it was for — member access, arithmetic and + comparison on a root all deferring to runtime — and which `map` delivered only + the first of. The two environments agree about these names, so the class cannot + arise rather than being compensated for downstream. + + What starts reporting, measured on each published surface: + + - `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare + reference behind a root name is the hard error it always was for the same + identifier written first (`ok` was `true` with zero errors; it is now `false`). + - `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the + did-you-mean warning reaches a misspelled field behind a root name. + - `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a + root name in a `visibleWhen` predicate is a finding. Per that rule's own + message the console otherwise falls open and the element renders + unconditionally. + - `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a + root name is warned. That rule's documented blind spot is now name-local, as + its wording always claimed: the colliding name itself is still not reported. + + ⚠️ One published answer also WIDENS, and it is not a reporting surface. + `inferExpressionType` (`@objectstack/formula`, re-exported from the package + root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a + formula's coarse value type through `inferCelType`, which shares this same + strict environment. While the roots were `map` there was no `==`, `<` or `+` + overload for them, so an expression using a namespace root as a DIRECT OPERAND + did not type-check at all and the answer was `'unknown'`. With the roots `dyn` + those expressions type-check and the answer is the truthful CEL type: + `result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, + `data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No + answer changes from one concrete type to another and nothing narrows to + `'unknown'` — `size(record)` and `"a" in record` still answer, and a root that + is only the base of a member access (`record.amount > 100`) never consulted this + declaration. A consumer that keys off a concrete type therefore sees strictly + more expressions classified, never a different classification; for the + motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is + now correctly seen as text rather than as unprovable. Pinned on both sides in + `validate.test.ts`. + + ⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL + TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no + declaration this package makes can reach it; measured on the strict env, the + message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root + declaration. And `has()` handed a non-select argument still faults its own + class, which `@objectstack/lint`'s visibility rule masks at its own call site + (#16118) and which nothing else masks. + + The narrowing this helper is built on is unchanged: it still acts only on + `Unknown variable`, so `type(record.x) == string`, comprehension macros, guard + idioms, optional chaining and stdlib calls report nothing, and a widening of + that regex onto the overload message remains refused. +- 4ecfd2b: fix(lint)!: `objectstack validate` refuses a blank structural `condition`, the rule `registerFlow` has carried since #17322 (#17495) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): + `validateStackExpressions` — the pass behind `objectstack validate` — now + reports an `error` for a structural `condition` whose source is blank after + trimming. It reported nothing at all before. + + The value was already refused by two of the three doors. `FlowEdgeSchema.condition` + composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is + refused at `FlowSchema.parse`; #17322 rebound `AutomationEngine.registerFlow` to + that same rule, so the same value on a node's `config.condition` stops the flow + registering. `objectstack validate` was the door that still said nothing — so an + author got a clean bill, deployed, and the flow never registered: each boot path + in `service-automation`'s plugin wraps `registerFlow` in `try`/`catch`, logs one + `warn` naming the flow, and continues. On a `start` node that key is the + **trigger gate**, so the whole flow is armed by nothing. + + FROM → TO, for a build that used to pass and now fails: + + ```yaml + # FROM — validate said nothing; registerFlow refuses it at boot + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } + - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } + + # TO — either write the predicate you meant… + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: 'record.active == true' } } + - { id: branch, type: decision, config: { condition: { dialect: cel, source: 'record.rating >= 4' } } } + + # …or drop the key. An ABSENT condition is still not a malformed one: a start + # node with no `condition` is an ungated trigger, and that is unchanged. + ``` + + The refusal is the edge door's own sentence, not a second one — the finding + carries `EVALUATED_EXPRESSION_SOURCE_REQUIRED` verbatim, located at the node and + slot the author wrote (`flow 'f' · node 'gate' (start) condition`), because all + three doors now ask one imported schema. + + Unchanged, deliberately: the **evaluator**. A condition already stored blank + still answers `false` at run time — #15662's ruling on that half stands. What + moved is that it can no longer be authored past validate. + +### Patch Changes + +- 4f1a56b: `sys_user.manager_id` gains an admin write surface: `POST /api/v1/auth/admin/set-user-manager` + + `{ type: 'manager' }` is the canonical first rung of a tiered approval ladder, + and it resolves `sys_user.manager_id` — a column **no product surface could + write**. Measured: the generic data path refuses it (the ADR-0092 D2 + managed-update whitelist for `sys_user` is `{name, image, locale}`), the admin + bulk import does not carry it (`admin-import-users.ts` matches `manager_id` 0 + times, against a control of `phone_number` 8), and the column is `readonly` on + the user form. So on any install without a directory sync the rung expanded to + nobody, the request opened on a slate no one could act on, and under the + default `lockRecord: true` the record stayed locked. + + **The endpoint.** A platform admin posts `{ userId, managerId }`; `managerId: + null` clears the link. It is an ObjectStack mount on the raw app ahead of the + better-auth catch-all — the same family as `POST /api/v1/auth/admin/unlock-user` + — platform-admin gated (ADR-0068) and ledgered in `auth-route-ledger.ts`. + + **It is not a new editable profile column, and that is the design.** The + handler runs under a **system context**, so it reaches the column by context + rather than by a whitelist entry — the same way `admin-import-users` already + reaches `phone_number` and `role`. `SYS_USER_PROFILE_EDIT_FIELDS` is + untouched, `MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user` stays `{locale}`, and + `sys_user.manager_id` keeps `readonly: true`, so ADR-0092 D4 still holds by + construction. Since ADR-0092 D5's amendment made Tier-1 membership imply + self-editability, admitting the column to Tier 1 would have handed every member + their own first-rung approver and a widening of their own `own_and_reports` + read scope; it is not admitted. + + **Five refusals, every one enforced at the write** — the only manager-chain + walkers in the open tree are single-hop, so nothing downstream catches a bad + link: self-assignment; a link that closes a cycle (the walk is itself + cycle-safe, so a pre-existing loop is reported rather than hung on); a chain + past the depth cap that ADR-0057 D3's bounded rollups require; a manager + provably outside every organization the user belongs to (beside, not instead + of, the existing routing-time screen); and any identity whose `sys_user.source` + is `idp_provisioned`, where the directory stays the one authoring surface. + + **`@objectstack/lint`** keeps the `approval-approvers-may-resolve-empty` + advisory and its `stackWiresManagerChain` silencer — the dead end it reports + survives the write surface, because a static check still cannot read the + column; only its *cause* became recoverable. What changed is the remedy text, + which named a column with no route and now names the endpoint, its body, how to + clear the link, and what it refuses. The Approvals guide carries the same + rewrite in prose. + + **Why `patch` and not `minor`.** No new exported symbol is reachable from + either published entry: `admin-set-user-manager.ts` is deliberately not + re-exported from `plugin-auth/src/index.ts` and is not named in the package's + `exports` map, so none of `runSetUserManager`, `MAX_MANAGER_CHAIN_DEPTH`, + `SetUserManagerDeps`, `SetUserManagerEngine`, `SetUserManagerResult` or + `SetUserManagerRefusalReason` appears in the built `dist/index.d.ts`. No + already-published payload gains a key — the endpoint's response is a new + payload, not a new field on an old one. A new **route** is wire, and wire + compatibility is not the grading floor. +- e958468: fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) + + `hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` + (`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / + `hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` + (`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, + because that is the shape they parse. For a hook authored as an inline + `handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), + `hooks[i].body` is not something the author wrote at all — `lowerCallables` + mints it from the handler before `os build` / `os lint` hand the stack to + these rules (#16095). The reported `path` therefore named a key that does not + exist in the author's own source file; grepping for `body.source` there finds + nothing. + + **What changed.** `lowerCallables` now records, per `lowerCallables()` call, + which `hooks[*].handler` ref strings got their `body` minted this way (as + opposed to a `body` the author wrote directly). The CLI's four lowering doors + (`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) + pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two + hook write-set rules use it to redirect a finding on a lowered hook to + `path: hooks[i].handler` — the key that replaced the function the author + wrote — with a message suffix ("judged on the metadata body lowered from the + inline handler") explaining why. A hook whose `body` the author wrote directly + is unaffected: `path` stays `hooks[i].body.source`, unchanged. + + **No verdict changed.** Which hooks are flagged, at what severity, and why is + untouched — #13653 and #4271 are unmoved by a word. Only the location a + finding points at, and the wording explaining it, are different. `os build` + and `os lint` continue to report the identical `path` and message for the + same hook (#16095's "one implementation, both commands agree" — now including + this). + + No `--json` field was added or removed: `path` and `message` keep their + existing shape (string), and this is a within-type value correction for the + one subclass whose old value could never be resolved against the author's + source in the first place. +- 522f612: fix(lint): `component-type-unknown` reports an EXACT retired component type, relaying the spec's own prescription + + A retired component type is `isKnownComponentType` on purpose — its + `ComponentPropsMap` row is kept so the props door can dispatch the retirement + prescription — and this rule read that as "accepted". So a caller linting a + **raw stack** got silence on a name `PageComponentSchema.type` refuses at the + parse: the author's earliest feedback channel was the one that stayed quiet, + and the refusal landed later, at the parse door, or in front of an end user. + + ``` + FROM validateComponentTypes({ pages: [{ … components: [{ type: 'element:filter' }] }] }) + -> [] // silence, on a name the parser refuses + + TO -> [{ rule: 'component-type-unknown', severity: 'error', + path: 'pages[0].regions[0].components[0].type', + message: '`element:filter` was removed in @objectstack/spec 17 (ADR-0049) …' }] + ``` + + **No new prose.** The finding's `message` is the `RETIRED_PAGE_COMPONENT_TYPES` + entry **verbatim** — the same string the enum error map and the kept props row + already carry — pinned by byte equality in the rule's test, so the three doors + cannot drift and a type retired tomorrow arrives reported on the day it lands. + + Two things deliberately unchanged: `isKnownComponentType` still answers `true` + for a retired type (flipping it would MOVE the refusal out of the props door + rather than add a report), and the typo suggester still never proposes a retired + name. + + The new arm is judged **before** the reserved-namespace guard, because a + retirement can take its namespace with it: `user:profile` was the `user:` + namespace's only member, so `hasReservedComponentNamespace('user:profile')` is + `false` and a check placed after that guard would have stayed silent on the + member that has been refused longest. + + Measured before landing: **zero** authored instances of any retirement-map + member across the in-repo page sources, with live component types as the lit + control in the same query — so no existing authored stack turns red. +- 7026141: fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) + + + + **BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. + + A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. + + ⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". + + Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): + + - **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. + - **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. + + **The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. + + ⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. + + **Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. + + **driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. + + `@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [f55922f] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/sdui-parser@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index 9f9fedda1f..d158d94fd8 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Static, build-time validation for an ObjectStack metadata graph — dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.", "type": "module", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index fdef8d61c4..c40c61c5db 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,335 @@ # @objectstack/plugin-mcp-server +## 17.5.0 + +### Minor Changes + +- 76ddab7: fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) + + **Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. + + FROM → TO, for a caller of a gated action: + + ``` + run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran + run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required + ``` + + The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. + + **What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: + + - Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. + - Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. + - Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. + - `list_actions` is unchanged. + + **A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. + + Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. + +### Patch Changes + +- f19dbcf: Connect an Agent is reachable from the Account app, so a non-admin can mint their own key + + `POST /api/v1/keys` mints a `sys_api_key` bound to the **caller**, and the + Connect-an-Agent page says the key "acts as you". But the page's only navigation + entry sat in the Setup app, which declares `requiredPermissions: + ['setup.access']` — so every non-admin following the shipped two-step guide, and + every reader of the runtime's own error text (`packages/mcp/src/plugin.ts`: + *"mint an API key (Setup → Connect an Agent, or POST /api/v1/keys)"*, and + `README.md`), stopped at step 1 while the endpoint behind the button had accepted + them all along. Measured before: a principal with no system permissions gets + `403 PERMISSION_DENIED` on `GET /api/v1/meta/apps/setup` and `nav_connect_agent` + is absent from the wire. + + `CONNECT_AGENT_UI_BUNDLE` now carries a **second** `navigationContributions` + entry, targeting the `account` app's `grp_account_developer` group beside the + `nav_account_api_keys` entry already shipping there. Measured after, over the + real composition (real `SETUP_APP` / `ACCOUNT_APP` / `SETUP_NAV_CONTRIBUTIONS`, + the real fold and the real RBAC-by-route filter): the same permissionless + principal gets `200` on `GET /api/v1/meta/apps/account` with + `grp_account_developer` carrying `['nav_account_api_keys', + 'nav_account_oauth_apps', 'nav_connect_agent']`, while `apps/setup` still + answers `403 PERMISSION_DENIED` with `connect_agent` absent from that body. + + **Nothing else moves.** No backend change, no authorization change, no change to + which permissions exist, and the published "acts as you" promise is unchanged — + it simply becomes keepable for the users it was written for. The Setup entry + stays exactly as it was, so admins keep the page where the guide points, and no + gate is added or removed anywhere: a navigation contribution registers exactly + when the page registers, so an opted-out deployment + (`OS_MCP_SERVER_ENABLED=false`) still gets no page and neither entry. + + ⛔ Ungating Setup was **not** the fix, and was measured rather than assumed: the + app-level `setup.access` gate fires before the group gate, so dropping the group + gate alone changes nothing, and dropping both serves 14+ unrelated Setup + surfaces (Users, Organization, Business Units, Branding, Feature Flags, …) to + every signed-in user. ⛔ Nor was a `requiresService: 'mcp'` gate on an + `account.app.ts` entry: the `mcp` service registers unconditionally in `init()` + while this bundle registers behind `isMcpServerEnabled()`, so such an entry + would outlive its page and 404 for every signed-in user on an opted-out + deployment. + + Both entries deliberately share the item id `nav_connect_agent` — one + destination, one identity. That is scoped, not a collision: `SchemaRegistry` + keys contributions by target app and `applyNavContributions(app)` consults only + that app's bucket, so a nav item id is unique within one app's navigation tree, + and the translation bundles are keyed `apps..navigation.`. +- 4af758d: refactor(runtime,mcp): the last two admission doors classify the `tenancy` rejection through the shared `classifyAdmissionTenancyPosture` (#17114) + + `@objectstack/core`'s `classifyAdmissionTenancyPosture` is the one place the + #13906 decision 1 option A classification lives: a branded "never registered" + rejection is the supported no-tenancy composition and answers a quiet + `undefined`, while every other rejection becomes + `AuthzStoreUnavailableError('tenancy', err)` — ADR-0112 `SERVICE_UNAVAILABLE` / + 503 — because the posture is an authorization INPUT and admission was never + decided. + + Two admission doors were still hand-writing that classification, out of the + declared scope of the fold that extracted it: + + - `@objectstack/runtime`'s `resolveExecutionContext` — the REST/dispatcher + entry-point identity resolver; + - `@objectstack/mcp`'s `resolveStdioTenancyPosture` — the stdio door's **async + kernel** leg. + + Both now call the shared function. ⛔ **No behaviour changes at either door.** + Tenancy posture decides which rows a caller may see, so a divergence between + copies would be two answers to "whose data is this", and the copies are the + stale ones by construction — the shared version is the one that will be + maintained. + + **The resolution stayed at each seam, deliberately.** The extractable part is + the classification, not the resolution: each door keeps its own accessor guard + and hands its own former accessor expression in as the thunk, so the helper + never learns *how* a seam reaches the service. A helper that owned the wiring + too would be wrong for one seam or grow a flag per seam. + + **One neighbouring leg is deliberately NOT folded.** The stdio door's **sync** + fallback is taken only on a `KernelBase`-shaped host with no `getServiceAsync`, + whose accessor reports its one possible fault — nothing registered under that + name — **unbranded**. Routing it through the shared classification would mint a + 503 outage out of a supported composition, so its bare `catch` remains that + seam's recorded decision. A test arm now fails if that leg is ever folded. + + Shipped rather than `skip-changeset`: both packages publish `files[]: ["dist"]`, + and the built `dist` of each carries the new call (2 files each, measured after + a real build, with a symbol known-absent scoring 0 and + `isServiceNotRegisteredError` scoring 4 in `runtime/dist` as the lit control). + `@objectstack/mcp`'s `dist` no longer mentions `isServiceNotRegisteredError` at + all. +- 3977410: docs(mcp): the README no longer promises that Claude Desktop reaches intranet deployments — *Add custom connector* is the claude.ai connector system and dials from Anthropic's servers (#16882) + + `packages/mcp/README.md` grouped the clients by **where the client application runs**: "Local clients (Claude Code / Desktop) can reach intranet deployments; claude.ai web connectors additionally need the endpoint publicly reachable." That grouping is wrong for Claude Desktop. Its *Settings → Connectors → Add custom connector* flow is the same claude.ai connector system, and the connection to the MCP server is made **from Anthropic's servers** — Anthropic's custom-connector documentation requires the server to be reachable over the public internet from Anthropic's IP ranges and states that a server on a private corporate network, behind a VPN, or blocked by a firewall will not connect. An operator following the old sentence pointed Claude Desktop at an intranet address and the failure surfaced inside a third-party client, with nothing to connect it back to our instructions. + + The README now groups by **where the connection is made from**, which is the mechanism and does not go stale when a client's dialog is redesigned: + + - **Claude Code** (`claude mcp add`, or the plugin) dials the endpoint from your own machine, so `localhost` and intranet-only deployments work — this is the door that genuinely reaches a private deployment, and the README now names it as such. + - **claude.ai (web) and Claude Desktop** go through the one claude.ai custom-connector system and need public HTTPS; a locally trusted certificate does not make a private address reachable. + + Documentation only — no exported symbol, endpoint, schema or runtime behaviour changes. The `patch` bump is because `README.md` is in this package's published `files[]`, so the corrected text ships to the npm page. +- 46cf705: fix(mcp): refuse undeclared argument keys on every MCP tool instead of stripping them + + `query_records` answered `{"objectName":"crm_opportunity","sort":"-amount","limit":3}` with `200` + and rows in seed order, and `{"objectName":"crm_opportunity","filters":[["name","contains","Meridian"]]}` + with `200` and the full unfiltered set. Neither key is declared, and zod's strip default — reached + through the MCP SDK's raw-shape wrap — deleted both before the handler ran, so the handler could not + report what it never received. Nothing in either payload distinguished it from a real answer, and the + consumer of these tools is an AI agent: it reads a successful response and reports the wrong answer + confidently. A dropped sort key answers a differently ORDERED set; a dropped filter key answers a + WIDER one. + + All eleven tools held that posture; none refused. Each tool's `inputSchema` is now a built strict + object, so an undeclared key is refused before dispatch, the data bridge is never reached, and + `tools/list` advertises `additionalProperties: false` — the closed set is readable off the schema + rather than discoverable only by being refused. The refusal names the offending key and, where the + spelling is recognisable, the declared one to send instead. + + Spellings that used to be accepted-and-ignored, and what to send now. Every one of them was already + inert: it was dropped, and the call proceeded exactly as if it had never been sent. + + | previously sent and ignored | send instead | on | + | :-- | :-- | :-- | + | `sort`, `sortBy`, `order`, `order_by` | `orderBy` | `query_records` | + | `filters`, `filter`, `conditions`, `criteria` | `where` | `query_records` | + | `select`, `columns`, `projection` | `fields` | `query_records` | + | `pageSize`, `top`, `take` | `limit` | `query_records` | + | `skip`, `start` | `offset` | `query_records` | + | `filters`, `filter`, `conditions` | `where` | `aggregate_records` | + | `metrics`, `aggregates`, `aggs` | `aggregations` | `aggregate_records` | + | `group_by` | `groupBy` | `aggregate_records` | + | `tz`, `timeZone` | `timezone` | `aggregate_records` | + | `object`, `table` | `objectName` | every object-scoped tool | + | `id`, `record_id` | `recordId` | `get_record`, `update_record`, `delete_record`, `run_action` | + | `record`, `values`, `fields` | `data` | `create_record`, `update_record` | + | `action`, `name`, `action_name` | `actionName` | `run_action` | + | `args`, `input`, `arguments`, `parameters` | `params` | `run_action` | + | `formula`, `expr`, `cel` | `expression` | `validate_expression` | + + A key outside this table is refused with its name echoed back and a closest-declared-key suggestion + when one is within a length-relative edit distance. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 85d4ee15de..6e25216276 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP)", "type": "module", diff --git a/packages/metadata-core/CHANGELOG.md b/packages/metadata-core/CHANGELOG.md index 490fc200c5..e8b467bed0 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,222 @@ # @objectstack/metadata-core +## 17.5.0 + +### Minor Changes + +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- cca1dc0: + + feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) + + **BREAKING** — two published machine surfaces change a key name. There is **no alias + and no dual-key transition window**: one axis, one name. + + | Surface | Was | Now | + |:--|:--|:--| + | `os migrate meta --json` payload | `runtime` | `protocolVersion` | + | `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | + | `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | + + The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol + major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload + moves — no other key is added, removed or reshaped, and both text faces are byte-identical. + The parameter rename is positional, so no call site changes. + + ## Why the name had to move + + `PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed + `@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* + it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or + a stale install, next to the real package versions of the same upgrade session. + + The human line was repaired first and now reads + `Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the + worse half and was left standing, because a key on a published payload is a contract + change: an agent scripting an upgrade has no prose to disambiguate at all, and the + diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer + does not parse. + + ## What a consumer should do + + Read the new key. The old one is absent, so a consumer that does not move reads + `undefined` rather than a wrong value. + + ```diff + - const v = payload.runtime; // os migrate meta --json + + const v = payload.protocolVersion; + + - const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE + + const v = err.diagnostic.protocolVersion; + ``` + + The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` + spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it + from the package install boundary, and `@objectstack/services-package` reads it during + hydration — so a consumer reading it from any of those reads the new name too. + + `runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer + protocol major, not a semver in a version position, and it does not carry the ambiguity + this rename closes. + + The breaking surface was measured before the rename and is closed inside this repository: + the only reader of the `--json` key was this repo's own e2e pin and the only reader of the + diagnostic member was `metadata-core`'s own unit test, both of which move in this same + change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever + naming the field. **Zero external consumers were found.** Graded `minor` rather than + `major` for the launch window; the banner above carries the breaking-ness the level cannot. + +### Patch Changes + +- 134b410: The artifact-ingestion door no longer replays the **default-flip** class of ADR-0087 conversion, so an artifact carrying `defineApp({ hidden: true })` is registered with `hidden: true` — not as an unpublished app (#17885, #4829). + + `app-hidden-to-unpublished` rewrites `app.hidden: true` into `app._unpublished: true`. Both keys are live and they mean opposite kinds of thing: `hidden` is navigation presentation and *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the machine-managed publish gate `filterAppForUser` drops the app on for every user without `studio.access` / `setup.access`. Measured before the change, on an artifact declaring `engines.protocol: ^17.0.0` — the range `create-objectstack` stamps — against a 17.4.0 runtime: the door emitted the `app-hidden-to-unpublished` notice and the object that reached registration carried `hidden: undefined`, `_unpublished: true`. So an author who asked for "keep this out of the App Switcher" got "nobody but a builder can see this" — the incident the `_unpublished` split was introduced to end, arriving through the conversion layer. + + - **The entry is not withdrawn and no key moves.** It still fires where its precondition is a fact — the stored-row rehydration seams (a pre-split `hidden: true` row can only have come from the materialization path) and `os migrate meta`, where the operator asserts the source's age. What changed is that the artifact door, whose evidence is the artifact's **declared `engines.protocol` floor** rather than its age, no longer treats that guess as sufficient for a rewrite that reinterprets a live authorable key. + - **The retired window stays open.** Closing it wholesale would fix this and re-break #12772: an artifact built by 17.1.0 tooling carrying `allowRestore` / `allowPurge` would again be refused at the tombstone with no operator remedy. The door refuses one named class by id, with its reason written beside it, and the pin drives a retired conversion and a non-retired one through the same window to prove it. + - **New seam option, no new export.** `applyConversions` accepts `excludeConversionIds` — the seat-level spelling of "my evidence cannot carry this entry". `retiredFromLoadPath` cannot express it: that flag's jurisdiction is the authoring funnel and nothing else. + - ⛔ **The consumer is unchanged.** `filterAppForUser` withholding on `_unpublished` is correct; the defect was who writes `_unpublished`. + + Deployments whose apps were being served as unpublished purely because of a permissive `engines.protocol` range will see those apps again, for every user, on the next boot. No artifact file changes and no stored row is rewritten. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index fd114e4537..22beba6506 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Metadata Repository contracts: types, canonicalization, errors, interface (ADR-0008).", "type": "module", diff --git a/packages/metadata-fs/CHANGELOG.md b/packages/metadata-fs/CHANGELOG.md index 4a06acf204..0e1205b5ad 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,14 @@ # @objectstack/metadata-fs +## 17.5.0 + +### Patch Changes + +- Updated dependencies [134b410] +- Updated dependencies [2bed4c3] +- Updated dependencies [cca1dc0] + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 713ee5dc89..68ab9709bc 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "FileSystemRepository: Node-only Repository implementation backed by JSON files and a JSONL change log (ADR-0008).", "type": "module", diff --git a/packages/metadata-protocol/CHANGELOG.md b/packages/metadata-protocol/CHANGELOG.md index b41c75aae5..3c36792503 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,852 @@ # @objectstack/metadata-protocol +## 17.5.0 + +### Minor Changes + +- 04333d0: The `kernel:ready` migrations ask whether a table exists WITHOUT running a statement that has to be refused, so a normal boot stops printing `[sql-driver] DATABASE_ERROR … no such table` (#17175) + + Two migrations on the boot hook asked "does this table exist?" with a statement + that cannot succeed when the answer is no — `SELECT "tenant_id" FROM + "_objectstack_sequences" WHERE 1 = 0` in `seed-tenancy-backfill.ts`, and `SELECT + 1 FROM sys_setting WHERE 1 = 0` in `sys-setting-identity-index.ts` — and read + the refusal as "no". Both are correct on their own terms. Both make + `SqlDriver.execute()`'s raw terminal write the statement and the dialect's + message to the operator's log on the way out. + + Measured on this tree against real `better-sqlite3`: exactly one line per probe, + on `console.warn` — i.e. **stderr** — carrying both the `DATABASE_ERROR` token + and `no such table`. It fires on **every boot** of every install that has never + allocated an autonumber, and again on every boot of every kernel that does not + register the optional `service-settings`. + + ⭐ The cost is not the line. It is that operators learn this product prints + errors when nothing is wrong, and then miss the one that matters. A consumer told + to read the boot log (`objectstack-ai/hotclm`'s `AGENTS.md` names `no such table` + as a failing boot) must either ignore an unactionable ERROR every boot or chase a + platform-internal probe. + + **The question is now asked of the CATALOG.** A new shared + `migrations/read-probe.ts` compiles one arm per dialect family — `sqlite_master` + for SQLite, `to_regclass` for Postgres, `information_schema.tables` scoped with + `DATABASE()` for MySQL — each of which returns zero rows for a table that is not + there instead of being refused. Both migrations call it; the probe lives once, + not once per site. + + **⛔ Why not in the driver.** Quietening a refusal requires classifying it, this + repo has one predicate for that (`isMissingTableError`), and it needs the name of + the thing the caller was reading — which the raw path structurally does not have + (`rawStatementFaultError` declares no targeted table, and + `driver-error-classification.callers.test.ts` fails any in-repo call that omits + `readObject`). An unclassified demotion of the driver's raw terminal would + quieten real failures too. The caller knows the table; the driver does not. + + **⛔ The fence, and it is the one way this repair can go wrong.** A catalog arm + mis-compiled for some dialect would be refused, caught by the same `catch` the + expected miss uses, and read as "the table is not there" — turning a stored-row + data repair into a silent no-op on whichever dialect nobody exercised. So the + probe answers four verdicts rather than a boolean, and `'unreadable'` is never + folded into `'absent'`: it is returned, and reported at `warn`. An unrecognised + dialect gets no guessed catalog statement at all — it keeps the caller's own + `WHERE 1 = 0` probe, whose refusal is now *classified* with + `isMissingTableError(error, table)` rather than swallowed as absence. + + **Why `minor`.** + + - `SeedTenancyBackfillStatus` gains `'unreadable'`. It is an OUTPUT union, so no + input a caller writes is affected; the one consumer shape that could break is + an exhaustive `switch` with a `never` default, which is why this is not a + `patch`. + - `ensureSysSettingIdentityIndex` gains an optional third parameter + (`{ client? }`). Callers that pass two arguments are unchanged and keep + today's behaviour exactly — without a client there is no catalog arm and the + pre-existing probe runs. + - `buildSequencesPresenceSql` and `buildSysSettingPresenceSql` are unchanged in + text and still exported. They are no longer what the boot path runs first. + - `isResultSet` and `normalizeRows` moved to `migrations/read-probe.ts` and are + re-exported from `seed-tenancy-backfill.ts` unchanged, so the package index and + every importer see no difference. + + **What did NOT change.** #10789's ruling stands: a seam that accepts a statement + and returns no result set still reports `absent` with the `detail` that separates + it. The driver's error channel is untouched — a statement the backend genuinely + refuses is still written to the log in full, asserted against the same driver and + the same sink in the same test as the silence. + + **Dialect coverage, stated rather than implied.** The SQLite arm is pinned end to + end against a real `SqlDriver` (`packages/runtime`'s + `seed-tenancy-autonumber-split.integration.test.ts`); the MySQL arm runs against + the live server in `seed-tenancy-backfill.live-mysql.test.ts`, in both directions + and with the connected-schema scope measured. ⛔ The **Postgres** arm is NOT + MEASURED against a live server: this package has no live-PG harness, no `pg` + dependency, and its CI leg supplies `OS_TEST_MYSQL_URL` only while filtering to + `live-mysql`. Its statement text is pinned; running it is not. +- ada2869: fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290) + + + + **BREAKING** — `@objectstack/metadata-protocol`'s `insertManyData` no longer hangs + `droppedFields` on each entry of `outcomes`; the response itself carries it, beside + `outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read + the per-row member stops compiling, and the compiler names the site. The set reported + is the same set — what is gone is a per-row attribution that could not be computed + here and was wrong whenever it mattered. Nothing authored or stored changes shape. + + **What it got wrong.** Every create-side strip is the engine's, and its + `onFieldsDropped` event is the UNION over the batch — the listener signature + carries no row index. This seam reconstructed a row set from that union by + asking which rows SUPPLIED each dropped name + (`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that + "the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs + to exactly the rows whose supplied payload carried it". Maintainer ruling C + falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`, + AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned — + recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where + a hook stamps a protected key on some rows and not others: + + - row A supplied `approval_status`, no hook write ⇒ stripped, enters the union; + - row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and + written**; + - and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]` + on a record that still held `approval_status`. + + A row the batch culled before the strip ran (a per-row validation failure) was + named on the same test, having dropped nothing at all. + + ⇒ A wrong attribution costs the reader a wrong investigation, and the import + surface — which prefers this path over `createManyData` — is the consumer most + likely to act on it while reconciling what landed. + + **Why not attribute per row instead.** The honest set is `{rows whose payload + carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second + half is computed per row upstream but does not cross this seam. The outcome's + own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED + over exactly the keys the strip took, and a stripped `autonumber` is refilled by + `applyAutonumbers` — so on both, the key is PRESENT on the row that really did + drop it, and a post-hoc "is the key still there?" check would delete true + attributions while leaving the hook-exempt false one standing. Comparing values + fails on the very case `hookWrittenKeys` exists for: the hook assigning the + value the caller also sent. Restoring row precision means giving the engine's + drop report a per-row channel, not a reconstruction at the call site. + + **Prose corrected with it**, by CLAIM rather than by spelling — the docblock + that authorised the inference is the thing that re-authorises the next author: + `insertManyData`'s own docblock and `createManyData`'s parenthetical + (`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing + sentence, `engine.insertMany`'s docblock claim that "a caller holding the input + rows can attribute each name back to the rows that carried it" + (`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and + `CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical + (`@objectstack/spec`, a string printed AT the customer). + + **Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`, + and they always could: each row is its own `engine.update` / `engine.insert` + call, so that call's events are that row's — earned mechanically, not inferred. + `createManyData`'s aggregated shape is untouched. No strip changes, no row + changes, and the same field names are reported. +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- 4062aef: fix(metadata-protocol): the runtime authoring gate judges an OVERRIDDEN item from the body the runtime serves (#16224) + + The #4463 runtime authoring gate resolves references against the live metadata universe, and since #15950 it gathers that universe from BOTH homes — the `SchemaRegistry` and `sys_metadata`. That fold was **additive**: a stored row contributed a name the registry did not carry and never displaced a registry entry. Where an org or env-wide overlay REDEFINES an item a code package already declares, the gate therefore judged that item's CONTENT from the registry's copy — a body the runtime had already stopped serving. + + Measured end to end, in one process and one instant. A code package ships `dataset/D` with measure `m`; an env-wide overlay redefines `D` without it: + + - a dashboard widget bound to `values: ['m']` was **accepted**, and the runtime cannot serve it; + - a widget bound to the measure the overlay DOES declare was **refused** `422 widget-measure-unknown`, and the runtime can. + + One cause, both directions: an acceptance that should have been a refusal and a refusal that should have been an acceptance. + + The hand-rolled additive merge is replaced by `mergePackageAwareOverlay` with `foldObjectExtendersFromRegistry` as its transform — the merge, and the transform, that `getMetaItems` (the read API behind `GET /meta/:type`) already runs. The gate's universe is now the universe the platform answers reads from, by construction rather than by agreement, and ADR-0048 package slotting arrives with it: an overlay shadows the entry it actually overrides, and two installed packages shipping one `type/name` remain two entries. + + **#15950's resolved-vs-base distinction is kept by folding, not by declining.** Its argument was never "an overlay must not win" but "an UNRESOLVED body must not win" — the registry's copy of an object is its RESOLVED schema (ADR-0029 D9.2: base layer plus its `extend` contributors), a `sys_metadata` row is the base layer alone — and it names its own remedy, which is what `getMetaItems` does to its winner. Pinned: an `object` overlay wins on its own columns AND keeps the registry's `extend` contributors. For a name the registry does not carry the result is byte-for-byte #15950's additive contribution, pinned in the same process. + + Graded `minor` rather than `patch`: `PUT /meta/:type` is a published verb and this narrows its accept set. An `active` publish that names a reference the overlay removed now answers `422 INVALID_METADATA` where it answered `200` — one legal published answer replaced by another, not the repair of a value the schema already refused. The write it now refuses is one the runtime could never serve; the write it now accepts is one the runtime always could. + +### Patch Changes + +- dc709b2: The seed-tenancy backfill's organization probe records the operator channel as is — an empty one included — instead of the placeholder `'unknown error'` (#17167) + + `packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts` had one site left + that did not follow the rule the rest of the file follows. Where the other four + `operatorFacingErrorText` calls record the helper's return value as is, the + `sys_organization` probe spelled `operatorFacingErrorText(e) || 'unknown error'`, so a + backend that failed WITHOUT saying anything was recorded as having said + `'unknown error'` — words no backend produced, in a field an operator reads to find out + which probe failed and why. + + **Measured before and after**, driving `backfillSeedTenancy` at each site in that file + with the same three empty-channel shapes (a thrown `''`, a thrown `[]`, an `Error` whose + `name` and `message` are both empty) and with `new Error('boom')` as the control: + + | site | before | after | + |---|---|---| + | split probe → `result.detail` | `''` | `''` | + | **organization probe** → the warning's `organizationProbeError` | **`'unknown error'`** | **`''`** | + | duplicate-list probe → the warning's `error` | `''` | `''` | + | stamp → the warning's `error` | `''` | `''` | + | counter merge → the warning's `error` | `''` | `''` | + + The control records `'boom'` at every site in both columns. + + **Why this was not a one-line deletion.** The placeholder was carrying two jobs and only + one of them was a record: the site also read `organizationProbeError === ''` as "the probe + did not fail", which is how a failed probe is kept out of the benign `no-organization-yet` + branch (#9261 — unknown is not zero). Deleting the placeholder and putting nothing in its + place was measured: a thrown `''` then reports `no-organization-yet` and warns about + nothing, while the control still reports `skipped-ambiguous-organization`. So the failure + fact moved into the TYPE — `organizationProbeError` is `string | undefined`, `undefined` + means the probe answered, and every string, empty or not, is a failure. The text is then + free to say exactly what the backend said. + + **What does NOT move.** No status value changes for any input: an organization probe that + throws still reports `skipped-ambiguous-organization`, whatever its channel holds, and + `SeedTenancyBackfillStatus`, `SeedTenancyBackfillResult` and every exported signature are + unchanged. This probe's text never reached the returned result in the first place — it is + carried only by the warning this migration logs (measured: the control text appears in + `result.detail` at the split-probe site and appears nowhere in the returned object at this + one). + + **One operator-visible detail beyond the text.** The warning's structured field is now + absent when the probe answered and present-but-empty when it failed silently, so "empty" + and "there was no failure" stay distinguishable in the stored line — the one job the + placeholder was doing that a reader could have depended on. The sentence in the same + warning drops its parenthetical rather than filling it in: `the sys_organization probe + FAILED, so the count above is "unknown"` when the backend said nothing. +- 07f93e0: Seed loader: the pass-2 deferred-reference diagnostics now say which moment they describe + + `SeedLoaderService` runs inside `AppPlugin.start()`, which the kernel completes for every + plugin before it fires `kernel:ready` — where the first-admin handoff + (`claimSeedOwnership`) re-owns every `owner_id IS NULL` row of every user-authored object. + That handoff is the designed completion of a NULL owner column, so two of the loader's + pass-2 lines — `Deferred reference UNRESOLVED after pass 2` and + `Deferred reference back-fill FAILED` — were making a bare present-tense claim + (`x.owner_id stays NULL`) that the same boot then made false, with nothing in either the + log or the table to tell an operator that the other reading existed. + + Both lines now read `is NULL at the end of pass 2` and carry a scope sentence naming the + boot step that can supersede them and stating that a non-NULL value found later is not + evidence the reference resolved. Level, error count and remedy are unchanged — this is a + scope declaration, not a silencing. The two `Deferred reference DROPPED` lines are + deliberately untouched: they report a row that never landed, so no later boot step can + write a column of it and their claim survives to the end of boot as written. + + Nothing an author writes changes. Anything that greps the loader's output for the literal + `stays NULL` on these two lines should grep for `is NULL at the end of pass 2` instead. +- bdb247d: `@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + + The execution context a seed write must use — `isSystem`, `skipTriggers`, + `seedReplay` — had **no exported form**, so every seeder held a private copy of + it and nothing held the copies equal. There were three on `main`: + `SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), + `SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock + already recorded that it "mirrors" the first) and `SEED_CONTEXT` + (`@objectstack/verify`'s fixture writer, which spelled it a third time + specifically because the runtime kept its copy module-private). + + **Why a shared constant rather than three accurate copies.** `skipTriggers` is + what suppresses "on create" automation for seed rows, and `isSystem` alone does + **not** suppress dispatch. A seed path that lost that flag once seeded with + automation live while the main path had it suppressed — a self-trigger loop that + wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging + defect is a kernel semantic, not a local detail. + + **What is exported, and what deliberately is not.** The **inner** + `ExecutionContext` value, and nothing wrapped around it: + + ```ts + import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + + await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); + ``` + + The `{ context: … }` options bag stays at the call site. It is what all three + sites ultimately hand to `insert`, but it is an options envelope rather than the + posture: its type differs per engine method, so freezing one bag onto the + protocol surface would serve `insert` and no other operation, and it is + precisely the convenience bundle this export is not. + + ⛔ **No behaviour change.** The value is byte-identical to all three previous + copies, the three flags keep their existing meanings, and no seed path changes + what it writes or how. The three former copies now read this export, so the two + option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` + context is the export itself. + + **Additive, so `minor` on `@objectstack/spec`**: one new name on the existing + `./kernel` entry point, no existing export removed, renamed or narrowed. The + three consumers take `patch` — their published `dist` changes (an import edge, + and the constant now resolves through `@objectstack/spec/kernel`) while their + own public surfaces do not move. +- e743fb5: fix(metadata-protocol): the `/references` refusal front-loads its ADR-0110 D3 prescription, so the #5423 bound cannot cut the remedy (#17584) + + `GET /api/v1/meta/:type/:name/references` refuses an unanswerable target type + (`field`, addressed by the composite key `.` that no reference + site can hold) with a prescriptive 501: it names the question that IS + answerable, `GET /api/v1/meta/object//references`. That clause is the + half ADR-0110 D3 exists to deliver — the admin "Used by" panel renders an empty + answer as *"Nothing in the metadata graph points at this item. Safe to delete."* + to an operator whose next click is a delete. + + Since #16146 the refusal crosses the REST boundary through the shared #5423 + bound (`CLIENT_MESSAGE_MAX`, 500 characters), which truncates the **tail**. The + sentence back-loaded the prescription and interpolates the object name twice, so + it grew about three characters per character of name and the remedy was the + first thing a long name cost. Measured through the real route on the unrepaired + sentence: a 37-character object name beside a 37-character field name composed + 502 characters and arrived as `…/api/v1/meta/object//referenc…` — the + opener still readable, the URL cut mid-path, an instruction that 404s if + followed. `crm_opportunity_line_item_snapshot_v2` is 37 characters, and nothing + caps a metadata name near that (the ceiling is the storing column's + `maxLength`; the widest is `sys_metadata.name` at 255). + + The clauses are re-ordered so truncation costs the **explanation** instead. No + behaviour moves: the refusal decides exactly what it decided before, the same + `NOT_IMPLEMENTED` / `501` / `refusal` declaration is raised for exactly the same + targets, and the bound is untouched. Callers matching on the message's opening + words will see the new order; matching on `error.code` is unaffected. + + FROM: `References to a 'field' item cannot be computed. … Ask the owning object + instead: GET /api/v1/meta/object//references.` + TO: `Ask the owning object instead: GET /api/v1/meta/object//references. + References to a 'field' item cannot be computed, because …` +- 7e74af3: Execute the read-probe's PostgreSQL catalog arm against a live server, closing the one dialect this package pinned as text and never ran. + + `read-probe.ts` compiles one non-raising table-presence arm per dialect family. Two were executed against something real — SQLite end to end through a real `SqlDriver`, MySQL on the live server the `Temporal Conformance` job provisions. The PostgreSQL arm (`SELECT 1 WHERE to_regclass('"
"') IS NOT NULL`) was pinned character-for-character against all four knex client spellings and run nowhere: this package had no live-PG harness, no `pg` dependency, and its CI step supplied `OS_TEST_MYSQL_URL` alone while filtering vitest to `live-mysql`. + + A text pin cannot close that gap, because the failure this module is fenced against is an arm mis-compiled for one dialect: it raises, the `catch` that exists for the expected miss swallows it, and a stored-row data repair silently becomes a no-op. Whether `to_regclass` answers ZERO ROWS rather than raising is a claim about PostgreSQL, not about this repo's string concatenation. `seed-tenancy-backfill.live-postgres.test.ts` now runs every statement the migration builds, both presence directions with the refusal control beside them, the search-path scoping the arm depends on, and the whole backfill end to end — on a live server, in its own derived schema. Ablated (the Postgres arm re-compiled to MySQL's `DATABASE()` form), six of its seven cases go red, reporting `verdict: 'unreadable'` with `detail: "function database() does not exist"` — the exact shape the fence exists to keep out of `'absent'`. + + Grade: `patch`, measured rather than defaulted. Not `minor` — no new export, no widened accept-set, no runtime behaviour change of any kind. Not `skip-changeset` either, and that is the measurement worth recording: `dist/` is byte-untouched (grepped for this change's markers: zero hits, against a positive control that hits `dist/index.js` and `dist/index.cjs`), but `package.json` is one of the 27 files `npm pack` ships, and it now carries `pg` and `@types/pg` in `devDependencies`. `skip-changeset` is for a diff that publishes nothing from a released package; this one publishes two manifest lines a consumer never installs, which is still publishing. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- b110578: fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) + + ## What was wrong + + `#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside + the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a + valid JS `Date` becomes ISO text — and handed **every other input back + untouched**. Four adapter boundaries used it, and each fed a field declared + `z.string()` or `z.string().datetime()`: + + | site | declared as | + |:--|:--| + | `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | + | `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | + | `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | + | `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | + + So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a + field declared `string`, each wearing an `as string` / `as string | undefined` + cast that asserted the opposite. Measured over the seven inputs that + distinguish the two helpers, the declared schemas refused **21 of 35** produced + values. + + `recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which + none of the three available answers was legal — the visible text + `"Invalid Date"` fails the refinement, `undefined` fails the required field, and + the pass-through fed it the `Date` object, which fails both. + + ## What it does now + + Those four sites read `canonicalIsoInstant`, whose return type **is** + `string | undefined`, so all four casts are deleted rather than restated. Both + sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen + per site, from the site's own declared schema: + + - `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the + branch an absent column already took. ⛔ No default is invented for a field the + schema lets be absent. + - `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named + `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a + `now` stamp is a plausible-looking recording instant nobody measured, and it + sorts a version recorded years ago to the top of a newest-first timeline. The + epoch invents no fact and sorts to the oldest end. It is also the answer the + sibling reader of this same `sys_metadata_history.recorded_at` column already + gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). + + Schema refusals over the same seven inputs: **21 → 8**. The eight that remain + are a `number` and an opaque object at four sites — shapes no driver is measured + to materialise for these columns. They now arrive as the declared *type* (a + string) that simply is not a valid datetime, so the producer's bug stays visible + instead of being papered over. + + ## One behaviour change worth reading twice — and it is why this is `minor` + + `DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An + Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — + so a row with an unreadable `updated_at` and a good `created_at` published + `new Date()` as its `mtime`. It now folds to `undefined` one step earlier and + loses the `??`, so the row publishes its `created_at`: a stored instant in place + of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column + takes" that `#14078`'s own ruling text prescribes for the shape. + + ⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies + `MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the + pre-existing pin asserted exactly that. So this one site is **not** the repair of + a violation — it is one legal published answer replaced by a different legal + published answer on a published read verb. Nothing was refused before and is + permitted now; a consumer simply receives a different instant. + + ## Why the two levels differ + + - **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, + are the "repairing an implementation that silently violated its own already + published declared type" case: the values that changed there are ones + `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and + nothing a consumer legitimately received has moved. But this package also + carries `stat()`, and that site changes a **legal** published answer, which the + paragraph above measures. The level is per package, so the four repaired sites + ride along at `minor`. + - **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves + a legal published answer. `rowToEvent` only stops emitting values + `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a + field declared `z.string()`), and `listCommits` is byte-identical on all seven + probe inputs. + + ⛔ No declared type narrowed, no export was added or removed (neither helper was + ever exported), and no envelope or accept set moved — so this is `minor` by the + changed-answer row, not a breaking change, and it carries no ADR-0087 + disposition. + + ## What deliberately did NOT collapse + + `listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock + promises callers the RAW value back for a non-`Date`, and the shared spelling + rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the + response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently + drops a value that is on disk) and hand a `number` or an opaque object to the + commit-timeline sort as `String(value)` rather than verbatim. Measured, that site + is byte-identical on all seven inputs before and after this change. + + `SqlDriver`'s same-named helper is not part of this family at all: it takes + `Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, + and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. +- 29d00cc: Fix `GET /meta/types` serving an empty JSON Schema for `action` + + `ActionSchema` is a `ZodPipe`, and the `output` derivation of a pipe carries no + properties, so `/meta/types` advertised `action` as + `{"$schema": "https://json-schema.org/draft/2020-12/schema"}` — a document that + reads as "this type declares no constraints" for a type that accepts 47 keys. + The hand-crafted fallback declared for this case never fired, because the + conversion did not throw: it succeeded and returned a truthy husk, which + short-circuits the `??` that was supposed to reach the fallback. + + A derivation that comes back with no properties, no union arms, no `$ref` and no + `additionalProperties` object is now treated as a non-answer. It is retried in + the authoring shape (`io: 'input'`), and if that degenerates too the type is + named in a one-shot warning and the hand-crafted fallback decides. + + Only `action` changes. The `output` derivation remains the served default on + purpose: deriving every type with `io: 'input'` was measured across the whole + served surface and would move 24 of the 26 types that carry a Zod schema, in the + direction of a weaker contract (`required` entries 1132 to 867, + `additionalProperties: false` 663 to 637). Gating the retry on degeneracy keeps + the change to the one type that was actually broken. + + Consumers reading `schema` for `action` from `/meta/types` or `/api/v1/meta` now + receive its real 47 properties instead of an empty object. No other type's + served payload moves, and a type that resolves no Zod schema at all continues to + be served with no schema — absence is not the same failure as a derivation that + came back empty. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- 7173d7d: Correct the `summary` column representation stated in the sort-hint TSDoc. Since #16318 an engine-maintained `summary` column is an exact `table.decimal` on tables created after that change and a `table.float` on tables created earlier; the shipped doc comment still said flatly that it is a `table.float`. Comment text only — the sort behaviour it describes is unchanged, and the column type was never what makes a `summary` field sortable (having a provisioned column is). +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [c88fa2c] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [0fb6f97] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [6ec467b] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [86f4246] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [e958468] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [522f612] +- Updated dependencies [6e3462d] +- Updated dependencies [31064ca] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [f89dd33] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [e4fd55d] +- Updated dependencies [7026141] +- Updated dependencies [ba17017] +- Updated dependencies [ecdfc94] +- Updated dependencies [131851f] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [4ecfd2b] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/lint@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/metadata@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index ecc9c7d23c..38d8deda1b 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack metadata management protocol: sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics (ADR-0076).", "type": "module", diff --git a/packages/metadata/CHANGELOG.md b/packages/metadata/CHANGELOG.md index 24f3f39908..fcd7c47ac5 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,458 @@ # @objectstack/metadata +## 17.5.0 + +### Minor Changes + +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- b110578: fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) + + ## What was wrong + + `#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside + the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a + valid JS `Date` becomes ISO text — and handed **every other input back + untouched**. Four adapter boundaries used it, and each fed a field declared + `z.string()` or `z.string().datetime()`: + + | site | declared as | + |:--|:--| + | `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | + | `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | + | `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | + | `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | + + So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a + field declared `string`, each wearing an `as string` / `as string | undefined` + cast that asserted the opposite. Measured over the seven inputs that + distinguish the two helpers, the declared schemas refused **21 of 35** produced + values. + + `recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which + none of the three available answers was legal — the visible text + `"Invalid Date"` fails the refinement, `undefined` fails the required field, and + the pass-through fed it the `Date` object, which fails both. + + ## What it does now + + Those four sites read `canonicalIsoInstant`, whose return type **is** + `string | undefined`, so all four casts are deleted rather than restated. Both + sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen + per site, from the site's own declared schema: + + - `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the + branch an absent column already took. ⛔ No default is invented for a field the + schema lets be absent. + - `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named + `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a + `now` stamp is a plausible-looking recording instant nobody measured, and it + sorts a version recorded years ago to the top of a newest-first timeline. The + epoch invents no fact and sorts to the oldest end. It is also the answer the + sibling reader of this same `sys_metadata_history.recorded_at` column already + gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). + + Schema refusals over the same seven inputs: **21 → 8**. The eight that remain + are a `number` and an opaque object at four sites — shapes no driver is measured + to materialise for these columns. They now arrive as the declared *type* (a + string) that simply is not a valid datetime, so the producer's bug stays visible + instead of being papered over. + + ## One behaviour change worth reading twice — and it is why this is `minor` + + `DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An + Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — + so a row with an unreadable `updated_at` and a good `created_at` published + `new Date()` as its `mtime`. It now folds to `undefined` one step earlier and + loses the `??`, so the row publishes its `created_at`: a stored instant in place + of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column + takes" that `#14078`'s own ruling text prescribes for the shape. + + ⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies + `MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the + pre-existing pin asserted exactly that. So this one site is **not** the repair of + a violation — it is one legal published answer replaced by a different legal + published answer on a published read verb. Nothing was refused before and is + permitted now; a consumer simply receives a different instant. + + ## Why the two levels differ + + - **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, + are the "repairing an implementation that silently violated its own already + published declared type" case: the values that changed there are ones + `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and + nothing a consumer legitimately received has moved. But this package also + carries `stat()`, and that site changes a **legal** published answer, which the + paragraph above measures. The level is per package, so the four repaired sites + ride along at `minor`. + - **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves + a legal published answer. `rowToEvent` only stops emitting values + `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a + field declared `z.string()`), and `listCommits` is byte-identical on all seven + probe inputs. + + ⛔ No declared type narrowed, no export was added or removed (neither helper was + ever exported), and no envelope or accept set moved — so this is `minor` by the + changed-answer row, not a breaking change, and it carries no ADR-0087 + disposition. + + ## What deliberately did NOT collapse + + `listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock + promises callers the RAW value back for a non-`Date`, and the shared spelling + rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the + response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently + drops a value that is on disk) and hand a `number` or an opaque object to the + commit-timeline sort as `String(value)` rather than verbatim. Measured, that site + is byte-identical on all seven inputs before and after this change. + + `SqlDriver`'s same-named helper is not part of this family at all: it takes + `Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, + and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. +- d64bcb6: **BREAKING** — retire the `adr-0030-notification-event` data migration. + + `migrateSysNotificationToEvent` had no way to be run: zero production callers + anywhere in the repo, and no `os migrate` sub-command, while the two sibling + members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel + export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's + pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed + together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform + on this line. + + ## What is gone, and what an upgrader does about it + + ⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to + adopt — every item below is a deletion, and the fix is to stop using it. + + - `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. + No replacement exists, and none is coming: an `os migrate notification-event` + sub-command was considered and refused. Delete the call. The compiler delivers + this one: the import fails to resolve. + - `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and + `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They + described that runner's own result, options and receipt and nothing else. + - `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a + three-member tuple and is now a two-member one holding + `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids + keep their sub-commands, their receipt rows and their birth attestation; only + the notification id left. Code typed against + `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification + id no longer compiles — delete that arm. + + `NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A + deployment attested at birth, or one that made the operator call while the runner + shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, + and the constant is that row's name. Nothing writes or reads a row under it any + more — `attestFreshDatastore` no longer includes it — and it is not a + registration: it gates nothing and never did. + + ## Reversal path + + Two answers were considered and both refused: an `os migrate notification-event` + sub-command is a permanent operator surface for a migration with no measured + demand, and a boot-time invoker is an unattended data rewrite nobody asked for. + ⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 + `sys_notification` rows. If a **named** deployment turns out to hold rows it + needs, the migration returns as an operator-runnable sub-command shaped exactly + like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, + documented consequence — under its own card. + + + +### Patch Changes + +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/metadata-fs@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index d4d066c575..6094b1108a 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Metadata loading, saving, and persistence for ObjectStack", "type": "module", diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index 987279e56b..977256d710 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,913 @@ # @objectstack/objectql +## 17.5.0 + +### Minor Changes + +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- a54ecaa: feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) + + + + **BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). + + ## What was wrong + + Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: + + | filter | before | after | + |:--|:--|:--| + | `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | + | `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | + + What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. + + ## What it does now + + - **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. + - **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. + - **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. + - **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. + - **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. + + ## Deliberately unjudged + + - **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. + - **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. + - **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. + - **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. + + ## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` + + `@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. + + It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: + + | stored surface | how it reaches the door | + |:--|:--| + | `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | + | `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | + | `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | + | dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | + + **Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. + + No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | + | `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | + | `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | + | `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | + | `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- 54e8234: **BREAKING** `engine.registerHook` refuses an engine lifecycle event the engine never dispatches (#17713) + + `registerHook(event, handler)` took `event: string`. For a name outside the dispatched set it logged a warning and then **registered the handler anyway**, so the declaration succeeded and the handler never ran — ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam. + + The measured cost is a data-visibility one. A consumer registered **read filters** on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals. They sat inert through every boot behind ~40 warning lines: `findOne` was still filtered (`beforeFind` covers it, so the mistake gave no signal), `count` was not — a `limit`ed list answered a `total` counting rows the caller could not see — and `aggregate` was not either, so a `groupBy` was not narrowed at all. + + Six event names now throw at registration instead of registering inert. They are the engine's own lifecycle namespace — `before`/`after` × `OperationContext['operation']` — minus the eight the engine dispatches, derived in code rather than typed out. + + FROM → TO: + + | was | now | fix | + | --- | --- | --- | + | `registerHook('beforeFindOne', h)` | throws | register on `'beforeFind'` — it already fires for `findOne` | + | `registerHook('afterFindOne', h)` | throws | register on `'afterFind'` — same reason | + | `registerHook('beforeCount', h)` | throws | `count()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'count'` | + | `registerHook('afterCount', h)` | throws | same as `beforeCount` | + | `registerHook('beforeAggregate', h)` | throws | `aggregate()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'aggregate'` | + | `registerHook('afterAggregate', h)` | throws | same as `beforeAggregate` | + + One-line fix for a read filter that was on `beforeCount` or `beforeAggregate`: move it into `engine.registerMiddleware(async (ctx, next) => { if (ctx.operation === 'count' || ctx.operation === 'aggregate') ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, scope] } : scope; await next(); })` — the same seam RLS and sharing already use, so the predicate reaches the driver call. + + What is **not** affected: an event name outside the engine's lifecycle namespace (`'myPlugin:flush'`) still warns and still registers, so a plugin that dispatches its own events through `triggerHooks` keeps working. Metadata-authored hooks were never exposed — `HookSchema.events` is `z.array(HookEvent)` and `HookEvent` enumerates exactly the eight dispatched names, so the gap only ever existed on the code door. + + +- a016f08: fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) + + + + **BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. + + `check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. + + A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: + + - **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. + - **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. + + Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. + + **What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; `update` is unchanged. The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. + + **Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. + + **Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: + + - a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. + - an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. + + **The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. + + ⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. +- 5c8f5af: feat(engine): `ObjectRepository.findOne` / `.update` publish their honest types — the contract's shapes, not `any` (#16786) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, and PR #14434 before it on `@objectstack/driver-memory`). + + `ObjectRepository.findOne()` and `.update()` were written out with an explicit `Promise` while they have always answered what the contract declares — each one forwards, one line down, to an `IDataEngine` door that already declares the shape: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + + `IScopedObjectRepository` — the contract this class carries an `implements` clause for — declares both, and has since ruling A on #16231 landed (PR #16783). An explicit `any` satisfies that structurally, because a **wider** declared return always satisfies a narrower one: `class ObjectRepository implements IScopedObjectRepository` compiled green the whole time while the emitted `.d.ts` read `Promise`, so no caller holding an `ObjectRepository` — or reaching one through `ScopedContext` or `ObjectQL.createContext()`, both exported from this package's index — was ever asked to narrow. They are now declared as the contract declares them. No runtime behaviour changes. + + A caller that read fields off `findOne()`'s result through the `any` now narrows the `null` arm first; a caller that read `update()`'s result now separates the by-id record from the predicate-form count. The in-repo census for this change was one file, repaired alongside. + + `updateById` is deliberately untouched: `IScopedObjectRepository.updateById` itself declares `Promise`, so the class already matches its contract and there is no drift to repair on this side. That half stays open on #16786. + + +- 5b5bd36: fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) + + + + **BREAKING** for a non-system caller that CREATES a static `readonly` column on an + object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name + outside the reserved `sys_` namespace: the forged value used to be persisted and is + now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop + reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` + under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). + That is exactly what the same caller's UPDATE of the same column already did. Shipped + as `minor` under the repo's launch-window convention. + + ## The census, both halves — neither one is the whole reading + + **(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit + in the three now-judged buckets and carry a static `readonly` column between them — 64 + columns in all: + + - `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, + `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` + - `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, + `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` + - `system-data` (8 objects, 21 columns): `sys_approval_delegation`, + `sys_notification_preference`, `sys_notification_subscription`, + `sys_notification_template`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` + + **And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree + carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above + included — so the namespace test, which this change does not touch, keeps all of them + exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct + non-system `engine.insert` from the metadata repository, is doubly exempt + (`engine-owned` bucket **and** `sys_`) and is pinned as such. + + ⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the + population the narrowing reaches is real and named above, and an app that declares one + of those buckets on its own object gets the strip. The population on its own + understates it — not one shipped object changes behaviour on this release. What moves + is the contract for **app-authored** objects, which is the population the ruling is + about. + + ## What was wrong + + `staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, + carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / + #3004 grounds: those columns have their own 403 guards, and a silent strip must not + swallow the payload the guard exists to reject. The argument is sound and the bucket + list was not. `managedBy: 'system-data'` means "platform-defined schema, + **admin/user-writable data**" by its own definition, and `object.zod.ts` says in the + same breath that it "carries no such guard; its writes are adjudicated by the + delegated-admin gate / RLS / permission sets". So the create side skipped the strip on + objects whose data is the user's, while the update side stripped them — and #14147's + "one semantics, one enforcement point" was not literally true on that population. + + ## What it does now + + The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for + the three buckets whose columns really do carry a fail-closed refusal: + + | bucket | its own refusal | the create-side strip | + |:--|:--|:--| + | `engine-owned` | ADR-0103 engine-owned write guard | steps around it | + | `append-only` | ADR-0103, same guard (locked default) | steps around it | + | `better-auth` | ADR-0092 identity write guard | steps around it | + | `platform` | none — full user CRUD by default | judges it | + | `config` | none — admin-authored, writable by default | judges it | + | `system-data` | none — "admin/user-writable DATA" | judges it | + + An **unrecognised** bucket value is deliberately not read as platform-internal: the one + legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and + converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt + precisely the rows that conversion targets. The partition is pinned against + `@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing + silently on one side. + + The ruling's fallback ("leave it, if those buckets' readonly columns already carry + their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 + package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, + `drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / + `created_at` / `updated_at` stamps, which that guard does not reach. + + `@objectstack/lint` mirrors this predicate to decide which objects its create-verb + `flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, + and is narrowed in the same stroke — a lint that kept the wider exemption would go on + suppressing findings for a strip that now really happens. + + ⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. + The asymmetry is closed by moving CREATE toward UPDATE. + +### Patch Changes + +- ada2869: fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290) + + + + **BREAKING** — `@objectstack/metadata-protocol`'s `insertManyData` no longer hangs + `droppedFields` on each entry of `outcomes`; the response itself carries it, beside + `outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read + the per-row member stops compiling, and the compiler names the site. The set reported + is the same set — what is gone is a per-row attribution that could not be computed + here and was wrong whenever it mattered. Nothing authored or stored changes shape. + + **What it got wrong.** Every create-side strip is the engine's, and its + `onFieldsDropped` event is the UNION over the batch — the listener signature + carries no row index. This seam reconstructed a row set from that union by + asking which rows SUPPLIED each dropped name + (`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that + "the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs + to exactly the rows whose supplied payload carried it". Maintainer ruling C + falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`, + AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned — + recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where + a hook stamps a protected key on some rows and not others: + + - row A supplied `approval_status`, no hook write ⇒ stripped, enters the union; + - row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and + written**; + - and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]` + on a record that still held `approval_status`. + + A row the batch culled before the strip ran (a per-row validation failure) was + named on the same test, having dropped nothing at all. + + ⇒ A wrong attribution costs the reader a wrong investigation, and the import + surface — which prefers this path over `createManyData` — is the consumer most + likely to act on it while reconciling what landed. + + **Why not attribute per row instead.** The honest set is `{rows whose payload + carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second + half is computed per row upstream but does not cross this seam. The outcome's + own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED + over exactly the keys the strip took, and a stripped `autonumber` is refilled by + `applyAutonumbers` — so on both, the key is PRESENT on the row that really did + drop it, and a post-hoc "is the key still there?" check would delete true + attributions while leaving the hook-exempt false one standing. Comparing values + fails on the very case `hookWrittenKeys` exists for: the hook assigning the + value the caller also sent. Restoring row precision means giving the engine's + drop report a per-row channel, not a reconstruction at the call site. + + **Prose corrected with it**, by CLAIM rather than by spelling — the docblock + that authorised the inference is the thing that re-authorises the next author: + `insertManyData`'s own docblock and `createManyData`'s parenthetical + (`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing + sentence, `engine.insertMany`'s docblock claim that "a caller holding the input + rows can attribute each name back to the rows that carried it" + (`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and + `CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical + (`@objectstack/spec`, a string printed AT the customer). + + **Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`, + and they always could: each row is its own `engine.update` / `engine.insert` + call, so that call's events are that row's — earned mechanically, not inferred. + `createManyData`'s aggregated shape is untouched. No strip changes, no row + changes, and the same field names are reported. +- 758ac40: refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) + + The predicate that decides whether a sandboxed body's `throw` is a business + REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) + had **three byte-identical copies** — measured, one distinct 74-character regex + literal across three packages: + + | copy | package | its stated reason for being a copy | + |:--|:--|:--| + | `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | + | `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | + | `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | + + ⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of + them survives moving the rule.** `@objectstack/types` now owns + `isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence + of a bare `Error:`. All three packages already depend on it and it depends on + none of them, so this fold **adds zero dependency edges** and cannot cycle. + + ⚠️ The hazard was never style. One copy learning a new native error name and the + others not means the same throw is a refusal at one door and a crash at the + next — a crash message **leaked** at one boundary and **withheld** at another. + #16013's argument for extracting exactly this class applies verbatim: the + classification is the part nobody may get wrong, so one *tested* helper is worth + more than N correct copies that must each stay correct forever. + + ⛔ **No behaviour changes at any door, per case.** This is a pure refactor and + the three WRAPPERS are deliberately NOT folded, because they are not the same + shape and merging them would move a door's answer: + + - rest asks a trimmed message and answers a boolean; + - objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — + because a code hook and a sandboxed body carry the native name in different + places; + - runtime asks the trimmed inner message and answers the **message**, not a + boolean. + + What the three share is the predicate, so the predicate is what moved. Each call + site keeps its own slot choice and its own trimming, and `isNativeErrorName` + deliberately does **not** trim for its callers — a contract pinned in its test. + + **Shipped rather than `skip-changeset`**, measured on a real build: all four + packages publish `files[]: ["dist", …]`, and the built `dist` of each carries + the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, + `@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` + scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. + The retired copies are gone from the artifacts too: the regex literal scores + **0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in + `types/dist` (the ESM and CJS bundles). + + `@objectstack/types` takes **minor**: a new export is a purely additive widening + of a published surface, which is at least minor whatever the commit type says. + The three consumers take `patch` — their artifacts change, their behaviour does + not. +- 0780e88: fix(objectql): a refused write reports at `warn`, not `error` — the caller was already told (#17052) + + `insert`, `update` and `delete` each end their `catch` with `throw e`, then + logged the failure at ERROR one statement earlier. AGENTS.md → *Degradation log + levels* names that exact shape and forbids it: "a failure handed to the CALLER + is not a degradation at all … Do not bolt a `logger.error` onto such a site." + + **This moves published behaviour**, which is why it is a changeset rather than a + `skip-changeset`: the level is what an operator greps, and at least one consumer + reads it structurally. `scripts/publish-smoke.sh` fails a boot on any + error-level line (`SMOKE_ERROR_LOG_PATTERN`), and that is how the defect was + found — `@better-auth/oauth-provider` seeds `sys_oauth_resource` in `insertOnly` + mode and documents its `identifier` UNIQUE constraint AS its race-safety + mechanism, catching the collision and continuing at `debug`. Our line was + emitted before that catch ever ran, so a healthy first boot of every fresh + `create-objectstack` project printed `ERROR Insert operation failed` and red-lit + `publish-smoke / packed-tarballs` for six consecutive runs on a candidate whose + auth and CRUD probes were all green. + + **Nothing else about the entry moved.** Same message, same `object` meta, same + redaction (#8682: the bound statement and its values stay cut from `message` + and `stack`), same subject (#14095: the entry carries the driver's own error — + a `DuplicateRecordError`'s `cause` — never the envelope, so the failing column, + MySQL's index name and the driver's frames survive). The `Logger` contract gives + an `Error` slot to `error`/`fatal` only, so the engine now builds the + `{ error: { message, stack } }` bag that slot used to build; handing the Error + to `warn` as meta would have serialised `{}`, because those two fields are + non-enumerable. The rendered line is byte-identical apart from the level word, + and that equivalence is pinned rather than asserted. + + If you grep your logs for these three messages, keep the message and drop the + level from the pattern. If you alert on error-level lines from `@objectstack/objectql`, + a refused write no longer raises one — the write's exception still does. +- 706ad0f: fix(objectql): a hook that faults reaching through a withheld read-only key now names the key, says the platform withheld it, and points at `ctx.previous` (#17219) + + Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook body that reaches **through** such a key — `ctx.input.locked_meta.who = 'hook'`, where `locked_meta` is a caller-supplied read-only `json` column — therefore dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write. + + **The refusal is correct and is unchanged.** What it replaced is a write that succeeded while persisting a value derived from the caller's forgery, and #16344 exists to close exactly that route. What this fixes is the diagnostic. Measured before this change, at both doors: + + ``` + direct SandboxError: hook 'guard_task_body' threw: + TypeError: cannot set property 'who' of undefined + REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} + ``` + + The REST reading is the one that matters, and it is the worse of the two: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so an author was told nothing at all — not which key, not that the platform had taken it away, not what to read instead. + + ### Who is affected + + Anyone whose `beforeUpdate` hook reads a read-only field that the caller may also send. The write was already being refused; only the message changes. A hook that needs the stored value reads it from **`ctx.previous.`** — the same remedy PR #17195's changeset documents. + + ### What the message says now + + ``` + A `beforeUpdate` hook faulted while `locked_meta` was withheld from it. That field is + `readonly: true`, and the engine withholds a caller-supplied value for a read-only field + from `beforeUpdate` hooks, so `ctx.input.locked_meta` reads `undefined` — withheld by the + platform, not missing by accident. Read the stored value from `ctx.previous.locked_meta` + instead. Original fault: TypeError: cannot set property 'who' of undefined + ``` + + The error declares **HTTP 400**, which is what carries it past the script-fault sanitiser onto the same "message verbatim" channel a body's own authored refusal already rides; REST callers who previously saw `500 INTERNAL_ERROR` for this case now see 400 with the text above. The original fault is carried inside the message rather than replaced. + + ### Deliberate limits + + No new error code is registered and no key is added to any published payload — a dedicated `ERROR_CODE_LEDGER` entry for this refusal is a separate decision. The explanation claims only what is knowable at the seam: *faulted while these keys were withheld*, never a proven cause. An **authored** refusal (`throw new Error('…')`) is never rewritten, and a crash on an operation where nothing was withheld passes through untouched. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. +- 980dc78: fix(objectql): `engine.aggregate`'s in-memory lowering asks the driver for ROWS, so a per-aggregation `filter` stops being refused by the driver it was lowered for (#16642) + + `engine.aggregate` forks: a driver with a native `aggregate()` gets the pushdown, and anything the pushdown cannot express — a per-aggregation `filter` (#10576), a date granularity the driver does not advertise, a non-UTC reference timezone — falls back to `driver.find()` plus `applyInMemoryAggregation`. That fallback handed `find()` the whole aggregate AST, **aggregation keys included**. + + `find()`'s contract says nothing about `groupBy` / `aggregations`, and the drivers disagree about them. `driver-sql` and `driver-rest` ignore both and return rows — which is the only reason this path ever worked. `driver-memory` **honours** them (`find()` → `performAggregation`, the same method its `aggregate(AST)` door funnels through), which is the shape measured here; `driver-mongodb` and `driver-turso` carry the same refusal on their own aggregation faces, so a driver that ever routes `find()` into one lands in the same place. Against a driver of the second kind the one seam answered two different wrong things: + + - the per-aggregation `filter` that **routed the call here** was refused `NOT_IMPLEMENTED`/501 by the driver's own #10413 guard — a guard aimed at a caller reaching the driver's aggregation face directly, whose remedy text is *"route the query through the engine"*. The engine's own lowering was being told to use the engine. Downstream, `service-analytics`'s ObjectQL strategy lowers a dataset measure `filter` into exactly this key, so on the memory driver a measure `filter` (and the `derived: { op: 'ratio' }` that needs two differently-filtered counts) answered **501** while sqlite answered the number; + - a date-bucketed `groupBy` came back **already grouped**, on the raw timestamp — `dateGranularity` is an engine concept no driver face reads — and `applyInMemoryAggregation` then aggregated those group rows a second time. That half does not refuse: it reports a count of *buckets* under the author's own measure name. + + The fix is one seam: on the in-memory path the AST sent to `find()` carries no `groupBy`, no `aggregations` and no `having` — the three things this path is about to evaluate itself. `where` is untouched, so the middleware-injected read scope (RLS / tenancy) still travels with the call. + + `patch`: no signature moves and no key is added or retired. The pushdown fork is unchanged (an aggregation with no filter still goes to `drv.aggregate`), and on `driver-sql` — which ignored the stripped keys — the emitted statement and every number are unchanged. What changes is that two shapes that used to answer a refusal or a wrong number now answer the number the contract already promised: `driver-memory`'s `refusePerAggregationFilter` and `driver-sql`'s `unsupportedAggregationFilterError` both document themselves as *unreachable through `engine.aggregate`, which lowers in memory for every driver* — this is the line that makes that true. +- 2e8e118: Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. + + The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. + + No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [dc709b2] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e743fb5] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [7e74af3] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [4062aef] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [7173d7d] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/metadata@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 7b2710d80d..bdca9b8a92 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Isomorphic ObjectQL Engine for ObjectStack", "main": "dist/index.js", diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md index c4ffbf7402..cb24397aee 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/observability +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 18dee54e61..2416adf31d 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Observability contracts and exporters for ObjectStack — MetricsRegistry, ErrorReporter, Logger plus noop/console/OTLP-HTTP exporters. Deployment-target neutral; runtime and services depend on this so the same instrumentation works on Cloudflare Workers, Node, and self-hosted Kubernetes.", "type": "module", diff --git a/packages/platform-objects/CHANGELOG.md b/packages/platform-objects/CHANGELOG.md index 329fc74552..3804356423 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,458 @@ # @objectstack/platform-objects +## 17.5.0 + +### Minor Changes + +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- 9c577c1: fix(platform-objects,cli): the generated i18n staleness predicate judges every section a run generated, not two fixed names + + `os i18n extract --no-objects-only --fill=default --source-hashes` emits + `apps` / `dashboards` / `pages` leaves and fills them from the source locale — + leaves carrying exactly the property the GENERATED staleness predicate exists to + judge — but the population that predicate walked was the fixed + `GENERATED_SECTIONS` list (`['objects', 'metadataForms']`). So no provenance + record was written for such a leaf, none was read back, and a `--fill=default` + copy left behind by a revised source kept being served as a superseded draft + with every i18n gate green. The hand-authored predicate does reach those paths, + but it judges against `LOCALE.source-hashes.ts`, which by construction carries + no entry for a leaf a generator produced. Neither mechanism covered them. + + The population now follows the RUN, at both ends: + + - **write** — `collectFilledFromHashes` takes a new **optional** fourth + parameter, `sections?: readonly string[]`, defaulting to `GENERATED_SECTIONS`. + `collectGeneratedLeaves` takes the same optional second parameter. Every + existing call site compiles and behaves exactly as before; `os i18n extract` + passes the sections it actually built. + - **read** — `findStaleFills` walks the sections the recorded table itself + names. One run wrote that table, so the table is the record of what that run + emitted, and the two ends cannot disagree about it. For every table committed + today this resolves to `['objects', 'metadataForms']`, so no served byte moves. + + Adding `'apps'` to `GENERATED_SECTIONS` was the other available shape and is + deliberately not taken: it would make `collectSourceLeaves` and + `collectGeneratedLeaves` walk one section — two predicates permanently on one + path — and it would assert `apps` is always generated, which is false for every + bundle set that ships. Both constants are unchanged and pinned unchanged. + + Widening the generated population is safe in a way widening the hand-authored + one would not be, because the rule is self-discriminating per leaf: a record is + written only when `value === currentSource` or `previous[path] === hash(value)`, + so a leaf someone actually translated satisfies neither and stays + legacy-trusted however wide the walk. The section list was the only part of the + mechanism that could not tell a fill from a translation. + + No committed bundle or companion byte moves in this repository. All nine + `--source-hashes` configs run the default `--objects-only`, whose commit layer + already narrows the run's table to the sections it emits a bundle for. The 387 + hand-recorded digests across `zh-CN` / `ja-JP` / `es-ES` are neither read, + written, shadowed nor lost — `apps` stays in `HAND_AUTHORED_SECTIONS`, + `collectSourceHashes` still walks it, and the extractor still never writes that + file. Its header now states which table a maintainer keeps for a path that can + appear in both, and why the overlap cannot serve wrong text. +- a2c2852: Notification fan-out asks a channel whether the tenant can send on it before writing anything, so a channel with no transport no longer produces `sys_notification_delivery` rows that exist only to dead-letter (#17732). + + `MessagingChannel` gains one **optional** member, `isAvailable(ctx, { organizationId })`, answering `{ available: true }` or `{ available: false, reason }` from the closed vocabulary `CHANNEL_UNAVAILABLE_REASONS` (today: `transport_not_configured`). `emit()` consults it once per channel per emit — availability is a property of `(tenant × channel)`, not of a recipient — and a channel that answers unavailable gets no delivery row and no `send()` call on either the outbox (P1) or the inline (P0) path. + + - **Optional means available.** A channel that does not implement the member is treated exactly as before. Every existing implementation, in this repo and in yours, keeps working unchanged with no edit; the same is true of a channel that is registered but unknown to this version. ⛔ There is no way to configure the opposite default. + - **The suppression is recorded, not swallowed.** `sys_notification` gains one key, `suppressed_channels` — `[{ channel, reason }]`, `NULL` when nothing was suppressed — written in the *same* insert that creates the event row, so the feature costs no additional write. `EmitResult` gains the matching `suppressed` array, so a caller is never handed a delivery count that silently omits a channel it asked for. + - **The `email` channel answers from the transport it was handed** — a service-registry lookup, no I/O, nothing cached. Mail configuration in this tree is the `mail` settings namespace at `scope: 'global'`, materialised into a single in-memory transport that the settings change bus hot-swaps, so there is no per-tenant row to read and a memoized answer would survive the settings save that fixed it. The query still takes the tenant context so a future tenant-scoped transport needs no interface change. + - **A probe that throws is treated as available** and logged at `warn`: a broken availability check degrades into today's behaviour, never into a silent notification outage. + - ⚠️ **Unchanged on purpose**: a channel named in `channels` that is not *registered* at all keeps its existing path — the inline fan-out reports it as a failed delivery, the outbox enqueues a row the dispatcher dead-letters. It has no implementation to ask, and widening this ruling to cover it is filed separately. +- c744c0a: `apps.account.navigation.nav_connect_agent` is translated in all four locales, so the Connect an Agent page renders the same string behind both doors + + `@objectstack/mcp` contributes the Connect an Agent page into **two** apps — `setup` (admins) and, since #17646, `account` → Developer (every authenticated user). The translation bundles are keyed `apps..navigation.`, one namespace per app, and only the `setup` key existed. So `apps.setup.navigation.nav_connect_agent` never answered for the Account door, and one destination rendered two different strings for the same signed-in user: + + | door | before | + |:--|:--| + | Setup → Integrations | 「连接智能体」 / 「エージェントを接続」 / "Conectar un agente" | + | Account → Developer | `Connect an Agent`, the English literal, in every locale | + + The population that got the untranslated one is precisely the non-admin on a non-English locale: Account is the only one of the two doors they can open. + + Adds the key to `en` / `zh-CN` / `ja-JP` / `es-ES`, mirroring the Setup twin's strings verbatim, plus the `#8765` provenance row in each of the three hand-maintained `.source-hashes.ts` tables (`en` is the source, not a copy of one, so it has no table and gets no row). The recorded digest is `collectSourceHashes(en)['apps.account.navigation.nav_connect_agent.label']` — the repo's own `hashSource`, not a hand-written value. + + ⛔ No behaviour outside the bundle moves. No nav item, permission, route or page is added: the contribution and the destination already existed and are untouched, and the Setup key is byte-unchanged. This is an additive key on a published payload, which is why it ships `minor` rather than `patch`. + + Neither gate over this surface could see the gap, and neither is changed here: `pnpm check:app-nav-i18n` scopes itself to `APP_NAME = 'setup'` and skips every contribution targeting another app, and `app-nav-translation-parity.test.ts` walks statically declared nav — the Account entry is contributed at runtime, so no static walk reaches it. Extending the gate is the next step in the standing repair order and lands in `packages/cli/scripts/**` under its own card, deliberately not folded in here. +- 23aa83c: `DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. + + **What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. + + **Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. + + **Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. + + This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. + +### Patch Changes + +- 305e7fc: Name where the organization record page's Members / Invitations / Teams tab strip is declared, at the three places that assert it (#16270) + + #16270 measured that no object under `packages/platform-objects/src/identity/` declares + the `Field.relatedList` prominence key, and inferred from that a two-way disjunction: + either the metadata is short three `relatedList: 'primary'` declarations, or the three + documents that describe the page as opening on tab-0 **Members** have gone stale. + + **Neither. The premise is false.** The tab strip is declared metadata — + `SysOrganizationDetailPage` in `packages/platform-objects/src/pages/sys-organization.page.ts`, + a `kind: 'slotted'` record page for `sys_organization`, `isDefault: true`, handed to the + runtime by plugin-auth's `pages: [SysOrganizationDetailPage, SysUserDetailPage]`. Its + `slots.tabs` override carries exactly three `record:related_list` tabs — Members, + Invitations, Teams, in that order — and objectui's synthesizer pushes that authored node + and never calls `buildDefaultTabs`, so the strip replaces the synthesized + Details + stacked `Related` one outright and Members really is at index 0. That file was + already in the tree at the commit the card measured. + + `relatedList: 'primary'` is a different mechanism (prominence on a child's lookup field, + promoting one derived list to its own tab). The card looked for that key, correctly found + none, and read the zero as "declared by no metadata". While the `tabs` slot is present, + adding the key would not move this page at all. + + **What changes here is prose only — no metadata, no behaviour.** The two source comments + that assert the tab order and the QA checklist item that grades it now name the page that + declares it, so the next reader does not repeat the measurement: + + - `packages/platform-objects/src/identity/sys-member.object.ts` — the `invite_user` + mirror's rationale + - `packages/platform-objects/src/identity/invite-entry-toolbar.test.ts` — the file header + that states the whole pin's premise + - `docs/qa/platform-checklist/areas/identity-auth.json` — + `identity-auth.org-membership-team-management`, a new `source` entry plus the revision + and history bump its ledger requires. Steps, acceptance clauses, oracles and negatives + are unchanged: a grader grades exactly what it graded before, and now knows that a + Details + stacked `Related` strip means this page failed to load rather than that the + clause was wrong. + + This package ships its `src` comments in `dist` (measured: the new comment text appears + 4 times under `packages/platform-objects/dist`, with an exported symbol as the positive + control and the test-file header absent at 0), which is why a comment-only diff here + takes a changeset rather than the publishes-nothing exemption. +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- 4215417: `sys_user.role`'s field description and its `readonly` comment stop pointing at the retired Set Platform Role action (#15188) + + Both strings named `set_user_role` / "Set Platform Role", an action retired in #9968 — the description told an operator to press a button that no longer exists anywhere in the product. This is not a source comment: a field `description` is authored data that ships in the published bundle and is extracted into the i18n bundles, so it surfaces in the admin UI's field help and in generated reference material. The correct path was already there and already the only one: platform-admin standing comes from an unscoped `admin_full_access` grant in `sys_user_permission_set` (ADR-0068 D2), which is exactly what the #9968 removal note in the same file says. + + - **`description`** now reads "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`." It states what the column IS (a vendor authentication-layer scalar that stays published as `user.role`) and where the operator actually goes, and it deliberately does not claim the scalar confers nothing: `judgePlatformAdmin` still reads `user.role === 'admin'` as the legacy fallback it has always been, so a pre-D2 deployment carrying the value is not locked out. Saying "this field grants nothing" would have replaced one false sentence with another. + - **The `readonly` comment** keeps its ADR-0092 anchor and now states the true reason the field is not editable — nothing writes it since #9968 — instead of naming a writer that is gone. + - **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand: the default locale's leaves are rewritten from the source on every run. + + **Deliberately unchanged, and pinned so it stays that way.** The same file carries a third mention inside the #9968 removal note — *"a working \"Set Platform Role\" button **was** a supported, one-user-at-a-time resurrection channel…"*. It is past tense, it narrates what was removed, and it is true; sweeping it up with the other two would turn a true sentence false. A new test pins the removal note's tombstone opener and that past-tense sentence as occurrence counts over the source text, so both directions fail: deleting the history drops a count to 0, and re-introducing the retired action's name in live prose pushes one past 1. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 9be7140e04..f4196af6f5 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Core platform object schemas for ObjectStack — identity, security, audit, tenant, and metadata objects", "main": "dist/index.js", diff --git a/packages/plugins/embedder-openai/CHANGELOG.md b/packages/plugins/embedder-openai/CHANGELOG.md index b4d6799c82..6e30a806b3 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/embedder-openai +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 3e29a6a985..69e2a84bcf 100644 --- a/packages/plugins/embedder-openai/package.json +++ b/packages/plugins/embedder-openai/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/embedder-openai", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "OpenAI-compatible embedder for ObjectStack — works against OpenAI, 阿里通义 DashScope, 智谱 BigModel, 硅基流动 SiliconFlow, 火山引擎 Doubao, MiniMax, Ollama, and any drop-in OpenAI-shape endpoint.", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-memory/CHANGELOG.md b/packages/plugins/knowledge-memory/CHANGELOG.md index 1c79374df6..a71a11d53d 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/knowledge-memory +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index b7da0f383d..772ff4f9f0 100644 --- a/packages/plugins/knowledge-memory/package.json +++ b/packages/plugins/knowledge-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-memory", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "In-memory knowledge adapter for ObjectStack (dev / test reference implementation).", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-ragflow/CHANGELOG.md b/packages/plugins/knowledge-ragflow/CHANGELOG.md index 9762e0d512..faf5a0e370 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/knowledge-ragflow +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index baebbba5ab..21c17bd790 100644 --- a/packages/plugins/knowledge-ragflow/package.json +++ b/packages/plugins/knowledge-ragflow/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-ragflow", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "RAGFlow knowledge adapter for ObjectStack — production-grade RAG via the Apache 2.0 RAGFlow REST API.", "main": "dist/index.js", diff --git a/packages/plugins/organizations/CHANGELOG.md b/packages/plugins/organizations/CHANGELOG.md index 9a513a19a0..813733f8f4 100644 --- a/packages/plugins/organizations/CHANGELOG.md +++ b/packages/plugins/organizations/CHANGELOG.md @@ -1,5 +1,148 @@ # @objectstack/organizations +## 17.5.0 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [ee6fbd7] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [c9246fa] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [45c2cf9] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [9ca49eb] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json index a1aea863cd..f2f83b1bc0 100644 --- a/packages/plugins/organizations/package.json +++ b/packages/plugins/organizations/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/organizations", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Multi-organization runtime for ObjectStack — registers the `org-scoping` service that turns single-database row-level Organization isolation on: `organization_id` auto-stamp on insert, per-org seed replay, default-organization bootstrap, and the walled-posture membership-policy gate.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index 0d119e6ac3..35eae51916 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,278 @@ # @objectstack/plugin-approvals +## 17.5.0 + +### Minor Changes + +- c8a006f: An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A). + + **The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers. + + ``` + FROM service.decide(requestId, { decision: 'approve' }, ctx) + -> { finalized: true, decision: 'approve', runId: '', resumed: true } + // identical to a healthy composition's answer — no caller can tell + + TO service.decide(requestId, { decision: 'approve' }, ctx) + -> { finalized: true, decision: 'approve', runId: '', resumed: true, + resumeError: "RESUME_FAILED: … its own flow run '' resumed, but the " + + "subflow parent above it — run '' — consumed its suspension " + + "and is now stranded: ", + resumeFailure: { code: 'RESUME_FAILED', runId: '', status: 'stranded', repairable: true } } + ``` + + **Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history. + + **What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here. + + **New public surface — the reason for `minor` on both packages, not `patch`.** Getting the parent's strand from the engine to the approvals door without touching `packages/spec` or the wire-visible `AutomationResult` (which a raw REST `POST …/resume` also serves verbatim, so a field there would leak an undeclared key onto every subflow resume, not only an approvals-mediated one) needed a small new internal channel: + + - `@objectstack/service-automation`: `AutomationEngine` gains a new public method, `takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined` — read-once (deletes on read), populated only by `bubbleToParent`'s `'stranded'` exit. `SubflowParentStrand` is a new exported interface (`{ runId, repairable: true, error }`). + - `@objectstack/plugin-approvals`: `ApprovalResumeSurface` (already exported from the package entry) gains a matching optional member, `takeSubflowParentStrand?(childRunId): { runId, repairable, error } | undefined`. + + Both are additive and optional; nothing existing changes shape or behaviour. Neither reaches any wire payload — `AutomationResult`, the REST resume door's response, and every other published contract are byte-for-byte unchanged. + +### Patch Changes + +- 9fca8eb: An approval `recall()` whose resume strands the run now tells the caller WHICH failure it was, in fields — `resumeFailure: { code, runId, status, repairable }` beside the prose `resumeError` — instead of one sentence a caller has to parse (#15970; the #16472 family ruling, decision batch #76, option A). + + **The shape.** A flow parks at an `approval` node; the reject branch's downstream node throws. The submitter recalls the request, which resumes the run down the `reject` edge — and that resume strands it. The withdrawal is durable and the call correctly does not throw, but the engine's own discriminator never reached the caller: `recall` resumes DIRECTLY rather than through `resumeRecordedOutcome`, and its `catch` kept `err.message` alone, discarding the `resumeStatus` (`AutomationResult.status: 'stranded'`) the error already carried one line before the result was built. `repairable` had a producer and, on this door, no consumer. + + ``` + FROM service.recall(requestId, { actorId }, ctx) + -> { request: { status: 'recalled' }, runId, resumed: false, + resumeError: "resume of run '' failed: " } + // prose only — nothing says the run is still repairable + + TO service.recall(requestId, { actorId }, ctx) + -> { request: { status: 'recalled' }, runId, resumed: false, + resumeError: "resume of run '' failed: ", + resumeFailure: { code: 'RESUME_FAILED', runId: '', + status: 'stranded', repairable: true } } + ``` + + **⛔ The no-throw stays, and that is the ruling's point.** The withdrawal and the record-lock release are the product of this call and they have already happened when the resume fails; making `recall` fail would be the wrong fix, not a stricter one. The door's `error` log line is untouched too, at the same level with the same context keys — the ruling left logging alone, and the report is a sibling of that line, not a replacement for it. + + **Two exits report, and the rest deliberately do not.** A report is stamped exactly where the engine's own verdict says `'stranded'`: this door's own resume stranding, and (the sibling half of #15556, whose producer landed one door over) a resume that SUCCEEDED while the subflow parent above it stranded — which answers `resumed: true` with the PARENT's `runId` on `resumeFailure`, exactly as `ApprovalRecallResult.resumed`'s docblock already declared. Every other exit answers as it always did, with no `resumeFailure` at all: a lost run's honest code is `RESUME_TARGET_LOST` and the tolerated duplicate's is `RESUME_IN_PROGRESS`, and this package's ADR-0112 ledger row admits exactly one code, so stamping `RESUME_FAILED` there would make the discriminator lie about which failure it was — the defect this fixes, one field over. Per the member's own docblock, an absent `resumeFailure` means no report was made, never that no run is stranded. + + **Additive only — no migration, and `patch` rather than `minor`.** `ApprovalRecallResult.resumeFailure` was already declared, exported and type-pinned in `@objectstack/spec` ahead of this card (`contracts/approval-service.ts`, `resume-failure-report.pin.test.ts`); this fix is the first producer that fills it. The delivered diff adds no exported symbol to `@objectstack/plugin-approvals` — nothing new is reachable from its published entry — and adds no key to a payload that did not already declare one. Nothing existing changes shape: a consumer that ignores unknown fields sees no difference, and one that reads `resumeFailure` can now branch on `repairable` and call `restoreConsumedSuspension` on the run the report names. +- 4ef8247: fix(approvals): the dead-run sweep classifies every `ExecutionStatus` member, so a `refused` run releases its pending approval (#16433) + + `ApprovalService.releaseDeadRunRequests` guarded on a hand-copied four-member subset of `ExecutionStatus` — `completed`, `failed`, `cancelled`, `timed_out` — written when that enum had eight members. #14945 then appended `refused`, documented on the enum as *"Terminal, never resumed"*, and the subset did not grow with it. A run in `refused` was therefore skipped by the sweep, so a still-pending approval on it read as ALIVE, was never released, and kept its record lock forever. + + **Why this is shipped as a fix rather than left alone.** Nothing inside this repo drives a run to `refused` yet — that is #15788 (lane 2 of the #14945 ruling), still open. But `ApprovalService` takes a HOST-supplied automation surface through `attachAutomation`, so a host whose `getRun` already answers with the status the published spec declares sees the corrected behaviour the moment it upgrades, rather than on the day lane 2 lands. That is a real behaviour change in a published package, which is why it carries a bump instead of `skip-changeset`. + + The repair is not "add `refused`" — that yields a five-member hand-copy with the identical trap re-armed for the tenth member — and it is not "derive the terminal set from the enum" either, since `running` and `paused` are plainly not terminal and a wholesale derivation would default every future member to terminal, i.e. to releasing approvals out from under LIVE runs. Instead the file now declares a **total map** over `ExecutionStatus`, classifying each member `terminal` or `live`, from which the terminal set is derived. A tenth member fails to compile until someone classifies it, and fails a test as well. + + No API change: the classification is module-internal and the package barrel is untouched. +- 9540590: `restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit + + `resumeInternal`'s catch arm journalled the consumed suspension of the run that + threw, and nothing else. For a nested run the ancestors were handled on both + paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` + and calls `failSuspendedRun` on each suspended ancestor) and delegation (the + parent frame sees a failed child with no retryable code and calls + `failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, + 'failed')` plus a `failed` log record — it journalled nothing. + + So the leaf was restorable while every ancestor was recorded `failed` with its + pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered + `NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent + that never continues: `bubbleToParent` found no parent suspension and logged. + The operator ended up worse off than before using the exit. + + `failSuspendedRun` now journals the pause it consumes whenever the descendant + whose failure consumed it is itself repairable — from the same single producer + and onto the same durable terminal row as the strand's own snapshot, so the + chain is repairable from any replica and after a restart, not only from the + process that stranded it. `restoreConsumedSuspension` then repairs the chain as + one unit: it walks down to the stranded descendant and up through the ancestors + it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes + resumable only after the run it is parked awaiting is parked again. The entry + point does not matter — naming any member of the chain repairs all of it — and + the continuation is then re-issued once, on the run that was named. + + Additive on the wire and in the type: the result's existing fields still + describe the run the caller named, and the new `chain` key is present only when + the repair was a chain repair. `ChainRestoreEntry` is exported for it. The + narrower `IAutomationService.restoreConsumedSuspension` contract in + `@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the + door answers `{ runId, restored, reason }` as it always did. + + Every member goes through the same per-run call as a flat restore — its own + in-process claim, its own strict live-suspension read, its own two-witness read, + its own durable park — so idempotence and the #14333 advance claim hold per run + in the chain: a second restore finds every member parked and answers + `RUN_SUSPENDED` without minting a second pause anywhere. + + ⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run + that consumed its OWN pause and then threw downstream, and nothing re-arms an + ancestor by resuming it; stamping it would send an operator to retry a recovery + that cannot succeed. The parent frame's delegation result still carries no + status at all, and an ancestor's repairability is carried by the journal and by + this verb's answer. + + Journalling is EARNED, not applied to every cascade: an ancestor whose + descendant is beyond repair is still consumed without a snapshot, because + re-arming it would promise a chain repair that could not be completed. + + **`@objectstack/plugin-approvals`** reports the consequence rather than causing + it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed + ancestor whose descendant is repairable now comes back `runState: + 'repairable'` instead of `'unrepairable'`, and restoring either row repairs the + pair. `'unrepairable'` keeps its other causes — a run that never paused, a + snapshot no longer held, and a cascade whose descendant was itself beyond + repair. No plugin logic changed; the docblocks that documented the old + limitation did. +- 6465cc0: Correct the `resolveRecordedContinuation` discriminator's stated invariant in + `approval-service.ts` to what was measured. The comment claimed the + `action: 'resubmit'` audit row was "at most one per request"; a `resubmit` whose + own resume strands opens no next round, so the row stays `returned` and a second + `resubmit` after `restoreConsumedSuspension` lands a second such row. The + comment now records that more than one row can exist, states why the read is + correct anyway (it is a presence check with `limit: 1`, deciding identically on + one row or two), and points at the pin that measured it. + + Prose only — no behaviour change, no door narrowed, no guard touched. The audit + trail's one-row-per-advancement shape is accepted residue; requiring one row per + advancement is a separate change. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index b32ced2938..08c0d10d1e 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-approvals", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-audit/CHANGELOG.md b/packages/plugins/plugin-audit/CHANGELOG.md index 90bf92d823..ea417aef16 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,183 @@ # @objectstack/plugin-audit +## 17.5.0 + +### Patch Changes + +- ab48938: A lost audit row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy. + + `reportAuditWriteFailure` — the best-effort catch around `persistAuditTrailRow` — deduped on a single process-wide boolean. After the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process, so a long-running server could keep losing compliance rows for hours to a second, unrelated fault with one `error` line at the top of the log describing the first. `persistAuditTrailRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`. + + The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object being audited. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per failed write. Keyed on the code, the reported-cause set is bounded by the boot-declared object registry and the driver's code vocabulary and does not grow with traffic — measured at 65 lines for 6,500 failed writes and the same 65 for 26,000. + + The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for both tables this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working. + + `@objectstack/types` is added as a dependency for that predicate, rather than hand-rolling a second driver-error vocabulary. +- 8d4690b: fix(plugin-audit): record-view rows keep the VIEW instant instead of the buffer-drain instant (#16829) + + `sys_audit_log`'s `record_views` rows answer "when did this user look at this record?". Read auditing batches its INSERTs off the request path by design, so `buildRow` writes `created_at: event.viewedAt` rather than letting the column's `NOW()` default stamp a whole batch with one flush timestamp — up to `flushIntervalMs` after the fact, with read order inside the window destroyed. + + `persistReadAuditRows` wrote that row under `{ context: { isSystem: true } }`, and the module's comment cited that flag as what carried the view instant through. It never was. `isSystem` exempts a write from the readonly strip; the layer that decides `created_at` on an insert is the audit stamp hook `sys_stamp_audit_insert`, which reads `session.preserveAudit` and has never read `isSystem`. What was actually carrying the value was that hook's pre-#15964 line, `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. #15964 closed that accident (maintainer ruling 2026-09-06), and the ordinary branch has stamped `now` since: on this path, the flush instant. + + The write now declares both context keys, for two different layers: + + ```ts + await engine.insert( + 'sys_audit_log', + rows as any, + { context: { isSystem: true, preserveAudit: true } } as any, + ); + ``` + + `isSystem` still carries the readonly-strip exemption the row needs; `preserveAudit` is the one the stamp hook reads. `preserveAudit` is the ruled historical-import channel (#3493, reaffirmed by #15964's ruling) — the door audit left open for reinstating an original timeline — and a view row's original timeline is the moment of the view, so this use is inside its declared purpose rather than a bypass of it. + + **What changes for a deployment.** Only for deployments that opted objects in to record-view auditing (`AuditPlugin`'s `readAudit.objects`). Rows written from now on carry the view instant. ⛔ Rows already written under the flattened behaviour are not repaired by this change: their `created_at` is the drain time of the batch they were in, and the view instant they should have carried was never persisted anywhere else, so it cannot be recovered. Only builds cut from `main` after #15964 are affected — the objectql half has not shipped in a published version. + + **No exported symbol, schema, route or config key moves.** The only observable change is that a `created_at` this writer already intended to write now survives. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 41a6242dd9..04040691d5 100644 --- a/packages/plugins/plugin-audit/package.json +++ b/packages/plugins/plugin-audit/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-audit", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Audit Plugin for ObjectStack — System audit log object and audit trail", "main": "dist/index.js", diff --git a/packages/plugins/plugin-auth/CHANGELOG.md b/packages/plugins/plugin-auth/CHANGELOG.md index 774154c204..cea97d6738 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,618 @@ # Changelog +## 17.5.0 + +### Minor Changes + +- ee6fbd7: fix(plugin-auth): give the auth `basePath` default a single written definition (#16384) + + `'/api/v1/auth'`, the shipped default for `AuthPlugin`'s `basePath` option, was + written independently at four sites: the `AuthPlugin` constructor, two later + re-derivations inside `AuthPlugin` (`registerAuthRoutes`, the OIDC discovery + `.well-known` alias), and `AuthManager.configuredBasePath()`'s own fallback. + Nothing was broken by the duplication — `AuthPlugin` always supplies `basePath` + to `AuthManager`, so the manager's copy was dead on the live path and + unfalsifiable by construction: no test could have caught one copy drifting from + the other three. + + The default now lives in exactly one place, `DEFAULT_AUTH_BASE_PATH` (exported + from `@objectstack/plugin-auth`, declared beside `readMcpServerEnabledEnv` in + `auth-manager.ts`); all four sites import it instead of retyping the literal. + Every site evaluates byte-identically to before — this is a consolidation of + where the value is *written*, not a change to what any site *evaluates to*, and + in particular does **not** touch `AuthManager`'s `configuredBasePath` → + `rootedBasePath` → `getBasePath` normalisation chain (#16399) or the published + OAuth `iss` / RFC 8707 `aud` identifiers those getters produce. + + This is additive and non-breaking — no existing call site's behaviour changes — + but it does add one new named export (`DEFAULT_AUTH_BASE_PATH`) to the + package's public surface, which is what makes this `minor` rather than `patch`. +- 344d475: fix(plugin-auth)!: `POST /admin/create-user` reads the deployment's membership policy instead of hard-coding `auto` (#16683) + + **BREAKING** — the membership this published endpoint writes moves for existing inputs on `invite-only` deployments. The route, its request body, its response fields and every exported signature are byte-identical; what changes is what an existing call does on a deployment that declared a non-default policy, stated as a FROM/TO pair below. + + ADR-0093 D1 makes the deployment's `membershipPolicy` the one answer to "does this new account get an organization membership", and enumerates the `invite-only` flows as a closed set — "which endpoint created the user" is explicitly not a determinant. The `user.create.after` reconciler and the D6 backfill both read it through `AuthManager.getMembershipPolicy()`. This endpoint did not: its belt-and-suspenders bind handed the reconciler a literal `'auto'`, so it was the one membership-writing path in the product that ignored the setting. + + FROM: on a deployment declaring `membershipPolicy: 'invite-only'`, an account created through `POST /api/v1/auth/admin/create-user` was bound to the default organization anyway, and the 200 response answered `membershipCreated: true`. The `user.create.after` reconciler had already declined to bind it; this endpoint bound it afterwards. + + TO: the same call creates the account and binds no membership. The response answers `membershipCreated: false` and omits `organizationId`, and the audit row records the same. The account is created and can sign in — `invite-only` withholds the membership, not the login. + + Who is affected: only deployments that set `auth.membership_policy` (or `OS_AUTH_MEMBERSHIP_POLICY`) to `invite-only`. Under the default `auto` posture behaviour is unchanged in every observable respect — response body, `sys_member` write and audit metadata — and that equivalence is pinned by a test rather than asserted here. + + If you relied on admin-created accounts acquiring a membership on an `invite-only` deployment, the supported way to keep it is to bind the membership explicitly (the `add_member` action / `POST /organization/add-member`), which is what `invite-only` means: memberships are granted deliberately, never as a side effect of account creation. Setting the deployment back to `auto` restores the old behaviour for every path at once, including sign-up. + + The direction of the old defect was open, not closed: it GRANTED a membership the operator had configured the platform to withhold, and reported success while doing it. An operator who set `invite-only` specifically to keep a shared organization identity off their users got one anyway. + + +- 374d9d3: **BREAKING** — `GET /api/v1/auth/get-session` answers an anonymous caller with the + declared ADR-0112 failure envelope and HTTP 401, instead of HTTP 200 wrapping a JSON `null`. + + Until now an unauthenticated session read answered: + + ``` + HTTP 200 + null + ``` + + `ObjectStackClient.auth.me()` declares `Promise`, and + `SessionResponseSchema` requires `data.session` and `data.user` — so no value of that type + means "nobody is signed in", and the most ordinary call a logged-out caller can make + resolved to something outside the method's own declared type. Ruled by the director seat + (decision batch #117 item 4) under the charter rule + 「spec 与代码不一致默认改代码,改协议单独立卡非选项」: the implementation is corrected to + the published contract. `SessionResponseSchema` is untouched. + + What changes on the wire: + + - **An anonymous or unresolvable credential ⇒ `401` with `error.code: 'UNAUTHENTICATED'`** + and the message `Sign in first`, the same body a raw `/admin/` mount already answers the + same caller with. No error code is minted: `UNAUTHENTICATED` is an existing + `StandardErrorCode` member, derived from the status through ADR-0112's own map, so + `ERROR_CODE_LEDGER` is unchanged. + - **Unchanged:** a signed-in read still answers `200` with `{ user, session }`, + byte-identical. Every other `/auth/*` route is untouched, and so is the `404` that a + method this route does not serve already answered — this change never invents a route. + - **Also unchanged:** better-auth's JS API. `auth.api.getSession()` still returns `null` for + an anonymous caller, so every internal identity read — execution-context resolution, the + platform-admin gates, the SSO bridges — behaves exactly as before. Only the wire moves. + + **`@objectstack/client`:** `client.auth.me()` now **rejects** for an anonymous caller + instead of resolving with `null` — the SDK throws on every non-2xx before unwrapping. Every + value the method resolves with is now inside its declared `SessionResponse`. Callers that + inspected the resolved value must move to a `catch`: + + ```ts + try { + const session = await client.auth.me(); + // …signed in + } catch (err: any) { + if (err.code === 'UNAUTHENTICATED') { + // …signed out; err.httpStatus is 401 + } + } + ``` + + A caller that branches on the HTTP status directly reads `401` plus + `error.code: 'UNAUTHENTICATED'` where it used to read `200` plus an empty body. + + +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- e758131: fix(plugin-auth): a `single`-posture deployment holding more than one organization is reported at `error` instead of booting silently (#17010) + + ADR-0131 §1.2(3) states that its precondition — many organizations with the organization wall inert — 「is today a refused boot」. It is not. A deployment that never REQUESTS a walled posture and simply HOLDS more than one `sys_organization` row under `single` boots, serves, and says nothing: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently. The harm then surfaces far away and looks like an unrelated data outage: users reconciled from then on are bound to no organization, a platform admin reads zero rows of every organization-stamped object while analytics still counts them, and system-context writes are refused `ambiguous-organization` by the per-write guard. + + The tenancy service now takes a `count(sys_organization)` census on that same seam and reports at `error` when a non-walled deployment holds more than one, naming the posture it DECLARED, the count it HOLDS, and the two ways out: declare a walled posture (`OS_TENANCY_POSTURE=group` / `isolated`, plus the `@objectstack/organizations` package that activates it), or hold one organization and model the sub-units as business units. + + **The boot is not refused.** This change only reports; whether the boot should instead be refused stays open for the maintainer, and nothing here has to be undone if that is the answer. The per-write `ambiguous-organization` refusal is untouched. + + Cost is one `count()` per process: the census sits downstream of the walled-posture early return (a `group`/`isolated` deployment pays nothing and says nothing) and downstream of the memoized resolution, and an engine that cannot answer stays silent rather than guessing. A healthy install — exactly one organization, or none bootstrapped yet — is silent by construction. +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. + +### Patch Changes + +- 4f1a56b: `sys_user.manager_id` gains an admin write surface: `POST /api/v1/auth/admin/set-user-manager` + + `{ type: 'manager' }` is the canonical first rung of a tiered approval ladder, + and it resolves `sys_user.manager_id` — a column **no product surface could + write**. Measured: the generic data path refuses it (the ADR-0092 D2 + managed-update whitelist for `sys_user` is `{name, image, locale}`), the admin + bulk import does not carry it (`admin-import-users.ts` matches `manager_id` 0 + times, against a control of `phone_number` 8), and the column is `readonly` on + the user form. So on any install without a directory sync the rung expanded to + nobody, the request opened on a slate no one could act on, and under the + default `lockRecord: true` the record stayed locked. + + **The endpoint.** A platform admin posts `{ userId, managerId }`; `managerId: + null` clears the link. It is an ObjectStack mount on the raw app ahead of the + better-auth catch-all — the same family as `POST /api/v1/auth/admin/unlock-user` + — platform-admin gated (ADR-0068) and ledgered in `auth-route-ledger.ts`. + + **It is not a new editable profile column, and that is the design.** The + handler runs under a **system context**, so it reaches the column by context + rather than by a whitelist entry — the same way `admin-import-users` already + reaches `phone_number` and `role`. `SYS_USER_PROFILE_EDIT_FIELDS` is + untouched, `MANAGED_EXTENSION_EDITABLE_FIELDS.sys_user` stays `{locale}`, and + `sys_user.manager_id` keeps `readonly: true`, so ADR-0092 D4 still holds by + construction. Since ADR-0092 D5's amendment made Tier-1 membership imply + self-editability, admitting the column to Tier 1 would have handed every member + their own first-rung approver and a widening of their own `own_and_reports` + read scope; it is not admitted. + + **Five refusals, every one enforced at the write** — the only manager-chain + walkers in the open tree are single-hop, so nothing downstream catches a bad + link: self-assignment; a link that closes a cycle (the walk is itself + cycle-safe, so a pre-existing loop is reported rather than hung on); a chain + past the depth cap that ADR-0057 D3's bounded rollups require; a manager + provably outside every organization the user belongs to (beside, not instead + of, the existing routing-time screen); and any identity whose `sys_user.source` + is `idp_provisioned`, where the directory stays the one authoring surface. + + **`@objectstack/lint`** keeps the `approval-approvers-may-resolve-empty` + advisory and its `stackWiresManagerChain` silencer — the dead end it reports + survives the write surface, because a static check still cannot read the + column; only its *cause* became recoverable. What changed is the remedy text, + which named a column with no route and now names the endpoint, its body, how to + clear the link, and what it refuses. The Approvals guide carries the same + rewrite in prose. + + **Why `patch` and not `minor`.** No new exported symbol is reachable from + either published entry: `admin-set-user-manager.ts` is deliberately not + re-exported from `plugin-auth/src/index.ts` and is not named in the package's + `exports` map, so none of `runSetUserManager`, `MAX_MANAGER_CHAIN_DEPTH`, + `SetUserManagerDeps`, `SetUserManagerEngine`, `SetUserManagerResult` or + `SetUserManagerRefusalReason` appears in the built `dist/index.d.ts`. No + already-published payload gains a key — the endpoint's response is a new + payload, not a new field on an old one. A new **route** is wire, and wire + compatibility is not the grading floor. +- c9246fa: fix(plugin-auth): `/sign-in/email` and `/sign-up/email` now attach the `session` their declared `SessionResponse` envelope requires (#17234) + + Both routes answered `{ token, user }` (`/sign-in/email` also carries + `redirect`) with no `session` member anywhere in the body or the response + headers, so `SessionResponseSchema.safeParse` on `auth.login()` / `auth.register()`'s + return value always reported a `data.session` issue — the second of two + departures measured on #17234 (`success` was closed in the previous round). + + **The fix is a read, never an invention.** better-auth stores sessions in the + database by default and `internalAdapter.createSession` is awaited to + completion — including the write — before either endpoint returns its + `{ token, user }` body (measured against the installed `better-auth@1.7.3`, + `dist/db/internal-adapter.mjs:247-319`). So the row the response's own `token` + names is already committed by the time this repo's global `after` hook runs. + The fix reads it back through `internalAdapter.findSession(token)` — the exact + seam `/get-session` already uses for `data.session` — and attaches it. No id or + expiry is ever fabricated; a read that fails for any reason (no + `internalAdapter`, no row, any error) leaves the response exactly as + better-auth wrote it. + + ``` + FROM POST /api/v1/auth/sign-in/email -> 200 { redirect, token, user } + TO POST /api/v1/auth/sign-in/email -> 200 { redirect, token, user, session } + + FROM POST /api/v1/auth/sign-up/email -> 200 { token, user } + TO POST /api/v1/auth/sign-up/email -> 200 { token, user, session } + ``` + + `session` is the SAME row a following `/get-session` call reads (same `id`, + same `expiresAt`, same `userId`) — one row read twice, not two arrangements — + and `session.token` is the same UNSIGNED credential the body already carried + at `token` / `data.token`, not a second credential this fix introduces. + + ⛔ **No wire byte moves on any other member.** `token`, `user`, `redirect` are + byte-identical; `data.token` and the client's auto-`this.token = data.token` + are unchanged and pinned. `auth.me()` / `auth.refreshToken()` (`/get-session`, + #16760) are untouched — this change is scoped to the two credential-issuing + routes. + + This is additive on an already-declared field — `SessionResponseSchema.data.session` + existed in `@objectstack/spec` before this card; the two routes simply did not + serve it. No schema changes, no new exported symbol, no new key on any + published payload. +- efa2533: fix(plugin-auth): build ONE better-auth instance per boot, so the RFC 8707 resource row is seeded once (#17176) + + `AuthManager.getOrCreateAuth()` assigned its `this.auth` memo only after `createAuthInstance()` had resolved, and that function awaits a dynamic `import('better-auth')`, the plugin list, the password hasher and finally better-auth's own `$context`. Every caller arriving inside that window read `this.auth === null` and started its own build, so overlapping callers constructed one better-auth instance each — measured: three concurrent `getAuthInstance()` calls returned three distinct instances. + + The boot has such callers. `AuthPlugin` dispatches `registerOidcDiscoveryRoutes()` with `void` from its route-mounting `kernel:ready` hook, which returns while that call is still pending, and a later `kernel:ready` hook reads the instantiated social providers off the instance for the account-issuer backfill. + + Each duplicate instance re-runs every better-auth plugin's `init`, and `@better-auth/oauth-provider` seeds the RFC 8707 `sys_oauth_resource` row from there. Its seed is already check-then-insert — `findOne` by `identifier`, then `create` only on a miss — so on a warm database every instance finds the row and inserts nothing. On a FRESH one all of them miss together, all of them insert, and the unique index refuses all but the first: the `Insert operation failed {object: sys_oauth_resource}` line on the first boot of a fresh project. + + `getOrCreateAuth()` now holds the in-flight build so concurrent callers share it. The seed runs once per process on every driver, because there is only one plugin `init` to run it. Two consequences of the new in-flight slot: `setRuntimeBaseUrl()` now reports "already created" for a build in flight (it silently no-opped before), and `applyConfigPatch()` discards a build composed from the pre-patch configuration instead of letting it install itself. + + No log level changed, in this package or any other. +- 2c87a48: Gate the `no_sign_in_account_at_boot` boot report on whether the deployment has a delegated sign-in path. + + The report fires on one store shape — human `sys_user` rows, zero `sys_account` rows — and calls it unrecoverable. On a deployment whose sign-in is delegated to an identity provider that shape is the healthy resting state: `ssoOnlyMode` states it in the auth config contract ("managed (IdP-provisioned) users simply hold no local credential") and names cloud-as-IdP. Such a kernel logged the report at `error` on every boot, including boots that had just served a successful SSO sign-in. + + The report now also reads the runtime's sign-in wiring — SSO-only mode declared, a configured social/OIDC provider, or enterprise SSO with at least one registered `sys_sso_provider` — and stays silent at `error` when one of them holds, recording the shape at `debug` under the same grep token with the reason named. + + Unchanged: `probeSignInAccountsPresence` keeps its existence-only predicate, and a deployment with no delegated sign-in path — including one that merely switched the SSO plugin on with no identity provider registered — still reports at `error`. +- dd2fd20: fix(plugin-auth): one base-path normalisation chain, and an MCP resource identifier that is always a URL + + `AuthManager` derived its base path in three independent places. `getMcpResourceUrl()` + read `this.config.basePath` directly and added no leading slash, so a `basePath` + configured without one produced a value that is not a URL at all: + + basePath 'api/v1/auth' -> http://localhost:3000api/v1/mcp + + `new URL()` throws on that (`3000api` is not a port), so the RFC 9728 path-inserted + well-known route derived from it throws too, and `@better-auth/oauth-provider` 1.7.2 + refuses to seed the `sys_oauth_resource` row from it at plugin init ("resource + identifier ... must be an absolute URI (RFC 8707 §2)"). With + `enforcePerClientResources` at its `true` default, every MCP client was then refused + for want of a link row. That input class could never mint or match a token, so + repairing it re-selects nothing. + + There is now exactly one read of the configured value and one chain above it: + + configuredBasePath() the configured value VERBATIM — what better-auth is handed + └─ rootedBasePath() + a leading slash when absent (better-auth's own rule) + ├─ getAuthIssuer() = origin + this + └─ getBasePath() = this, trailing slashes stripped + └─ getMcpResourceUrl() = origin + this minus `/auth` + `/mcp` + + `getAuthIssuer()` and `getBasePath()` answer byte-identically to before for every + spelling. Only `getMcpResourceUrl()` moves, and only for a non-canonical `basePath`: + a missing leading slash (was not a URL), repeated trailing slashes, or a configured + `/` (was a `//mcp` path no mount serves). A canonical `basePath` is unchanged on all + three getters. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- 96684bb: fix(plugin-auth): let `ImportProtocolLike` type the admin import protocol's members (#17422) + + `admin-import-users.ts` is the only hand-written in-repo implementor of the runner's `ImportProtocolLike`, and it annotated all three required members `args: any`. An explicit parameter annotation wins over the contextual type, so #16952's newly declared request dialect held every implementor except this one — the one with a demonstrated history: before #16950 this file read `args?.query?.$filter ?? {}`, the runner moved to the canonical spelling, the read went `undefined`, and the `?? {}` default degraded the import's duplicate probe into match-everything, so `POST /api/v1/auth/admin/import-users` updated the wrong users without a sound. + + The three annotations are deleted, so `findData` / `createData` / `updateData` are typed by the contract they implement. Measured: with the annotations gone, reading a retired wire alias (`args.query?.$filter`) is `TS2339 Property '$filter' does not exist on type 'QueryInput'`; with `args: any` restored the identical probe type-checks at exit 0. + + `FindDataRequest` declares `query` optional, so `findData` now states its refusal in code — a thrown `Error` carrying the already-registered `INVALID_REQUEST` code — instead of relying on an incidental `TypeError` from a property read on `undefined`. No `??` fallback and no optional chaining were added: both spell match-everything, which is the defect this closes. + + No API, request body, response shape or exported signature changes. A caller that reaches `findData` through `runImport` always supplies `query`, so no supported call moves; only a protocol call that was already failing now fails with a code attached. +- 45c2cf9: MCP OAuth: refuse a `client_credentials` (machine-to-machine) access token + + `AuthManager.verifyMcpAccessToken` resolved an M2M access token to a + principal — a machine ran as an authenticated member, stamping a user id that + belongs to no user into `created_by` / `updated_by` and owner columns — while + the method's own contract declared such tokens rejected. The contract's + premise was that they carry no `sub`; the OAuth provider stamps + `sub = user?.id ?? client.clientId`, so the premise was never true and the + rejection it described could never fire. + + The subject and the client identity are now read as a pair, the way RFC 9068 + defines them for a JWT access token: `client_id` is REQUIRED (§2.2), and `sub` + is the resource owner for a grant that had one or an identifier for the client + application for a grant that did not (§2.2.3.1). A token whose `sub` equals its + own `client_id` / `azp` therefore assembles no principal, and the MCP HTTP door + answers `401`. A token carrying neither client claim is refused as well: the + check has no input, and a check that cannot run must not silently pass. + + Unchanged: interactive OAuth clients (authorization code + PKCE) resolve + exactly as before, and the headless track is untouched — `x-api-key` / + `Bearer osk_…` over HTTP and `OS_MCP_STDIO_API_KEY` over stdio are a separate + chain with a separate credential shape, and remain the supported way for a + machine to call this platform. +- 9ca49eb: `runAdminImportUsers`'s hand-written `ImportProtocolLike` reads the CANONICAL QueryAST (`where` / `limit`) — the payload `@objectstack/rest`'s import runner sends as of this same release — instead of the wire-only `$filter` / `$top`. + + `POST /api/v1/auth/admin/import-users` reuses the shared import runner but swaps in an identity-specific protocol, because an identity write is `auth.api.createUser` and not an engine insert. That protocol is hand-written, so it never passes through `ObjectStackProtocolImplementation` — the normalizer that folds `$filter` onto `where` and `$top` onto `limit` for a caller arriving off the HTTP door. It has to read the canonical keys itself. + + - **A mismatch here does not produce a missing filter, it produces an unbounded one.** `const where = args?.query?.$filter ?? {}` turns an unread key into an empty filter, and an empty filter constrains nothing: the upsert duplicate probe stops discriminating, `findExisting` matches rows it was given no key for, and an admin import updates the WRONG user. Both halves are measured in `admin-import-users.test.ts` — the email-match case reported `updated: 2` where one of the two rows was new, and the phone-match case sent a probe carrying no `where` at all. + - **One dialect, and no default behind it.** The two reads are now `args.query.where` and `args.query.limit`, with no `??`. A default here would not be tolerance for an older caller — this handle is fed by the runner, never off the wire — it is precisely the lenient fallback that converts a spelling mismatch into a silent match-everything. A request that arrives without a `query` now costs a loud `TypeError` instead. + + ⚠️ No published version shipped the mismatch. The runner's rewrite and this adapter land in the same release, and `@objectstack/plugin-auth` depends on `@objectstack/rest` at an exact workspace version, so the two cannot be installed apart. What this entry records is why they move together — and what the same mismatch costs any OTHER hand-written `ImportProtocolLike`, which the `@objectstack/rest` entry calls out for implementors. +- ab1c585: `POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. + + On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. + + `two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. + + - **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. + - **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. + - **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. + + `@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [a370073] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [690f083] +- Updated dependencies [e7fea46] +- Updated dependencies [a9096af] +- Updated dependencies [4be4e04] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [dfb42c5] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-messaging@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index cea37b9e32..e7162432d7 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-auth", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Authentication & Identity Plugin for ObjectStack", "main": "dist/index.js", diff --git a/packages/plugins/plugin-dev/CHANGELOG.md b/packages/plugins/plugin-dev/CHANGELOG.md index bd54f17513..436594fd9f 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,220 @@ # @objectstack/plugin-dev +## 17.5.0 + +### Patch Changes + +- 50bc9c7: Operator-facing text no longer tells an open-source install that multi-organization + operation requires a subscription. + + ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` + is Apache-2.0, carries no licence check, and declares both walled postures (`group` and + `isolated`) as its own constant. The messages an operator actually reads had not followed: + + - `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and + is NOT on the public npm registry ... Without one this bullet is not followable" — it now + says the runtime is Apache-2.0 and on the public registry, and notes that a commercial + deployment resolves the same package name to its own private, licence-gated build. + - The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the + runtime "enterprise". + - `os verify`'s `--org-scoped` flag description drops the same word. + - The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe + the package as the enterprise runtime. + + Text only — no control flow, no identifiers, no behaviour change. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [ee6fbd7] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [89a652b] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [4af758d] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [c9246fa] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [ea4d164] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [2266438] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [0ced0aa] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [6ff5b56] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/account@17.5.0 + - @objectstack/setup@17.5.0 + - @objectstack/service-realtime@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 4e9a8ac721..bcf30e3f00 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-dev", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development", "main": "dist/index.js", diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md index 6a7fa513b6..bff5e99015 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/plugin-email +## 17.5.0 + +### Patch Changes + +- ca31ff6: Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. + + The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. + + Two published packages change what a downstream install resolves, which is what this changeset grades: + + - **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. + - **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). + + No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. + + The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). + + `hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 8ff131ba9c..8916bed793 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-email", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Email service plugin for ObjectStack — IEmailService + transport-pluggable outbound delivery with sys_email persistence.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-hono-server/CHANGELOG.md b/packages/plugins/plugin-hono-server/CHANGELOG.md index 5afdbf01cb..bbd8bd1842 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,247 @@ # @objectstack/plugin-hono-server +## 17.5.0 + +### Minor Changes + +- 89a652b: feat(cli): `objectstack dev --cert --key ` terminates TLS in the dev process, and the canonical origin follows the listener (#16804) + + An interactive MCP client refuses to start an OAuth sign-in against a non-TLS + URL, so the self-serve identity path the product advertises — "interactive + clients just open a browser login" — could not be exercised against a local dev + server at all. The only way round it was a hand-built https reverse proxy plus + `OS_AUTH_URL`, a page of setup that every developer, demo and video recording + repeated off-camera. + + **Bring your own certificate.** Nothing here generates one, and nothing here — + not the code, not `--help`, not any doc page — says anything about installing a + certificate into a system trust store. 「⛔ 不生成自签 CA;⛔ 不打印、不文档化任何 + 「把 CA 装进系统信任库」的指引——信任库是开发者自己的事」. The trust store is the + developer's own business; this feature's whole job is to *use* the certificate + they already have. + + ```bash + objectstack dev --cert ./localhost.pem --key ./localhost-key.pem + ``` + + Both flags are required together — half a pair is refused by name — and an + unreadable file is refused rather than degraded to a plain-http listener. + + **What follows the listener.** With both flags given, everything this boot + advertises is `https://localhost:`: the two `/.well-known/*` discovery + documents, the CSRF allow-list, the ready banner's `API:` / `MCP:` rows, the + `🤖 MCP server` connect hint, and the runtime state file the `os dev` parent and + external supervisors dial. Only the built-in default at the end of the base-URL + chain moves — `OS_AUTH_URL`, `BETTER_AUTH_URL` and `OS_BASE_URL` keep winning, + an `http://` value included, because they name where a deployment is *reached* + rather than what this process *bound*. + + **Without the flags nothing changes**, byte for byte — pinned by ablation legs + rather than asserted. + + `@objectstack/plugin-hono-server` gains the option this is built on: + `HonoPluginOptions.tls` (`{ cert, key }` PEM bytes) makes the adapter bind a TLS + listener with the same fetch handler, the same route table and the same graceful + drain. Absent, the listener is plain http exactly as before. +- cefe068: fix(plugin-hono-server): an escaped throw that declares an ADR-0112 envelope is answered as that envelope, not as a bare `500 INTERNAL_ERROR "No response from handler"` (#16545) + + `HonoHttpServer.wrap()` is the seam **every direct-mount route passes** — `get` / + `post` / `put` / `delete` / `patch` each register `this.wrap(handler)`, and + `IHttpServer` is how `service-datasource`, `packages/rest` and the dispatcher + bridge all mount. Until now a throw that escaped a route handler was answered + there as `500 { code: 'INTERNAL_ERROR', message: 'No response from handler' }`, + with the thrown value discarded — so a producer that had *declared* its refusal + lost both halves of the declaration on the way to the caller. + + The measured case: `service-datasource`'s `requireDatasourceAdmin` re-raises + `AuthzStoreUnavailableError` (declared `status: 503`, declared `code: + SERVICE_UNAVAILABLE`) when the authorization store cannot be read — deliberately, + per the #13279 ruling that an unreadable store licenses no verdict. The operator's + outage reached the caller as a generic fault naming the wrong component: the + declared code never arrived, and the message said "No response from handler". + + **What changed.** An escaped throw carrying **both** a declared ADR-0112 status + (a key of `HttpStatusErrorCodeMap`) **and** a code registered in `ErrorCode` + (`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) is now rendered as that envelope, + with the producer's `details` and `userMessage` channels forwarded. The status + and code are read through `resolveThrownHttpError` — the one rule the REST + registrar and the dispatcher already share — so this seam agrees with the other + doors by construction rather than by a second ladder. + + **What did NOT change**, pinned in the same PR: + + - an escaped throw that is **not** such an envelope answers exactly the bytes it + answered before — 500, no cause in the body. A partial declaration (status but + no code, code but no status), an unregistered code, and a status ADR-0112 does + not declare all take that arm; + - a handler that simply wrote nothing is untouched; + - a handler that **wrote and then threw** keeps what it wrote; + - the `notFound` fallback seam still answers `Fallback handler failed` — a + fallback that threw is a broken consumer, not a refusal it declared; + - ⛔ no error code is minted and no ledger row is added. A code on this path that + is not registered is a ledger gap under the #16404 ruling, and takes the + unchanged 500 arm rather than being registered in passing. + + The 5xx disclosure filter every door emitting a thrown message already runs + (`looksLikeInternalErrorLeak`, #3867 / #8086) is applied here from this seam's + first day: a driver dump on a declared 5xx is withheld, where the old bare 500 + disclosed nothing at all. The escaped-throw diagnosis (#5848) still fires exactly + once at `error`, and now names the answer that was really sent instead of + claiming an opaque 500. + + ⚠️ **Known-unreached door, stated rather than left silent.** A route mounted + through `getRawApp()` funnels through neither `wrap()` nor any registrar wrapper, + so it is **not** repaired by this change and still answers a non-envelope + `text/plain` 500. That is out of this card's scope by the `domain:cli` seat's + ruling and is filed separately. + +### Patch Changes + +- ca31ff6: Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. + + The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. + + Two published packages change what a downstream install resolves, which is what this changeset grades: + + - **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. + - **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). + + No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. + + The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). + + `hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. +- 0ced0aa: **`getRawApp()` mounts now answer an escaped throw with the declared ADR-0112 envelope.** A route mounted on the Hono handle funnels through neither the adapter's `wrap()` nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler — `500 text/plain "Internal Server Error"`, no `success` flag, no `code`, and the thrown value's own declared `status` / `code` discarded. A transport error seam on the raw handle now renders the same throw-to-envelope rule a direct-mount route already used, so both doors answer one shape: a throw declaring `503` / `SERVICE_UNAVAILABLE` answers `503 application/json` with `{"success":false,"error":{"code":"SERVICE_UNAVAILABLE",…}}`, and a throw declaring no envelope still answers `500` with no cause in the body. + + The escape hatch is unchanged: consumers still mount framework-natively, still stay outside `getMountedRoutes()`, and still need no adapter verb. A thrown value carrying its own `Response` (Hono's `HTTPException`) keeps the response it declared. A consumer that installs its own `getRawApp().onError(...)` replaces the seam. + + Also fixed alongside it: `afterResponse` observers — and therefore `http_requests_total{status}` — reported a hard-coded `500` for any request that ended in a throw, which stops being the status actually sent once a declared envelope is rendered. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 9f0383e009..d43108da7e 100644 --- a/packages/plugins/plugin-hono-server/package.json +++ b/packages/plugins/plugin-hono-server/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-hono-server", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Standard Hono Server Adapter for ObjectStack Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-pinyin-search/CHANGELOG.md b/packages/plugins/plugin-pinyin-search/CHANGELOG.md index 5535204f49..a391f161bc 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,46 @@ # @objectstack/plugin-pinyin-search +## 17.5.0 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [fe71032] +- Updated dependencies [baf9745] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [ada2869] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [4c42fd1] +- Updated dependencies [0da638c] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [2bed4c3] +- Updated dependencies [71629a1] +- Updated dependencies [d2c1d19] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [a016f08] +- Updated dependencies [6e3462d] +- Updated dependencies [0f38ab0] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5a95b0e] +- Updated dependencies [07150b3] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [8c9bd8f] + - @objectstack/types@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index a925269ae8..90f6a78e4c 100644 --- a/packages/plugins/plugin-pinyin-search/package.json +++ b/packages/plugins/plugin-pinyin-search/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-pinyin-search", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).", "main": "dist/index.js", diff --git a/packages/plugins/plugin-reports/CHANGELOG.md b/packages/plugins/plugin-reports/CHANGELOG.md index ba4c503ddc..2047b40275 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,133 @@ # @objectstack/plugin-reports +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 184649861d..599f0a8cd0 100644 --- a/packages/plugins/plugin-reports/package.json +++ b/packages/plugins/plugin-reports/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-reports", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 02025fa814..ee17bc4c78 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,422 @@ # @objectstack/plugin-security +## 17.5.0 + +### Minor Changes + +- a83dbb6: A package whose `manifest.permissions` carries the ADR-0025 capability grant is now NAMED when the audience-binding reconciler skips it, instead of vanishing; and both halves of the `permissions` key now point at each other in the spec (#18031). + + `permissions` has two incompatible readings and the package registry stores both in the same slot. At the AUTHORING stage `ManifestSchema.permissions` is the capability grant a plugin requests — the legacy flat `string[]`, or the structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). At the ASSEMBLED stage the collection wins and the same key is the ADR-0090 `PermissionSet[]` collection (`AssembledPackageBodySchema`, ADR-0130 D4). `SchemaRegistry.installPackage` records whichever stage its caller handed it. + + - **`collectDeclaredSuggestions` reports the reading it cannot use.** It wants the assembled one. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, so `Array.isArray(manifest.permissions)` was false and the value never entered the loop; every member of the legacy arm is a bare string, so `consider`'s `typeof ps !== 'object'` line dropped all of them. A package declaring the other reading produced no `sys_audience_binding_suggestion` row, no prompt and no log. It now warns once per engine per package and arm, naming which arm it found, what is lost if the author meant permission sets (no admin is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong — the package's own `defineStack({ permissions: [ … ] })`, which is what the assembled body carries. + - **`warn`, not `error`, and deliberately.** Nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reasoning, one step weaker, as the write-refusal report beside it, and the same sink (`SuggestionDeps['logger']`, which declares no `error`). + - **Reported once per engine per package+arm.** The pass runs at boot, after every package-door `permission` publish and on every list call, while a manifest's shape is fixed for as long as that package is installed; an undeduplicated line would repeat on every console page load and be skimmed past. + - **The spec half is declaration text only — no key, export, arm or accept-set moved.** `ManifestSchema.permissions` now says it describes the AUTHORING stage and names the assembled-stage counterpart; the stack collection `permissions` names the manifest-stage grant; and `InstalledPackageSchema.manifest` says it is the authoring STAGE rather than "whatever was stored", pointing at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` for the stage a `defineStack()` host installs. + - ⛔ **The union at the key was NOT widened, and must not be.** Widening a manifest key into a union of both stages is road C of #14242, rejected by name by the maintainer on 2026-09-02 in favour of road B — declare the assembled stage rather than widen the authoring one — because a union at the key makes neither stage checkable (Prime Directive #12). That ruling is why the fix here is a report and a cross-reference rather than a schema change. +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 2266438: Re-run the seed-ownership claim when the seed settles, and report whether each pass was final. + + `claimSeedOwnership` was reached exactly once per database lifetime, on the pass that promotes the first platform admin, while the platform's own seeder was still writing in the background — an app bundle that overruns `OS_INLINE_SEED_BUDGET_MS` (default 8 s) continues past kernel start rather than block it. Registry order and seed order are unrelated, so every object whose rows landed after that walk stayed `owner_id IS NULL` permanently: nothing re-ran the claim. Ownerless rows are invisible to every `readScope: 'own'` grant, and under `public_read` they read fine and answer 403 on every write at `modifyAllRecords: false` — a granted permission that can never be exercised. + + The claim now also runs on `app:seeded`, the published settle signal for that background continuation, against the same admin and with the same predicates — so it moves ownership for exactly the rows the promotion-time pass missed, and never for a row a human already owns. + + Two additive keys support it, both optional: `bootstrapPlatformAdmin` reports `adminUserId` on the promotion path and on the `already_have_admin` short-circuit (so the re-run reads the one existing holder scan instead of a second copy of it), and both `bootstrapPlatformAdmin` and `claimSeedOwnership` accept a `seedSettlement` snapshot read through the `seed-settlement` contract. No existing key, argument or return shape changed. + + Every claim pass now logs one line whether or not it claimed anything, and says whether its reading was final: a pass taken while a seed source is still writing is reported at `warn` as PROVISIONAL. Previously a pass that matched nothing logged nothing at all, so a boot that permanently orphaned rows and a boot with nothing to do produced identical evidence. +- a016f08: fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) + + + + **BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. + + `check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. + + A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: + + - **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. + - **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. + + Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. + + **What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; `update` is unchanged. The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. + + **Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. + + **Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: + + - a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. + - an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. + + **The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. + + ⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- 1c83ca2: The first-boot `already_have_admin` short-circuit now FINDS an existing platform admin instead of sampling for one, so a tenant's organization-admin count can no longer decide whether a second unscoped `admin_full_access` grant is minted. + + Before this change the holders read was `sys_user_permission_set` with **no `orderBy` and a cap of 50**, and the predicate that actually decides — `!organization_id` — was applied **client-side to whatever 50 rows the driver returned first**. `admin_full_access` is not only the platform-admin set: every *organization-scoped* grant of it writes a row carrying the same `permission_set_id`, so this population grows with the number of **org** admins, not platform admins. A tenant with fifty-odd of them filled the window with rows that all fail the filter, the short-circuit did not fire, a **second** unscoped grant was minted, and `claimSeedOwnership` re-owned the seeded business records to the newly promoted user — silently, because the boot logs a successful promotion exactly as on a genuinely fresh install. Measured on the real better-sqlite3 driver: with 60 organization-scoped grants plus one unscoped human grant, the unordered 50-row window contained 50 organization-scoped rows and not the one that decides. + + That is the guarantee #14348 case D pins — 「Moving an already-granted platform admin is reserved to the maintainer.」 — failing open by row count. + + - **The read asks the driver the narrow question first.** `{ permission_set_id, organization_id: null }`, ordered and bounded. Because it is narrowed server-side, no number of organization-scoped grants can crowd the answer out of a window. + - **A second, ordered and bounded leg still applies the exact predicate.** It runs only when the narrow leg found nobody. This is deliberate rather than redundant: `organization_id: ''` is storable and reads back as `''` on both SQL families, which `!organization_id` counts as **unscoped** and `where: { organization_id: null }` does **not** return — so replacing the client-side predicate with the narrowed read alone would have made this guard fire *less* often and mint the very grant this fixes. Both legs are strictly additive to what the old read could see, so the guard can only fire more often than before, never less. + - **The bound is never silent.** The scan pages 200 rows at a time up to a 5000-row ceiling, and reaching that ceiling without finding an unscoped human holder now WARNS — naming the ceiling, the number of rows examined, and the consequence (promoting from here would mint a second unscoped grant and re-own the seeded records). + - **The answer says how many rows it examined.** `bootstrapPlatformAdmin`'s returned report gains an optional `adminGrantRowsExamined`, counted by row identity across both legs, on every return the guard reaches. A guard that had seen the whole population and one that had seen a truncated slice of it previously returned byte-identical payloads. + - **The ordering is stated to the driver, and it is measured, not assumed.** `tryFind` answers `[]` when a query is refused, and on this guard `[]` reads as "no platform admin exists yet" — which promotes. An order this object could not serve would therefore be a silent relaxation, so `id` ascending was measured honoured through ObjectQL on both SQL driver families against the real declarations. + + Unchanged: an unscoped grant held by the seed identity `usr_system` still never counts, so a database where it was wrongly promoted stays self-healing on restart; the walled postures still mint no grant row and still point a legacy unscoped holder at the config path; and a genuinely fresh install still promotes exactly as before. +- 9b9581b: First-boot platform-admin promotion under the `single` posture now CHOOSES its target instead of sampling one: the candidate read is ordered by the database, and an operator who declared an owner gets that owner — and only once that owner has verified the address. + + Before this change the selection read `sys_user` with **no `orderBy` and a cap of 50** and then sorted that array client-side, so "the oldest authenticable user" actually meant *the oldest authenticable user among whatever 50 rows the driver produced first*. Measured on 113 seeded users with the intended owner inserted first, holding the oldest `created_at` and an id that collates last: the in-memory driver returned it in row 1 and promoted it, while the default sqlite driver returned rows in id order, never saw it at all, and handed the unscoped `admin_full_access` grant — plus, through `claimSeedOwnership`, ownership of every seeded business record — to a seeded job-seeker persona. Same code, same config, same data; the answer changed with the storage driver. + + - **The read is ordered where the driver can see it.** `created_at` ascending with `id` as the tie-breaker (seeded populations routinely share one timestamp). There is deliberately no client-side re-sort left behind: one would re-rank the returned page and keep the guard passing if the ordering were ever lost again. + - **The declared owner is asked first, and must be a VERIFIED holder.** `OS_PLATFORM_OWNER_EMAIL` was imported into this file and read only on the walled branch, so a deployment that had said who its owner is could still have someone else promoted. Under `single` the target is now a row that holds a declared address, is human, can authenticate, and has `email_verified === true` — all four. Requiring verification rather than merely preferring it answers the one direction in which honouring the declaration would otherwise have been a widening: because `sys_user.email` is UNIQUE on the SQL family, an attacker who registers the declared address before the operator does would have been promoted with no way for the real owner to coexist, so an unverified holder is refused instead. + - **A declared owner who cannot sign in, or has not verified, REFUSES.** No silent fall-back to whoever happens to be oldest — that is the outcome this fixes. The pass warns, naming the variable, the address and which of the two is missing (`declared_owner_not_authenticable` / `declared_owner_not_verified`), and promotes nobody. **Accepted cost, stated rather than discovered:** a `single` deployment whose declared owner has not verified their email gets no platform admin at first boot until they do, loudly. Because the pass replays per sign-up while no admin exists, that warning re-emits on each replay until the owner is promotable; it is deliberately not latched, so the condition stays visible in the log a fresh operator is actually reading. + - **Verification landing is a replay trigger again.** `shouldReplayBootstrapFor` admits a `sys_user` update touching `email` / `email_verified` under `single` — but only while an owner is declared, which is the only configuration where such a write can change the answer. With none declared, the trigger set stays exactly as narrow as it was. + - **The cap is replaced, and never silent again.** A 200-row page with a 5000-row scan ceiling, walked oldest-first. Because the page is ordered it holds the rows the age rule actually wants, so truncation can only bite when every one of the oldest 5000 humans is non-authenticable — and reaching the ceiling now WARNS, naming the number examined. + - **The grant's log line records WHY and FROM HOW MANY.** `[security] first user promoted to platform admin: ` keeps its prefix and gains the basis (`declared-owner` / `oldest-authenticable`) and the candidate-pool size, repeated as `basis` / `candidatePoolSize` fields for structured sinks. The returned report carries `basis` too. + + Unchanged: no declaration still means first-user promotion by age (`single` keeps Choice 4A), and that leg has no verification requirement; a user nobody can authenticate as is still never promoted; an existing unscoped grant still short-circuits before any selection runs, so no deployment that already has an administrator can be re-pointed by this. +- 2a79726: feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972) + + `sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — `platform_admin`, `org_owner`, `org_admin`, `org_member`. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant: an out-of-repo consumer that reads the NAME instead of the capability rung reopens the hole with nothing mechanical to catch it. + + Both declarations now carry an object-level `validations[]` rule whose CEL list literal is **generated** from `BUILTIN_IDENTITY_NAMES`, the `@objectstack/spec` constant that declares the identities. The set is a closed enumeration — imported, never retyped, and never widened to an `org_*` pattern, so an ordinary tenant position named `org_manager` still writes. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by one refusal carrying one code (`VALIDATION_FAILED`). + + Two doors, two shapes, for a reason: + + - **`sys_position`** exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling). `bootstrapBuiltinRoles` seeds exactly these four names per organization on purpose, and that catalog is unaffected. A `package`- or tenant-authored row is refused. + - **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity. + + Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export. + + Housekeeping this change drags along, disclosed because a reviewer should not have to discover it: a validation rule's `name` is snake_case by contract, and `scripts/tenant-audit-census.mjs` counts every snake_case `name:` literal in a `*.object.ts` as a "declared object" (it already counts the four `actions[]` names on `sys_position`, so that figure was never a count of objects). The two new rule names move it 298 → 300, so the census artefacts are regenerated with the script's own `--write`. That block regenerates **whole**, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 → 562 and `engine-shaped types recognised` 59 → 58 — which are drift accumulated since the block was last measured at `9cefca9a3`. +- 7026141: fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) + + + + **BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. + + A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. + + ⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". + + Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): + + - **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. + - **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. + + **The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. + + ⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. + + **Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. + + **driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. + + `@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. + +### Patch Changes + +- fb7d75f: Tell a read that DID NOT ANSWER apart from a read that answered NOTHING at two boot-reconciler seams, so a transient storage fault can no longer withdraw a standing org-admin grant or report an unreadable catalog as an already-canonical one (#15840). + + `reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. One transient read fault therefore revoked a sitting admin's standing grant, and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair: nothing is granted, so nothing widens, and nothing standing is destroyed. The next `sys_member` write and the `kernel:ready` backfill ask again. + + `normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels — the same `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while the row that needed healing stayed legacy. A read that does not answer now reports at `error` and refuses the pass instead of attesting counts it could not read. The refusal aborts at the first un-answered read, so it is one line per refused boot rather than the four the report-and-continue shape measured. Its only production consumer already declared the handling: the `kernel:ready` bootstrap catches it, reports it at `warn` as non-fatal, and boot proceeds. + + ⭐ Per-site, not a sweep. A genuine EMPTY read keeps today's behaviour EXACTLY at both seams — a demotion with no membership row still revokes, a membership still grants, an already-canonical catalog still answers `{ positions: 0, permissionSets: 0 }` in silence. `claim-seed-ownership.ts` is untouched: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, which is the right disposition already. The plugin's other reads keep their existing best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed. + + No exported symbol, published payload key or spec path changes: `action: 'skipped'` is already in the returned union, `reason` is already free text, and the two logger option types gain an optional `error` method a caller may omit. Healthy-path behaviour is byte-identical; only the fault path moves. +- 470746a: fix(security): resolve `current_user.accessible_org_ids` into the RLS variable bag (#16518) + + `patch` — a bug fix in a released package. No API signature changes, no exported + symbol added, no spec or ADR edit: the contract already promised this, and only + the line that delivers it was missing. + + ## What was wrong + + `packages/spec/src/contracts/rls-membership-resolver.ts` does not merely reserve + the name `accessible_org_ids`. It declares the field's SHAPE (`:53`, + `accessible_org_ids?: string[]`), states at `:35` that the key is CORE-resolved + and not an app resolver, and lists it at `:70` in + `RESERVED_RLS_MEMBERSHIP_KEYS` — so an app's membership resolver is refused when + it tries to supply the set itself. `ExecutionContext.accessible_org_ids` goes + further and names the RLS spelling outright: *"RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`"*. + + `RLSUserContext` declared `id`, `organization_id`, `positions`, `org_user_ids` + and `email`, and nothing copied `accessible_org_ids` out of the execution + context. So the key was reserved on the grounds that core resolves it, and core + did not resolve it — a slot with a declared shape and no filler, which is the + ADR-0049 "declared but unenforced" shape. + + **The cost is the invisible one.** A predicate such as + `employer_org IN (current_user.accessible_org_ids)` compiled to an unresolved + variable, every applicable policy dropped out, and `RLS_DENY_FILTER` returned + **zero rows with no error raised**. Nothing failed. An empty list is + indistinguishable from "this user really has no data", which is how the shape + survived three green static gates and, in the reporting app, left ten policies + across six objects inert — the entire multi-tenant isolation model. + + The failure direction is **closed**: zero rows, never a cross-tenant read. This + is a usability and declared-means-enforced defect on a security surface, not a + leak. + + ## What it does now + + `RLSCompiler.compileFilter` copies `ExecutionContext.accessible_org_ids` into + `RLSUserContext`, following `org_user_ids`' precedent exactly — both are + core-resolved membership sets the runtime **pre-resolves**, precisely so this + compiler never has to issue a subquery. The compiler is unchanged otherwise; it + already handled the value correctly once present. + + The producer already existed and is unconditional: `resolve-authz-context.ts` + types the set as required and `assemble-execution-context.ts` copies it on every + face, in every posture (*"in `single` posture the set is resolved but no wall + consumes it"*). Only the consuming line was missing. + + One consequence worth naming: **reserved now means reserved at the compiler + too.** `stageRlsMembership` screens reserved keys out of a *resolver's* answer, + but a bag already present on the context was spread through unscreened, and + landed in the variable bag because nothing named the field. Now that the kernel + names it, the compiler's own "a membership key never clobbers a named field" + rule covers it and the kernel's value wins. + + ## Measured, end to end + + A rig on real drivers (`driver-sql`, `driver-sqlite-wasm`), six rows across + three organizations, a caller holding membership in two of them: + + | predicate | before | after | + |:--|--:|--:| + | `employer_org IN (current_user.accessible_org_ids)` | **0 of 6** | **4 of 6** — the rows of both orgs | + | same, caller scoped to ONE org | 0 of 6 | 2 of 6 — that org only | + | same, caller with no set / an empty set / an org with no rows | 0 of 6 | 0 of 6 — unchanged, still fails closed | + | a predicate naming a NON-EXISTENT variable | 0 of 6 | 0 of 6 — unchanged (#16119's face, untouched) | + | `org_user_ids`, `organization_id`, `email`, `id`, an app membership key | — | byte-identical | + + An app **could** work around the defect by supplying the same set under its own + unreserved key through `rlsMembership` and rewriting its predicates to + `current_user.my_org_ids`; that reads 4 of 6 on the same rig, before and after. + The workaround costs every app a membership-resolver registration it should not + need and moves every predicate off the documented spelling — and it is no longer + necessary. +- ac24458: security(rls): the RLS compiler refuses `RESERVED_RLS_MEMBERSHIP_KEYS` by name + + A caller-supplied `ExecutionContext.rlsMembership` entry could supply a RESERVED + kernel key — `id`, `organization_id`, `positions`, `org_user_ids`, + `accessible_org_ids`, `email` — whenever the kernel had not resolved a value for + that key on the request. `RLSCompiler.compileFilter` admitted a membership key on + the test `userCtx[key] === undefined` ("did the kernel happen to resolve one"), + not on whether the key is reserved, so an absent kernel value handed the name to + the bag. + + The direction was widening. With the key unresolved, the predicate referencing it + fails CLOSED — it joins the dropped-policy path and the compile returns the deny + sentinel, which yields zero rows. The bag instead produced a satisfiable filter + over caller-chosen values, converting a denial into a match. + + The merge now refuses reserved keys by name, at the one seam both faces pass + through (the read layer compiles `using` there, the ADR-0058 D4 write gate + compiles `check` there). `stageRlsMembership`'s existing screen covers only the + registered resolver's answer, and only when a resolver is registered at all — it + returns at its first line otherwise — so it could not carry this guarantee. + + No behaviour change for non-reserved membership keys, and none when the kernel + did resolve the reserved value: the kernel's value already won, and still does. + A refused key simply stays unresolved, so its policies drop out and fail closed + through the reason vocabulary that already exists. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 18dbc3dff6..6f96c6ab22 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-security", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md index bcfab501d6..a4d23b550f 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,205 @@ # @objectstack/plugin-sharing +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 2e41707313..5a70180667 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-sharing", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Record-level sharing for ObjectStack — sys_record_share + middleware that enforces sharingModel + ISharingService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-webhooks/CHANGELOG.md b/packages/plugins/plugin-webhooks/CHANGELOG.md index 5b6784db0f..afb94f100b 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,139 @@ # @objectstack/plugin-webhooks +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [a370073] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [690f083] +- Updated dependencies [e7fea46] +- Updated dependencies [a9096af] +- Updated dependencies [4be4e04] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-messaging@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index cad3e3500f..e008c76a68 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-webhooks", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.", "type": "module", diff --git a/packages/qa/dogfood/CHANGELOG.md b/packages/qa/dogfood/CHANGELOG.md index adfe5dcf6f..772f920318 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,226 @@ # @objectstack/dogfood +## 0.0.45 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [c8a006f] +- Updated dependencies [9fca8eb] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [e526556] +- Updated dependencies [305e7fc] +- Updated dependencies [ee6fbd7] +- Updated dependencies [4f1a56b] +- Updated dependencies [f19dbcf] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [a370073] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [4af758d] +- Updated dependencies [86c5052] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [c9246fa] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [5741ff1] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [690f083] +- Updated dependencies [e7fea46] +- Updated dependencies [a9096af] +- Updated dependencies [4be4e04] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [40098a4] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [4ef8247] +- Updated dependencies [ab48938] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [e66da5c] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [2266438] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [611795e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [45c2cf9] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9540590] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [8d4690b] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [6ff5b56] +- Updated dependencies [ecdfc94] +- Updated dependencies [6465cc0] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [6058cb2] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/plugin-approvals@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/service-messaging@17.5.0 + - @objectstack/verify@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/plugin-audit@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/plugin-email@17.5.0 + - @objectstack/trigger-schedule@17.5.0 + - @objectstack/example-crm@4.0.97 + - @objectstack/example-multi-package@0.0.4 + - @objectstack/example-showcase@0.3.19 + - @objectstack/connector-mcp@17.5.0 + - @objectstack/connector-openapi@17.5.0 + - @objectstack/connector-rest@17.5.0 + - @objectstack/plugin-webhooks@17.5.0 + - @objectstack/trigger-record-change@17.5.0 + ## 0.0.44 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 5e6059d64e..bd27c30801 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.44", + "version": "0.0.45", "private": true, "license": "Apache-2.0", "description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.", diff --git a/packages/qa/downstream-contract/CHANGELOG.md b/packages/qa/downstream-contract/CHANGELOG.md index 5f44fedc68..82c67e7ebc 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/downstream-contract +## 0.0.43 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 0.0.42 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index f2fa9a2790..90d63d3e4d 100644 --- a/packages/qa/downstream-contract/package.json +++ b/packages/qa/downstream-contract/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/downstream-contract", - "version": "0.0.42", + "version": "0.0.43", "description": "Frozen third-party consumer fixture — a backward-compatibility gate for @objectstack/spec. Authored the way an external project on a published release authors metadata; if a spec change breaks it, that change is breaking (#2035).", "license": "Apache-2.0", "private": true, diff --git a/packages/qa/http-conformance/CHANGELOG.md b/packages/qa/http-conformance/CHANGELOG.md index 38eb98f273..d1424619e2 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,22 @@ # @objectstack/http-conformance +## 0.1.5 + +### Patch Changes + +- Updated dependencies [baf9745] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [4c42fd1] +- Updated dependencies [0da638c] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [71629a1] +- Updated dependencies [07150b3] + - @objectstack/core@17.5.0 + ## 0.1.4 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 311de0bb09..4c77e71426 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/http-conformance", - "version": "0.1.4", + "version": "0.1.5", "private": true, "license": "Apache-2.0", "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index 46c756f426..5c6110d849 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,574 @@ # @objectstack/rest +## 17.5.0 + +### Minor Changes + +- 2b08a72: fix(runtime): a repeated `?version=` on `GET /packages/:id` is refused `400 VALIDATION_ERROR` in the repo's one message, and `@objectstack/rest` publishes the rule that owns it (#17672) + + `GET /api/v1/packages/:id?version=a&version=b` answered **`404`**, with a second + sentence written at that door. This repo already had a landed answer for exactly + that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112 + nested body (#6307) — and one implementation of it, `refuseRepeatedQueryParams` + / `repeatedQueryParamMessage` in `packages/rest/src/query-multiplicity.ts`, + whose header is the authority on the rule. + + Driven before the change, one host, three refusals: + + ``` + GET /packages/com.acme.crm?version=a&version=b -> 404 RESOURCE_NOT_FOUND + GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + ``` + + A client branching on the answer could not tell "your request named the + parameter twice" from the two genuine not-founds. After: + + ``` + GET /packages/com.acme.crm?version=a&version=b -> 400 VALIDATION_ERROR + GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + ``` + + The body is the dispatcher's declared envelope — + `{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }` + — with `VALIDATION_ERROR` derived by `buildApiError` from + `standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400. + ⛔ Nothing in `packages/spec` moves. + + **What was actually blocking this was reachability, not judgement.** + `@objectstack/rest` declares exactly one export subpath and that module was not + on it, so #17668 could neither call the rule nor (correctly) copy it, and + shipped the `404` with its own sentence instead. The barrel now publishes + `repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, and the dispatcher + domain calls the message function — so the sentence a caller is told for a + repeated parameter is the same one on every door that carries the rule, ⛔ never + a second copy that drifts. + + ⚠️ The two published symbols are not interchangeable across a package boundary, + and the barrel entry says so. `repeatedQueryParamMessage` is the portable half: + a pure function of two primitives. `refuseRepeatedQueryParams` writes the bare + ADR-0112 body onto a `res`, which suits handlers of that shape and ⛔ not a + runtime dispatcher domain — measured, its body fails that surface's + `BaseResponseSchema` with `success is missing, must be a boolean`. + + **Not a breaking change, measured rather than assumed.** The `404` it replaces + was introduced by #17668 (`1a25f4a8d`), which is not an ancestor of + `@objectstack/runtime@17.4.0` (exit 1; two control commits from that tag's own + history answer exit 0 on the same predicate, in a checkout + `--is-shallow-repository` reports `false`). It has never been published, so no + released consumer can have branched on it. Everything else about the door is + unchanged: `?version=` and `?version=latest` still serve the + installed row, an absent version and an unknown id still answer `404`, and a + one-element array is still one occurrence. + + Also corrected, on the module that owns the rule: its header said the + dispatcher's `/packages` domain "reads no `version`" — load-bearing prose, + since it is part of why the rule needs only one home. That stopped being true + when #17668 landed. The paragraph now states what is true, which is that the one + home did not move and now serves two doors. +- 94c9302: `POST /analytics/dataset/query` parses its `selection` at the door, the way its two siblings already do + + The route checked one thing about the body it forwards — that + `selection.measures` was a non-empty array — and forwarded everything else + unexamined. `/analytics/query` and `/analytics/sql` Zod-parse their body at + the entry and lift a malformed member to a 400 before the service is reached, + so a client met two postures on one family depending on which door it knocked + on, and a malformed member of `selection` travelled into `dataset-executor` to + be answered by whatever the face behind it happened to do with it. + + ⚠️ **A 400 is newly reachable.** Requests that previously slipped through are + now refused. Two shapes: + + - A `timeDimensions[].dateRange` outside the closed preset vocabulary answers + `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` — the same code, status and wording + the sibling door has answered for the identical condition since the + vocabulary closed. Measured on the tree before this change, the literal + string `not a range at all` reached the executor under an ordinary `200`. + - Anything else malformed answers `400 VALIDATION_FAILED` with + `details.fields[]`, each entry naming the member as `selection.`. + + **What is NOT newly refused, deliberately.** `selection` is a + `DatasetSelection`, which is *not* the `AnalyticsQuery` the siblings parse: it + carries no `cube`, and `runtimeFilter`, `dateGranularity`, `compareTo` and + `totals` are members of its own. Reusing the sibling schema would have refused + every real dashboard widget. What the door parses is the projection of the + seven members whose declaration on `DatasetSelection` *is* the `AnalyticsQuery` + member of the same name — `dimensions`, `measures`, `timeDimensions` (declared + there by reference), `order`, `limit`, `offset`, `timezone` — so the refusal + set is exactly what the published interface already declared. The four + dataset-only members are projected away before the parse and keep reaching the + executor untouched. + + Validation-only: the caller's `selection` object is what `queryDataset` + receives, by identity, never a parse output. +- ab56ea3: refactor(rest)!: `ImportProtocolLike` declares the request each of its three required members receives, instead of `args: any` (#16952) + + The exported extension point `runImport` accepts a protocol through now states its own contract. + + **FROM** — every required member erased its parameter, so the interface declared nothing about the request it would hand an implementor: + + ```ts + export interface ImportProtocolLike { + findData(args: any): Promise; + createData(args: any): Promise; + updateData(args: any): Promise; + } + ``` + + **TO** — each member names the declared spec request, wrapped in the server-scoped envelope the runner adds (`ImportProtocolRequest`, exported alongside): + + ```ts + export type ImportProtocolRequest = R & { context?: any; environmentId?: string }; + + export interface ImportProtocolLike { + findData(args: ImportProtocolRequest): Promise; + createData(args: ImportProtocolRequest): Promise; + updateData(args: ImportProtocolRequest): Promise; + } + ``` + + **Why this is breaking-ish, and released as `minor`.** This is a narrowing of a published surface: an implementor that compiles today may stop compiling. Nothing about the values the runner sends changes — the request objects are byte-for-byte the ones #16638 already made canonical — so no runtime behaviour moves. What changes is that the compiler now holds an implementor to the same `QuerySchema` the runner is held to: `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` are declared, and the wire spellings `$filter` / `$top` are not. + + **Migration for implementors.** If your `findData` / `createData` / `updateData` reads a wire alias, it will now fail to compile — that diagnostic is the point of this change, and the fix is to read the canonical key: + + ```ts + // before — compiles, and silently degrades to match-everything when `$filter` is absent + async findData(args: any) { + const where = args?.query?.$filter ?? {}; + const limit = args?.query?.$top ?? 2; + } + + // after — drop your own annotation and let the declaration type the parameter + async findData(args) { + const where = args.query!.where; + const limit = args.query!.limit; + } + ``` + + ⛔ An implementor that keeps an explicit `args: any` annotation of its own opts back out: the annotation wins over the contextual type, and the contract reaches nothing. Leave the parameter unannotated, or name `ImportProtocolRequest` explicitly. + + ⚠️ The `?? {}` shape in the "before" is the mechanism that made a dialect mismatch silent rather than loud: an unrecognised query does not throw, it degrades into a filter that constrains nothing, so a duplicate probe stops discriminating and an upsert updates the wrong record. Prefer a read that throws. + + +- 9ca49eb: `import-runner.ts` builds its three server-side `findData` requests in the CANONICAL QueryAST, and the helper that carried them is typed against the declared contract instead of `any`. + + `FindDataRequestSchema` declares `query: QuerySchema.optional()`, and `QuerySchema` declares `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` — it declares neither `$filter` nor `$top`. The normalizer's own table calls those two "the wire-only spellings no schema declares". The reference resolver, the duplicate probe and the id recheck each built a literal in that undeclared dialect, and nothing reddened because the helper they went through took `query: any`: the literals were type-checked by nothing at all, so the undeclared keys cost no diagnostic. Reverting one of them to `$filter` now costs `TS2353 … '$filter' does not exist in type 'QueryInput'`; on the pre-change file the identical revert cost zero errors. + + - **The three literals.** `$filter` → `where`, `$top` → `limit`, plus the `object` the declared query requires. No behaviour change on the two `rest-server.ts` call paths (`POST /data/:object/import` and the async import-job worker), which hand `runImport` the real `ObjectStackProtocolImplementation`: that normalizer folds `$filter` onto `where` and `$top` onto `limit` by the spec's own `RPC_QUERY_ALIAS_SLOTS`, moving the value verbatim, so both dialects reach `engine.find` as the same option bag. + - **The erasure vehicle.** `findArgsBase` now takes a `FindDataRequest` rather than a bare `any` query, so the request-level `object` is compiled too and the `object: ''` placeholder every caller had to override is gone. This is the durable half: rewriting the literals while leaving the parameter `any` would leave the next author in this file with no diagnostic at all. + - **The pin.** `rest-server-canonical-query-ast.test.ts` censuses the PACKAGE rather than one file. `import-runner.ts` has no HTTP door — every query in it is server-built — so its census rejects a wire spelling anywhere in the file, not only inside a `query:` slot. That whole-file rule is the one that finds this class: these three literals were arguments to a helper and were never in a `query:` slot to begin with. + + ⚠️ Implementor-visible: `ImportProtocolLike` is exported, its `findData(args: any)` never declared which dialect the runner sends, and the runner now sends the canonical one. An implementation that reads `args.query.$filter` / `args.query.$top` directly — rather than through the protocol normalizer — receives `undefined` and must be updated to read `where` / `limit`. +- 3644fad: **BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route + `GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key + `reference` alone. The three tolerant fallback arms it used to read after it — the + `referenceTo`, `target` and `options.objectName` spellings — are deleted. + + Effect on the wire: a stored object-metadata row whose lookup field carries one of those + three spellings and no `reference` used to answer `200` with rows from the aliased object; it + now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field + carrying `reference` is unaffected, including a partially-migrated row carrying a legacy + spelling beside it. `publicPicker.object` on the form is still the explicit override and is + still read first. + + No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses + `relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering + with a rename hint naming the canonical key, so no authoring path can produce such a row; a + census across both trees found no producer and no relation field carrying any of them, with + positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to + preserve. The spec spelling is the contract, and a stored row spelling the target the old way + is a producer defect rather than a dialect this route accommodates. + + +- cf6e0a1: fix(rest): a hook that crashes after declaring a code now answers 500 UNCLASSIFIED_FAULT instead of the declared status with the crash text (#15071) + + + + **BREAKING** — the answer this published door gives moves for existing inputs. + No export, signature or declared type changes; what changes is the response an + existing call observes, and a client branching on `error.code` for the affected + shape now falls to its 5xx path instead of its refusal path. Shipped as `minor` + under the launch-window convention (`major` is refused while the fixed group + versions in lockstep), so this banner — not the level — is the breaking-ness + signal. + + **What changes for an operator.** A sandboxed hook or action body that declared a + refusal code and then CRASHED — `throw`-ing nothing, but hitting a bug on a later + line — used to answer the single-record `/api/v1/data` routes with the code's own + business status and the QuickJS debug sentence as the client-facing message, for + example `409 DELETE_RESTRICTED · "hook 'guard' threw: TypeError: x is not a + function"`. It now answers `500 UNCLASSIFIED_FAULT` with the sanitised message + and no crash text, which is what the same crash carrying no declared code has + always answered. The full wrapper still reaches the server log through the + existing `[REST] Unhandled error` / withheld-fault path, so nothing an operator + diagnoses with is lost. + + **What does NOT change.** An ordinary declared refusal — a hook that throws a + business error carrying a code and does not crash — is untouched: same status, + same code, same sentence, same structured fields. So is every non-sandbox + producer of those codes, and so is the `developerMessage` channel, which keeps + the rule it already had for a fault. + + **Why.** A declared code is the author's statement about the failure mode they + handled; a crash is not that mode. Answering one with a business status shipped + an internal, stack-shaped sentence to an end user and told the client the wrong + thing about what happened, while the door one branch down already sanitised the + identical crash. Maintainer ruling, 2026-09-04, decision batch #27, on #15071. + + **If you were relying on the old answer,** the affected shape is a hook that + declares one of the classification's ten code-gated refusals and then faults: it + now surfaces as a 5xx to clients and retry policies rather than as a 4xx. That is + the point of the change — the crash was never the refusal the code named. + +### Patch Changes + +- 3a5eaea: `packages/rest`'s fault logging gains a **declared level seam**, `OS_REST_LOG`, with the **shipped default unchanged**. At the default — and an unset or unrecognised value *is* the default — a reported fault still prints the whole `Error`: message, `cause` chain and stack frames, exactly as before. ⛔ No wire byte moves, no published payload gains a key, and no existing log line changes shape. + + What is new is that the loud/quiet choice is now **declared and machine-read** instead of implicit in whether an author happened to pass `error` or `error.message`: + + - **`OS_REST_LOG`** accepts `debug` / `info` / `warn` / `error` / `silent` — deliberately the same vocabulary and the same `'info'` default as `@objectstack/objectql`'s `OS_REGISTRY_LOG`, so the two are one logging contract with two populations rather than a second ad-hoc environment variable. Documented for operators in this package's README. + - **`scripts/check-rest-log-declared.mjs`** enforces it: the seam is located by its environment read (never a hardcoded path), the vocabulary is read from `REST_LOG_LEVELS` rather than copied, the two seams' vocabularies are held equal, a harness declaration must name a level the seam actually recognises — an unrecognised one resolves to the default *silently* — and every inline vitest project must carry its own declaration, because a root-level `env` is inert for project runs. + - **The shipped default is gated, not just documented.** Lowering `REST_LOG_DEFAULT_LEVEL` to `error` or `silent` is a finding, because at those levels this package stops reporting faults it is the only reporter of. + + **Why the default does not move.** Measured on one green `packages/rest` run: 2,095 indented `at ` frame lines, 36.7% of captured output, 100% of them arriving through this one shim. They are not dead weight. When a 5xx is withheld from the client, the log is the only copy of the driver text, and that text lives on `error.cause` — printed only because a whole `Error` object, not a summary, reaches `console.error`. Four assertions across `rest-5xx-message-sanitization.test.ts` and `rest-expected-error-logging.test.ts` pin that by asserting the **identity** of the error that arrives, one of them carrying an explicit do-not-delete warning aimed at exactly this repair. + + Operators: nothing to do. A deployment that wants the REST layer quieter can now say so — `OS_REST_LOG=error` drops the warning half, `silent` drops both — but doing so discards diagnostics that have no second copy anywhere, and the README says so at the seam. +- 758ac40: refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) + + The predicate that decides whether a sandboxed body's `throw` is a business + REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) + had **three byte-identical copies** — measured, one distinct 74-character regex + literal across three packages: + + | copy | package | its stated reason for being a copy | + |:--|:--|:--| + | `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | + | `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | + | `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | + + ⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of + them survives moving the rule.** `@objectstack/types` now owns + `isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence + of a bare `Error:`. All three packages already depend on it and it depends on + none of them, so this fold **adds zero dependency edges** and cannot cycle. + + ⚠️ The hazard was never style. One copy learning a new native error name and the + others not means the same throw is a refusal at one door and a crash at the + next — a crash message **leaked** at one boundary and **withheld** at another. + #16013's argument for extracting exactly this class applies verbatim: the + classification is the part nobody may get wrong, so one *tested* helper is worth + more than N correct copies that must each stay correct forever. + + ⛔ **No behaviour changes at any door, per case.** This is a pure refactor and + the three WRAPPERS are deliberately NOT folded, because they are not the same + shape and merging them would move a door's answer: + + - rest asks a trimmed message and answers a boolean; + - objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — + because a code hook and a sandboxed body carry the native name in different + places; + - runtime asks the trimmed inner message and answers the **message**, not a + boolean. + + What the three share is the predicate, so the predicate is what moved. Each call + site keeps its own slot choice and its own trimming, and `isNativeErrorName` + deliberately does **not** trim for its callers — a contract pinned in its test. + + **Shipped rather than `skip-changeset`**, measured on a real build: all four + packages publish `files[]: ["dist", …]`, and the built `dist` of each carries + the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, + `@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` + scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. + The retired copies are gone from the artifacts too: the regex literal scores + **0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in + `types/dist` (the ESM and CJS bundles). + + `@objectstack/types` takes **minor**: a new export is a purely additive widening + of a published surface, which is at least minor whatever the commit type says. + The three consumers take `patch` — their artifacts change, their behaviour does + not. +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- a900841: fix(rest): `/discovery` no longer contradicts itself — `services.*.route` follows the mounted paths, like `routes.*` already did (#16674) + + The `/discovery` document states each service's address twice: once in `routes.X` (the flat convenience map) and once in `services.Y.route` (the per-slot entry). The REST discovery handler rewrote only the first half to the paths this server actually mounts, so any deployment that moved a prefix received a document that disagreed with itself — and the `services` half pointed at a path with nothing mounted on it. + + Measured on a boot with `crud: { dataPrefix: '/objects' }`, reading `GET /api/v1/discovery`: + + - before — `routes.data` = `/api/v1/objects` (the mounted path), `services.data.route` = `/api/v1/data` (unmounted) + - after — both answer `/api/v1/objects` + + The same split opened on four keys at once for an `apiPath` deployment: `data`, `metadata`, `ui` and `auth`. All four now follow the mount. `services.*.route` is written as a projection of the finished `routes` map, so the two halves cannot state different answers whatever a future substitution does to `routes`. + + **A default deployment's document does not move by a byte.** With `crud.dataPrefix` at its `/data` default and `metadata.prefix` at `/meta`, the values the correction writes are the values that were already there; only a deployment that had moved a prefix sees a change, and there the old value addressed nothing. Route-less slots (`cache`, `queue`, `job`, and an in-process `realtime` bus) never gain a route, and no advertisement is withdrawn. + + If you have been reading `services.data.route` on a moved-prefix deployment and compensating for it — by re-deriving the path from `routes.data`, or by hard-coding the prefix — that workaround can go: the field now answers the mounted path directly. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- dfb42c5: fix(rest): `GET /meta/object/:name/state/:field` tells a wired-and-failing engine apart from an absent one (#15405) + + `objectQLProvider` has two consumers in `rest-server.ts`. #13476 repaired one of them — the `computeExecCtx` authorization-input seam — by reaching the provider through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine was wired and could not be resolved" as two facts instead of one `undefined`. This route, the slot's second consumer, reached it through `.catch(() => undefined)` and converted every rejection straight back into the `undefined` a never-registered engine produces, three lines before the answer is chosen. So a wired-and-failing engine and a never-registered one both answered `404 NOT_FOUND · "Object not found"` — a diagnostic route lying about the cause during exactly the incident it would be consulted in. + + That line was newly load-bearing rather than long-broken: before #13904 the shipped provider was `try { … } catch { return undefined; }` and could not reject at all, so the `.catch` was dead code. #13904 made the provider re-raise precisely so a consumer could see the outage, and this consumer caught it back. + + **What moves.** On this route only, an engine that is wired and fails to resolve now answers `503 SERVICE_UNAVAILABLE` instead of `404 NOT_FOUND` — the same answer its sibling seam and the package door (#13476) already give for the same fault. No accept set widens and no new wire code is minted: `SERVICE_UNAVAILABLE` is an existing `StandardErrorCode` member, reached through the existing `AuthzStoreUnavailableError`. + + **What does not move.** An engine that was never wired, and a provider that resolves `undefined` (the seam contract declaring absence rather than failing), both keep the `404 NOT_FOUND` they answered before — that is the supported no-data-plane composition. A healthy engine asked about an object that genuinely does not exist still answers `404 NOT_FOUND`; a healthy engine asked about an object that exists is still served. + + **Reachability, stated rather than implied.** Every `/meta` route sits behind the anonymous-deny gate, and that gate resolves the same engine first. Where it takes its provider branch (a single-kernel boot such as `pnpm dev:crm`) a broken engine already raised there, before this route's line ran — so nothing changes for those deployments. The collapse was reachable where a resolvable kernel supplies auth and the separately-wired `objectQLProvider` is broken, which is the multi-kernel wiring, and that is where the new answer lands. + + `POST /email/send` carried the other retired `.catch(() => undefined)` in the same file and moves to `seamOrUndefined`. Its answer is deliberately unchanged at `501 NOT_IMPLEMENTED`; what changes is that a host wiring a **non-`async`** provider — which the seam's declared type cannot prevent — now reaches that same 501 instead of throwing past a `.catch` that did not exist yet and landing in the handler's own `500 EMAIL_SEND_FAILED`. Not reachable from the shipped wiring, where both providers are declared `async`; repaired because it is the same spelling at an embedder-reachable seam. +- 2e8e118: Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. + + The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. + + No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/service-package@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index eacac0afc6..5bb3d9543e 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack REST API Server - automatic REST endpoint generation from protocol", "type": "module", diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 8f7444b387..f844aaea5a 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,740 @@ # @objectstack/runtime +## 17.5.0 + +### Minor Changes + +- 1a25f4a: fix(runtime): `GET /api/v1/packages/:id` honours `?version=` instead of silently ignoring it (#17416) + + The route accepted a `?version=` query parameter and the only surface serving it + never read the parameter. A caller asking for a version that is not installed + was answered `200` with the **installed** row, and nothing in the status, + headers or body distinguished that from a version-scoped read that actually + happened. + + The parameter is not hypothetical traffic: `ScopedEnvironmentClient.packages.get` + (`@objectstack/client`) declares `version?: string` and appends it, so the SDK + has been sending a parameter the runtime dropped. The handler that honoured it + — the REST registrar's twin of this route — was removed with the duplicate + response shape, and the dispatcher's `/packages` domain never had that read to + inherit. + + ``` + FROM GET /api/v1/packages/com.acme.crm?version=99.0.0 (1.0.0 installed) + -> 200 { data: { manifest: { version: "1.0.0" }, … } } + + TO GET /api/v1/packages/com.acme.crm?version=99.0.0 + -> 404 { error: { message: "Package 'com.acme.crm' version '99.0.0' not + found — installed version is '1.0.0'" } } + ``` + + **What does not change.** The unversioned read is untouched, down to the row and + the writability verdict it stamps — pinned as the lit control beside the new + assertions, because a green on only the scoped path would also pass with the + ordinary read broken. `?version=` naming the installed version is served + exactly as the unversioned read is, and so is `?version=latest`: the deleted + handler read `requested.value || 'latest'` and its store resolved `latest` to + the newest row, so "no version" and "`latest`" named one request there and name + one request here. An id the registry does not hold keeps its existing 404 + wording whether or not `?version=` rode along — a package that is not installed + cannot be at the wrong version. + + **This is request-side only.** The response shape is not touched, so the route + still answers with exactly one body shape; comparison is exact string equality + on the version, the same predicate the durable package store uses (`AND version + = ?`), so the two answers to "is this package at version v" cannot drift into + semver-range semantics at one of them. + + A repeated `?version=a&version=b` is no longer resolved by silently choosing + one — it is answered with a refusal naming what was seen. The repo's one rule + for a repeated single-valued parameter answers `400 VALIDATION_ERROR` and is + the right end state for this door too; it is not restated here, because the + helper that owns that rule and its message is not exported from + `@objectstack/rest`. +- 2b6a207: fix(runtime): the API root is the discovery route, under a second spelling — a gated session's `GET ${prefix}/` reaches discovery again (#17625) + + `HttpDispatcher.dispatch()` strips one trailing slash, so both root spellings it + accepts collapsed onto the empty string: `${prefix}/` arrives as `/` and + `${prefix}` arrives as `` (the MSW / base-URL-stripped form). Only the discovery + branch at the foot of the method knew that empty string meant the API root. The + ADR-0069 authentication-policy gate, which runs far above it, did not. + + That disagreement was invisible while `isAuthGateAllowlisted` answered `true` + for a falsy path. objectstack#7898 made the predicate fail-closed at the source + — exemption is now something a path EARNS by naming an allow-listed route — and + the bare-root discovery request started answering 403 for a session carrying an + `authGate` posture (expired password, required MFA): + + ``` + FROM GET ${prefix}/ (session with user.authGate) -> 200 discovery document + TO GET ${prefix}/ (session with user.authGate) -> 403 PASSWORD_EXPIRED // regression + NOW GET ${prefix}/ (session with user.authGate) -> 200 discovery document + ``` + + **Normalising the root to `/` is measured insufficient and is not what landed.** + `isAuthGateAllowlisted('/')` is `false` — a segment-less path matches no + `ALLOW_ROUTES` entry — and the discovery branch tests `/discovery` or the empty + string, neither of which `/` satisfies. `'' -> '/'` therefore relocates the 403 + rather than removing it. Both legs are pinned upstream in + `packages/core/src/security/auth-gate.test.ts` ("does not exempt the dispatcher + bare-root `cleanPath` — step 2 is #17625"). + + The root is canonicalised to `/discovery` instead — the route it has always + served — read from one constant by both the canonicalisation and the branch that + serves it, so the two cannot drift into a third disagreement about what the + empty path means. + + **⛔ No allow-list was widened and `packages/core` is untouched.** The only input + whose gate answer moves is the API root, and it gains exactly the exemption + `/discovery` already carried, by BEING that route — no new information is + reachable, since `/discovery` was already exempt and already outside the + project-membership skip check. A caller that reaches the gate with no path at + all is still refused at the predicate, and the pathless case stays declared + where it lives (`shouldDenyAnonymous`) rather than re-derived at this seam. + + **What does NOT change.** `${prefix}` with no trailing slash keeps serving the + same document; the named `/discovery` route is untouched; the + environment-scoped root `${prefix}/environments/` keeps its own answer, + which matched no allow-listed route before objectstack#7898 either. `//` strips + to `/`, not to the empty string, so it is not the root and is not canonicalised. + + **Why `minor` on a change whose commit type is `fix`.** The two are independent + and the floor is mechanical, not editorial: this PR's clause ② is declared + affirmative, and the maintainer's ruling of 2026-09-04 (decision batch #35, on + objectstack#15294) puts an affirmative clause ② on a package whose + `packages/**/src/**` the diff moves at AT LEAST `minor` — *the commit type may + raise a bump but never lower it below what the act requires*, written out under + "WHICH LEVEL" in the `Check Changeset` step of + `.github/workflows/pr-automation.yml`. ⛔ So the reading that this is "a 403 that + should be a 200, therefore a patch" is an argument about INTENT and does not + reach the level: the act re-admits an input class the merged tree refuses, on an + authorisation surface, and that is what the level grades. The commit type stays + `fix(runtime)`, because the type describes the act and the level prices it. + + **ADR-0087 disposition: no ledger entry is owed and no marker is required.** + This changeset declares no breaking change, which is the only condition under + which `check:adr-0087-registration` demands a disposition marker. On the + substance: no ADR-0087 shape surface moved — the diff touches one + `packages/runtime` transport file and its sibling test, no `*.zod.ts`, no + `packages/spec/**`, no `packages/spec/src/contracts/**` entry and no object + definition — so `objectstack migrate meta` has nothing to reach, and no + authorable metadata key, accept set or stored shape changes. Nor is this an + ADR-0087 conversion-layer entry: nothing lenient is being accepted from a + metadata producer. One transport's two spellings of its own route are being + reconciled to the route's own name, which is the opposite direction — a dialect + removed, not tolerated. +- 76ddab7: fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) + + **Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. + + FROM → TO, for a caller of a gated action: + + ``` + run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran + run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required + ``` + + The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. + + **What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: + + - Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. + - Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. + - Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. + - `list_actions` is unchanged. + + **A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. + + Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). +- ea4d164: Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes. + + `EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound). + + Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence. + + Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one. + + New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`. + + This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here. +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- e6965dd: **`AppPlugin` now names the manifest-stage `permissions` value its ADR-0057 security registrar cannot read, instead of dropping it in silence.** + + The registrar flattens the manifest under the stack's own collections (`{ ...manifest, ...collections }`), so `manifest.permissions` is read whenever the stack declares no `permissions` collection of its own. That key is the ADR-0025 §3.2 capability grant a package *requests* — a flat list of permission strings, or `{ services, hooks, network, fs }` — while the registrar wants ADR-0090 `PermissionSet[]`. Both arms were skipped with nothing logged: the structured arm is not an array, so the whole value never entered the loop; every member of the flat list carries no `name`, so all of them were dropped. An author who wrote `manifest: { permissions: ['sales_rep'] }` meaning a permission set got no set registered, no `sys_audience_binding_suggestion`, and no line anywhere saying why — the "absence must be loud" rule in AGENTS.md → Route & surface ownership §3. + + It now warns once per boot, naming the field, how many entries were lost, both readings of the key, and where permission sets belong (`defineStack({ permissions: [ … ] })`). The report is written per `SECURITY_FIELDS` entry, so a hand-built bundle carrying `positions` / `capabilities` / `sharingRules` on its manifest is named too. + + **Nothing else moves.** Which items register is byte-for-byte unchanged — the registrar is deliberately *not* made tolerant of the grant reading (widening the key was rejected by name, #14242 road C, maintainer 2026-09-02). The line is `warn`, not `error`: nothing here claimed to persist anything. It stays silent on every shape where nothing was lost — a stack declaring its own `permissions` collection, a manifest with no such key, a manifest whose entries the registrar really can read, and the `securityMetadataRegistrar: 'artifact-door'` composition that owns the route. +- 4280055: fix(runtime): mount the scoped `/api/v1/environments/:id/packages*` door, and reconcile the package read/delete responses to their declared schemas (#16781) + + **The door.** `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `@objectstack/plugin-hono-server` + this plugin with `enableProjectScoping: true` and **without** `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET …/packages/:id` and `DELETE …/packages/:id` answered by the transport's own `notFound` — a bare 404 on routes `content/docs/api/environment-routing.mdx` documents. The domain has resolved scoped package paths since #15859; nothing mounted one. + + `mountPackagesRoute` is now wrapped in a `base`-taking `registerPackageRoutes(base)`, exactly like its three siblings, and called a second time with the scoped base. **The same handler, no second implementation.** The unscoped mounts keep their registration position and their unconditional mounting, so the change is purely additive: no route that answered before stops answering. + + **The wire.** Two responses gained the key their own declared schema requires (contract review of #16628, finding F2). Both additions are **additive** — no key left either payload: + + - `GET /packages` now sends **`hasMore`** (`ListInstalledPackagesResponseSchema`). It is `false`: this door applies its `status` / `type` filters and returns every remaining row, reading no `limit` and no `cursor`, so there is no next page to announce. + - `DELETE /packages/:id` now sends **`packageId`** (`UninstallPackageApiResponseSchema`). `registryRemoved` and `persisted` stay on the wire unchanged. + + A client that reads only the keys it read before is unaffected; a client parsing either payload against the published schema stops being refused. + + The `DELETE /packages/:id` route-ledger row now carries `responseSchema: 'UninstallPackageApiResponseSchema'`, backed by new conformance coverage that drives the real handler. `GET /packages` is deliberately left blank: its rows are the ASSEMBLED package body, while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` — the #14242 stage mismatch, which no `@objectstack/spec/api` export declares yet. Both directions of that boundary are pinned, so the row becomes fillable against a red test rather than a guess. +- de1a611: `AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. + + The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. + + - **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. + - **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. + - **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. + + `SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. + + The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. + + ⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. + + ⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. + +### Patch Changes + +- 7f62536: A **declared capability absence** — a 5xx answered because the deployment did not install an optional service — is now reported **once per route per process at `warn`**, naming the missing service, instead of one `error` line per request. Every other 5xx keeps the per-request `error` line #14310 shipped. + + Measured before the change, on a stock showcase boot: `GET /api/v1/ai/*` (the cloud-only AI service's declared `501 NOT_IMPLEMENTED`) printed one `error`-level line per request, and Studio opens it unprompted. A deployment that is working exactly as configured was training the channel built to mean "an operator must look" into noise — which is the failure mode `--log-level`-watching operators learn as "skim the errors". + + - **What counts as an absence** is the envelope the door composed: a producer-declared 5xx (`declaresServerFault` — the repo's existing declared-5xx predicate) whose ADR-0112 `code` is `NOT_IMPLEMENTED` or `SERVICE_UNAVAILABLE`. Nothing is invented to recognise one; the code the producer already declared *is* the declaration. + - **The predicate is applied inside the shared funnel** (`logServerFault`, `@objectstack/types`), not at each door, so `sendError`'s nested-envelope exit and the runtime dispatcher read one answer by construction. A door cannot opt in, opt out, or drift. + - **The dedupe key is (route, process).** A restart reports again, and a second, different route reports on its own — deliberately not a global "first N", which is the shape that hides the second route. A door that supplies no route coordinates is demoted to `warn` but never suppressed: an un-keyed bucket is that same hiding shape. + - **A thrown 5xx keeps its `error` line even when it declared `501`.** The thrown exit hands the funnel the throw and no envelope `code`, so it is not recognised as an absence — fail-loud for the half that carries a stack. + + ⛔ **No wire byte moves.** Status, `code`, `message` and body shape are unchanged at both doors; this changes a log level and a count. The response bytes are pinned in `packages/runtime/src/declared-capability-absence-warn-once.test.ts`, and that block runs green on the pre-change tree too, which is what makes it a before/after measurement rather than a claim. + + Operators who were alerting on `[5xx]` at `error` level for an uninstalled optional service will now see one `warn` line per route per process instead. The line says so in its own text: `(declared capability absence — reported once per route per process)`. +- 4af758d: refactor(runtime,mcp): the last two admission doors classify the `tenancy` rejection through the shared `classifyAdmissionTenancyPosture` (#17114) + + `@objectstack/core`'s `classifyAdmissionTenancyPosture` is the one place the + #13906 decision 1 option A classification lives: a branded "never registered" + rejection is the supported no-tenancy composition and answers a quiet + `undefined`, while every other rejection becomes + `AuthzStoreUnavailableError('tenancy', err)` — ADR-0112 `SERVICE_UNAVAILABLE` / + 503 — because the posture is an authorization INPUT and admission was never + decided. + + Two admission doors were still hand-writing that classification, out of the + declared scope of the fold that extracted it: + + - `@objectstack/runtime`'s `resolveExecutionContext` — the REST/dispatcher + entry-point identity resolver; + - `@objectstack/mcp`'s `resolveStdioTenancyPosture` — the stdio door's **async + kernel** leg. + + Both now call the shared function. ⛔ **No behaviour changes at either door.** + Tenancy posture decides which rows a caller may see, so a divergence between + copies would be two answers to "whose data is this", and the copies are the + stale ones by construction — the shared version is the one that will be + maintained. + + **The resolution stayed at each seam, deliberately.** The extractable part is + the classification, not the resolution: each door keeps its own accessor guard + and hands its own former accessor expression in as the thunk, so the helper + never learns *how* a seam reaches the service. A helper that owned the wiring + too would be wrong for one seam or grow a flag per seam. + + **One neighbouring leg is deliberately NOT folded.** The stdio door's **sync** + fallback is taken only on a `KernelBase`-shaped host with no `getServiceAsync`, + whose accessor reports its one possible fault — nothing registered under that + name — **unbranded**. Routing it through the shared classification would mint a + 503 outage out of a supported composition, so its bare `catch` remains that + seam's recorded decision. A test arm now fails if that leg is ever folded. + + Shipped rather than `skip-changeset`: both packages publish `files[]: ["dist"]`, + and the built `dist` of each carries the new call (2 files each, measured after + a real build, with a symbol known-absent scoring 0 and + `isServiceNotRegisteredError` scoring 4 in `runtime/dist` as the lit control). + `@objectstack/mcp`'s `dist` no longer mentions `isServiceNotRegisteredError` at + all. +- bdb247d: `@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + + The execution context a seed write must use — `isSystem`, `skipTriggers`, + `seedReplay` — had **no exported form**, so every seeder held a private copy of + it and nothing held the copies equal. There were three on `main`: + `SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), + `SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock + already recorded that it "mirrors" the first) and `SEED_CONTEXT` + (`@objectstack/verify`'s fixture writer, which spelled it a third time + specifically because the runtime kept its copy module-private). + + **Why a shared constant rather than three accurate copies.** `skipTriggers` is + what suppresses "on create" automation for seed rows, and `isSystem` alone does + **not** suppress dispatch. A seed path that lost that flag once seeded with + automation live while the main path had it suppressed — a self-trigger loop that + wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging + defect is a kernel semantic, not a local detail. + + **What is exported, and what deliberately is not.** The **inner** + `ExecutionContext` value, and nothing wrapped around it: + + ```ts + import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + + await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); + ``` + + The `{ context: … }` options bag stays at the call site. It is what all three + sites ultimately hand to `insert`, but it is an options envelope rather than the + posture: its type differs per engine method, so freezing one bag onto the + protocol surface would serve `insert` and no other operation, and it is + precisely the convenience bundle this export is not. + + ⛔ **No behaviour change.** The value is byte-identical to all three previous + copies, the three flags keep their existing meanings, and no seed path changes + what it writes or how. The three former copies now read this export, so the two + option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` + context is the export itself. + + **Additive, so `minor` on `@objectstack/spec`**: one new name on the existing + `./kernel` entry point, no existing export removed, renamed or narrowed. The + three consumers take `patch` — their published `dist` changes (an import edge, + and the constant now resolves through `@objectstack/spec/kernel`) while their + own public surfaces do not move. +- cea85fd: A sandboxed hook's business refusal reached through a script action answers 4xx, not `500 INTERNAL_ERROR` + + `POST /api/v1/actions/:object/:action` answered **`500 INTERNAL_ERROR`** when a + `beforeUpdate` hook refused a state transition for a business reason and the + refusal travelled out through the action body's `ctx.api` write. The same refusal + has answered **`400`**, with the hook's sentence verbatim, on `/data` since + objectstack#11588. A 500 tells every client "the platform broke", so a + well-behaved one retries, alerts or pages for a guard that will never say yes. + + **Where the producer was.** Not in the action route's classifier — that read the + shape it was handed correctly, and both sides of the line it pins (`a deliberate + REJECTION is a 400` / `an unexpected FAULT is a 500`) are unchanged. The refusal + arrived already stripped of every mark that says "a body reported this on + purpose", one VM hop earlier: `hostErrorToVm` marked **every** `SandboxError` + crossing into the action body's VM as the sandbox's OWN fault (objectstack#4431) + on an `instanceof` test — and a nested sandboxed hook's refusal *is* a + `SandboxError`, wrapped by the same runner one level down. The pump branch that + reads that marker then discarded `innerMessage`, `code`, `status` and `fields`, + and the classifier read the missing business message as a crash. + + **What changed.** The marker now asks the question the `/data` door asks — + `sandboxBusinessMessage`, objectstack#11588 — instead of testing the error's + class. Both of that predicate's conditions travel, because both are load-bearing: + a capability denial carries no business message and stays a fault, and a nested + body that **crashed** carries `TypeError: …` and stays a fault too. + + **No status was picked for this route.** It matches what `/data` already answers + for the same producer: the status the body declared, or `400` when it declared + none. A refusal that declares `{ status: 409, code: 'RECORD_LOCKED' }` now + reaches the caller as `409 RECORD_LOCKED` instead of losing both. + + **The sentence a caller receives is byte-identical to what the 500 carried** — + this moves the status, not the prose. The flattened `SandboxError: ` name prefix + is stripped on the rejection path by the same helper the fault path already used. + + No authorable key, accept set or export surface moves; no consumer needs a + change. Clients branching on 5xx to decide whether to retry will stop retrying + these refusals. +- 310760d: `ActionEngineFacade.delete` refuses a nullish id instead of silently skipping it + + **Who this is for: untyped hosts.** A JS host, or a `registerAction` handler + whose context slot is still `(ctx: any)`, can hand `ctx.engine.delete()` a + nullish id — `delete('todo_task', null)`, or an array with a hole in it. Until + now the arm dropped that element on the floor: nothing refused it, nothing + warned, and the call **resolved as though the row had been deleted**. A silent + no-op on a destructive verb is the one failure an untyped caller has no way to + detect, which is why it is worth a line in your changelog rather than a shrug. + + **What changes.** Every id now reaches the engine as written, and the engine's + own delete-dispatch predicate refuses a `where.id` that is not a truthy scalar: + the call rejects with `Delete requires an ID or options.multi=true` where it + used to resolve in silence. In the array form the refusal stops the loop where + the declared member doc already said a failure stops it — ids before the + nullish element are deleted, ids after it are untouched. + + **If a host was leaning on the old behaviour**, filter before you call: + + ```js + const ids = candidates.filter((id) => id != null); + if (ids.length > 0) await ctx.engine.delete('todo_task', ids); + // `delete nothing` is the EMPTY ARRAY (it resolves, deleting nothing) — + // never a null id. An empty array is contract; a nullish id never was. + ``` + + ⛔ **No declaration moves, and this is not a correction of the `string | string[]` + widening that shipped just before it.** That declaration is accurate: it takes a + single id or an array of them, and under it **no typed caller could ever reach + the skipped branch** — the accept set it publishes has never admitted nullish. + The array form, its per-row semantics, its ordering and its empty-array case are + all unchanged and pinned as controls. What moves is only the runtime's + undeclared tolerance for a value three separate statements already excluded: the + published type, the member's own doc comment, and the spec-side pin that reads + «"delete nothing" is the EMPTY ARRAY, never a null id». +- 2b08a72: fix(runtime): a repeated `?version=` on `GET /packages/:id` is refused `400 VALIDATION_ERROR` in the repo's one message, and `@objectstack/rest` publishes the rule that owns it (#17672) + + `GET /api/v1/packages/:id?version=a&version=b` answered **`404`**, with a second + sentence written at that door. This repo already had a landed answer for exactly + that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112 + nested body (#6307) — and one implementation of it, `refuseRepeatedQueryParams` + / `repeatedQueryParamMessage` in `packages/rest/src/query-multiplicity.ts`, + whose header is the authority on the rule. + + Driven before the change, one host, three refusals: + + ``` + GET /packages/com.acme.crm?version=a&version=b -> 404 RESOURCE_NOT_FOUND + GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + ``` + + A client branching on the answer could not tell "your request named the + parameter twice" from the two genuine not-founds. After: + + ``` + GET /packages/com.acme.crm?version=a&version=b -> 400 VALIDATION_ERROR + GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND + ``` + + The body is the dispatcher's declared envelope — + `{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }` + — with `VALIDATION_ERROR` derived by `buildApiError` from + `standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400. + ⛔ Nothing in `packages/spec` moves. + + **What was actually blocking this was reachability, not judgement.** + `@objectstack/rest` declares exactly one export subpath and that module was not + on it, so #17668 could neither call the rule nor (correctly) copy it, and + shipped the `404` with its own sentence instead. The barrel now publishes + `repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, and the dispatcher + domain calls the message function — so the sentence a caller is told for a + repeated parameter is the same one on every door that carries the rule, ⛔ never + a second copy that drifts. + + ⚠️ The two published symbols are not interchangeable across a package boundary, + and the barrel entry says so. `repeatedQueryParamMessage` is the portable half: + a pure function of two primitives. `refuseRepeatedQueryParams` writes the bare + ADR-0112 body onto a `res`, which suits handlers of that shape and ⛔ not a + runtime dispatcher domain — measured, its body fails that surface's + `BaseResponseSchema` with `success is missing, must be a boolean`. + + **Not a breaking change, measured rather than assumed.** The `404` it replaces + was introduced by #17668 (`1a25f4a8d`), which is not an ancestor of + `@objectstack/runtime@17.4.0` (exit 1; two control commits from that tag's own + history answer exit 0 on the same predicate, in a checkout + `--is-shallow-repository` reports `false`). It has never been published, so no + released consumer can have branched on it. Everything else about the door is + unchanged: `?version=` and `?version=latest` still serve the + installed row, an absent version and an unknown id still answer `404`, and a + one-element array is still one occurrence. + + Also corrected, on the module that owns the rule: its header said the + dispatcher's `/packages` domain "reads no `version`" — load-bearing prose, + since it is part of why the rule needs only one home. That stopped being true + when #17668 landed. The paragraph now states what is true, which is that the one + home did not move and now serves two doors. +- 758ac40: refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) + + The predicate that decides whether a sandboxed body's `throw` is a business + REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) + had **three byte-identical copies** — measured, one distinct 74-character regex + literal across three packages: + + | copy | package | its stated reason for being a copy | + |:--|:--|:--| + | `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | + | `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | + | `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | + + ⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of + them survives moving the rule.** `@objectstack/types` now owns + `isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence + of a bare `Error:`. All three packages already depend on it and it depends on + none of them, so this fold **adds zero dependency edges** and cannot cycle. + + ⚠️ The hazard was never style. One copy learning a new native error name and the + others not means the same throw is a refusal at one door and a crash at the + next — a crash message **leaked** at one boundary and **withheld** at another. + #16013's argument for extracting exactly this class applies verbatim: the + classification is the part nobody may get wrong, so one *tested* helper is worth + more than N correct copies that must each stay correct forever. + + ⛔ **No behaviour changes at any door, per case.** This is a pure refactor and + the three WRAPPERS are deliberately NOT folded, because they are not the same + shape and merging them would move a door's answer: + + - rest asks a trimmed message and answers a boolean; + - objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — + because a code hook and a sandboxed body carry the native name in different + places; + - runtime asks the trimmed inner message and answers the **message**, not a + boolean. + + What the three share is the predicate, so the predicate is what moved. Each call + site keeps its own slot choice and its own trimming, and `isNativeErrorName` + deliberately does **not** trim for its callers — a contract pinned in its test. + + **Shipped rather than `skip-changeset`**, measured on a real build: all four + packages publish `files[]: ["dist", …]`, and the built `dist` of each carries + the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, + `@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` + scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. + The retired copies are gone from the artifacts too: the regex literal scores + **0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in + `types/dist` (the ESM and CJS bundles). + + `@objectstack/types` takes **minor**: a new export is a purely additive widening + of a published surface, which is at least minor whatever the commit type says. + The three consumers take `patch` — their artifacts change, their behaviour does + not. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [ee6fbd7] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [dc709b2] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [bdb247d] +- Updated dependencies [c9246fa] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e743fb5] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [7e74af3] +- Updated dependencies [7c2c5ae] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [2266438] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [4062aef] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [7173d7d] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/service-cluster@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 806bd2c1de..574be8b7a5 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack Core Runtime & Query Engine", "type": "module", diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md index 8b049eb7eb..4438b74876 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,19 @@ # @objectstack/sdui-parser +## 17.5.0 + +### Patch Changes + +- f55922f: `dashboard-widget-options.ts` header: `stageOrder` is a `funnel`-only key, not `funnel` / `pyramid` + + The accepted-set census comment at the top of the module (carried into the + published `index.d.ts`) described `stageOrder` as "funnel/pyramid stage order". + There is no `pyramid` widget type: `ChartTypeSchema` refuses it, so an author + who copied the pair got a parse refusal. The line now says what the schema's + own `.describe()` says: `funnel` is the only widget type that reads the key. + Comment-only — the accepted set, the diagnostic code and the emitted JS are + unchanged. + ## 17.4.0 ## 17.3.0 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index ec3eb36b9c..4314bdc9ef 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.", "main": "dist/index.js", diff --git a/packages/services/service-analytics/CHANGELOG.md b/packages/services/service-analytics/CHANGELOG.md index c49f233bab..71edc798cb 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,1055 @@ # Changelog — @objectstack/service-analytics +## 17.5.0 + +### Minor Changes + +- e526556: fix(service-analytics): a `min`/`max` over a `formula` field is typed from the formula's declared `returnType`, not described as `number` (#16236) + + > ⚠️ **Superseded within the same release window — ⛔ do not act on this entry.** + > Everything below was accurate when it was written and is kept as the record of what + > #16236 measured and built. It never reached a published version: **#17560** (director + > ruling, decision batch #127, 2026-09-13) refuses `min` / `max` over a `formula` field + > outright, on the compatibility table's own storage ground — a formula is VIRTUAL in SQL + > storage, no column is emitted, so no aggregate can be lowered to it whatever + > `returnType` says. At the version that compiles this entry such a measure answers + > `DATASET_INVALID` / **400** at compile time instead of carrying any `fields[].type`, and + > the `returnType?: string` member described at the foot of this entry is **not** on + > `AnalyticsServiceConfig.sourceFieldMeta` — it was added and removed inside one release + > window, so no published version ever carried it. ⇒ Read #17560's entry instead; the + > FROM → TO below never became a shipped behaviour. + + **Behaviour change — read this if any dataset measure aggregates a `formula` + field.** `AnalyticsResult.fields[].type` for such a measure column was always + `number`, whatever the formula computes. It is now translated from the field's + declared `FieldSchema.returnType`: + + ``` + FROM {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], + "fields":[{"name":"first_label","type":"number"}, + {"name":"latest_due","type":"number"}]} + + TO {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], + "fields":[{"name":"first_label","type":"string"}, + {"name":"latest_due","type":"time"}]} + ``` + + Both values were strings; both descriptors said `number`, so a renderer that + branches on the declared type never reached its textual or temporal branch. + + **The mapping is a TRANSLATION, not a pass-through.** `returnType` speaks the + authoring vocabulary (`number` / `text` / `boolean` / `date`); + `fields[].type` speaks `DimensionType` (`string` / `number` / `boolean` / + `time` / `geo`). Two of the four words do not exist on the wire at all: + + | declared `returnType` | `fields[].type` | + |:---|:---| + | `text` | `string` | + | `date` | `time` | + | `number` | unchanged — the producer's `number` is already correct | + | `boolean` | unchanged — three readings disagree on what `min`/`max` over a boolean returns | + + **A formula with no `returnType` is unchanged.** The key is optional — "absent + when the type can't be proven (an ambiguous/`dyn` expression)" — and an + unproven formula's measure column keeps the `number` it had. The absence is not + read as an answer. That tier is written down as a row in `measureResultType`'s + own table rather than left as an implied code path, and so is the treatment of + a word outside the declared four: left alone, never guessed at. + + **For hosts wiring `AnalyticsService` directly.** `AnalyticsServiceConfig`'s + `sourceFieldMeta` hook gains an optional fourth member on its return — + `returnType?: string` beside `type` / `defaultCurrency` / `max`. Additive: a + host that returns the three-member shape still satisfies the contract and gets + exactly today's behaviour for every column. `AnalyticsServicePlugin` relays the + key automatically, so a host on the plugin needs no change at all. + + ⚠️ **Superseded — see the banner at the top.** #17560 removed that member again in + the same release window, so the shape a host writes against is the three-member one + this paragraph calls today's. Nothing to do either way: a host that returns the + fourth key is ignored, not refused. +- 0252320: feat(service-analytics)!: `min` and `max` are judged by the aggregate × field-type table too — all 74 refused pairs answer `400 DATASET_INVALID` through one compile door (#17560) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, and the last + one this table owed. A dataset measure pairing `aggregate: 'min'` (or `'max'`) with any + of the **37** field types outside the numeric, temporal and boolean classes — for example + `text`, `select`, `lookup`, `autonumber`, `json`, `multiselect`, `file`, `location`, + `vector` or `formula`; the ADR-0087 entry registered below carries the full list — used to + compile and reach the backend; it is now refused by + `compileDataset` with `DATASET_INVALID` / **400** before any query is built. Shipped as + `minor` under the repo's launch-window convention for accept-set narrowings. + + ⛔ This changeset adds no rows to any table and restates none. The verdict is + `AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in + #16353 under the director ruling of decision batch #59 ("both legs, table in spec") — + read through `isAggregateCompatibleWithFieldType`. + + ## What was wrong + + The table refused these 74 pairs from the day it was declared, and **four declarations + gave three different answers about them**: + + | declaration | what it said about `min` × `text` | + |---|---| + | `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec) | refused | + | `dataset-compiler`'s compile leg | never judged — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` | + | `measureResultType` (service-analytics, #15768) | a supported `'string'` result | + | two shipped test files, in prose | "ruled C — the table is to be AMENDED to accept it" | + + Driven through the real service door before anything was written, `min` / `max` over 13 + sampled refused pairs all compiled and emitted SQL, with `avg` × `datetime` as the + firing control (refused, `DATASET_INVALID` / 400, no SQL) — so the zero was a reading of + the tree rather than of a blind harness. + + The fourth row had nothing behind it. The card it cited (#17513) is closed as a + duplicate carrying zero rulings, and the one recorded ruling on this table says the + opposite. ⇒ The director ruling of decision batch #127 (2026-09-13) settled all three + sub-questions in one pass, because one shared fixture drove members of both halves: + + 1. **the string classes** (42 pairs) stay refused, as batch #59 ruled — ⛔ the table is + not amended; + 2. **the non-string classes** (32 pairs) are refused **and enforced**; + 3. **`formula`** is refused on the table's own storage ground — it is VIRTUAL in SQL + storage, no column is emitted, so no aggregate can be lowered to it whatever + `returnType` says. + + The divergence is real, and for these two aggregates it is the **ORDER** rather than the + arithmetic: string order is collation-dependent, so two backends answer two different + "smallest" values for one metadata document, and `min(jsonb)` does not exist on + PostgreSQL at all. + + ## What changed + + - **`dataset-compiler`**: the scope condition is gone. `assertAggregateFieldTypeCompatible` + judges all six `AggregationFunction` members against the table, through the same + `DATASET_INVALID` / 400 door. The refusal message names the divergence its own + aggregate class really has (`min` / `max` SELECT a stored value and diverge on order; + `sum` / `avg` DERIVE a number and diverge on arithmetic) and prescribes accordingly. + - **`measureResultType`** asks `isAggregateCompatibleWithFieldType` before it answers, so + the rule and the table agree **by construction**. Its `STRING_SOURCE_FIELD_TYPES` + branch and its `formula` branch are retired with them; `min` / `max` over the temporal + class still answers `'time'`, unchanged. + - **`AnalyticsServiceConfig.sourceFieldMeta`** no longer declares `returnType`. It was + carried (#16236) for one reader — the retired `formula` branch — and a declared input + nobody consumes is the declared-not-enforced shape Prime Directive #10 refuses. + + ⚠️ **That key was never released, so against every published version this removal is a + no-op.** #16236 is still a pending changeset in the same release window as this one; + the last published entry (17.4.0) says in as many words that `FieldSchema.returnType` + "is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape". The key was + therefore added and removed inside one window and no published tarball ever carried it. + + **Host fix, one line:** drop `returnType` from whatever your `sourceFieldMeta` returns. + You do not have to — the hook is a function RETURN position, so an extra key is not an + excess-property error and is simply ignored at runtime — but keeping it declares an + input nothing reads. Hosts on `AnalyticsServicePlugin` need no change at all: the plugin + stopped relaying the key in this same change. + + ## FROM → TO, and the one-line fix + + | you wrote | write instead | + |---|---| + | `{ aggregate: 'min' \| 'max', field: }` | `count` / `count_distinct` if you were counting; a **sort** on the list/report if you wanted the first or last RECORD | + | `{ aggregate: 'min' \| 'max', field: }` | store the quantity you meant as a numeric or temporal field and aggregate that | + | `{ aggregate: 'min' \| 'max', field: }` | a formula emits no column; aggregate the stored field the formula reads, or persist the computed value | + + ⚠️ **Untouched:** those field types used as a **DIMENSION** (grouping, labelling, + bucketing, filtering), `count` / `count_distinct` over any type, `min` / `max` over the + numeric, temporal and boolean classes, and every `sum` / `avg` row #16778 and #16099 + already settled. The refusal also still stands down rather than guessing wherever the + declared type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a + `relationship.field` path whose column lives on a joined object. + + ⚠️ The hand-migration prescription ships as the ADR-0087 semantic TODO registered above, + which names the measure and the field type per affected pair — no lossless conversion + exists, because nothing can compute "the smallest text value" in a way every backend + agrees on. +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 5d12b16: fix(service-analytics): the ROW-SCOPE bridge to the `security` service tells the same three resolutions apart as the object-level one — a broken security service refuses the query instead of running it with no row policy (#16918) + + `AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead` → `canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope` → `getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one: + + ```ts + const trySecurity = () => { + try { + const svc = ctx.getService('security'); + return svc && typeof svc.getReadFilter === 'function' ? svc : undefined; + } catch { return undefined; } + }; + getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context); + ``` + + A throwing resolver and a registered service without `getReadFilter` both produced `undefined` — the same value an absent security service produces, and the value `ISecurityService.getReadFilter` reserves for one meaning only: *"this caller has no row restriction on this object"*. So on a deployment whose security service was wired but broken (a boot-order fault, a mis-registered plugin, a failing dependency, a provider that is not the contract it claims to be) analytics queries ran with **no row-level policy at all**, and nothing said so. One door of the file failed closed on a throwing resolver and its neighbour failed open — and the neighbour is the one carrying row-level policy. + + **What changes.** The bridge now resolves the same explicit three-way, at the same reporting level: + + - **ABSENT** — no `security` service resolved: **unchanged**. No row-scope provider on this deployment, which is a legitimate configuration (a single-tenant kernel that ships no `plugin-security`, where `/data` carries no row-level policy either) and is already reported loudly at init. ⛔ Deliberately not tightened: refusing here would break every such deployment. + - **THROWING** resolver, or a registered service with **no `getReadFilter`** — the query is **REFUSED**, and the reason is reported at `error` naming the object and which of the two states it was. The refusal is a throw, which `AnalyticsService.resolveReadScopes` — fail-closed since ADR-0021 D-C — already turns into "deny the whole query rather than emit SQL with that object unscoped". A log over an `undefined` would not have been a refusal. + + **This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service, and no deployment with none, changes behaviour by so much as a byte. Nothing that was refused becomes admitted. + + **No published-surface delta.** No new error code (the refusal rides the seam's existing fail-closed error), no exported symbol, no key on `AnalyticsServicePluginOptions` or any payload, and no documented envelope changes shape. Graded `minor` rather than `patch` because it is a behaviour narrowing on a published package's read path, matching how its object-level sibling was graded in the same lockstep window. + + ⚠️ Deliberately **not** answered here: which tenant wall the platform's is (plugin-security's posture-gated Layer 0, or driver-sql's posture-independent auto-scope) — the escalated maintainer decision of triage condition 5. Refusing to serve is neutral between them: it answers *"should we serve at all"*, never *"what shape is the wall"*. +- 634f23d: fix(analytics)!: `AnalyticsServiceConfig.sqlDialect` declares its three-name accept set, and a host that answers outside it is told once (#16206) + + + + **BREAKING** for a TypeScript host that declares its `sqlDialect` hook as returning + `string`: the hook's declared return is now the three canonical dialect names or + `undefined`, so such a composition stops compiling until the host's own annotation + says which names it can answer. Shipped as `minor` under the repo's launch-window + convention, in which breaking-ness is carried by this banner and the disposition + above rather than by the bump level. Runtime behaviour for every host is unchanged: + the same three names were the only ones that ever did anything. + + ## What was wrong + + `AnalyticsServiceConfig.sqlDialect` — the hook a host answers to say which SQL + dialect backs an object — was typed as free `string`, while `normalizeSqlDialect` + has only ever recognised `sqlite`, `postgres` and `mysql`. Nothing said so, and + nothing told a host that answered otherwise. + + So a host that owns a SQLite datasource and answers the spelling its own stack uses + — knex's canonical `sqlite3`, or `better-sqlite3`, both of which `driver-sql` itself + lists in `SQLITE_EMIT_CLIENTS` — was read as `unknown`. And because `sqlDialectFor` + is tiered "cannot answer, do not block", **a wrong answer and no answer were the + same answer**: the host that tried hardest to help got the residue arm, silently. + + ## What it does now + + - **The vocabulary is declared**, on the type and in the docblock, as + `AcceptedSqlDialect` — `sqlite` | `postgres` | `mysql` — so a host reading the + config learns the accept set without running anything. The type and the runtime + membership set are generated from one `const` tuple, so a future widening cannot + land in one and miss the other. + - **A non-empty answer outside the set is diagnosed**: one `warn` naming the object, + the answer and the accepted set. It is emitted **once per distinct unrecognised + spelling** — the failure's identity — so the line count is bounded by the host's + own hook and never grows with query volume. + - **`undefined` stays silent and legal.** The hook is optional and "cannot answer, + do not block" is a supported composition, not a misconfiguration. A pin holds both + halves, because a diagnostic that also shouted at hosts who wired nothing would be + a worse defect than the one being fixed. + - **The accept set is NOT widened.** Teaching this package `driver-sql`'s knex + aliases would be a second copy of that driver's table, and an unrecognised + spelling is sometimes deliberate (`mariadb`, #11756). The answer is still read as + `unknown`; only the silence changed. + - **The plugin bridge translates the driver's own residue.** `SqlDriver.dialectName` + carries a fourth name, `unknown`, meaning "I cannot say"; handed on verbatim it + would have presented a correctly-behaving driver as a host answering out of + contract. It now arrives as `undefined`, this hook's own spelling for the same + thing. The dialect the compilers end up with is unchanged either way. + + ## Measured, and worth reading before relying on the residue arm + + Driven on sql.js through a host answering `sqlite3`, against the shared + `FILTER_TEXT_CASES` fixture, with a host answering `sqlite` as the control: **five of + the six case-EXACT cases come back with the wrong rows** — every case that + discriminates on ASCII case. `{ name: { $contains: 'acme' } }` answers `['1','2']` + where the table says `['2']`, and the negated form DROPS a row that belongs in the + result. That is #15684's fold, live on the arm this population lands on, and it is + reported rather than fixed here: closing it is that card's business, not this one's. +- 357f499: feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. A dataset + measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to + `AVG(col)` and reach the backend; it is now refused by `compileDataset` before any + query is built. Shipped as `minor` under the repo's launch-window convention for + accept-set narrowings; the hand-migration prescription is registered under protocol + major 18 as `dataset-measure-aggregate-field-type-refused`. + + The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table + `@objectstack/spec` declared in #16353 under the director ruling of decision batch + #59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and + restates none: the refusal reads the shipped predicate, so the contract has exactly + one statement. + + ## What was wrong + + The answer to `AVG` over a temporal column was decided by the SQL dialect rather + than by the data. Both halves measured on this card: + + ``` + -- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) + select typeof(submitted_at), submitted_at from clm_contract limit 1; + text|2026-05-19T00:00:00.000Z + select avg(submitted_at) from clm_contract; + 2025.5 <- text->numeric coercion: the average YEAR + + -- PostgreSQL 16.13 + select avg(submitted_at) from t; + ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 + ``` + + The silent half is the dangerous one, and SQLite is the default dev datasource: + `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned + `-0.85` and rendered on a tile labelled "average cycle time delta" — a number + indistinguishable from a correct one. Nothing refused it at any layer: not the + schema, not `os validate` / `os lint`, not the analytics service, not the renderer. + + ## What it does now + + - `compileDataset` refuses an incompatible `aggregate` × `field` pair with + `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and + the accepted set (read off the table, never restated). Nothing reaches the driver. + - It reads the declared type from the `sourceFieldMeta` a host already wires, via a + new optional `DatasetCompileOptions.declaredFieldType` probe. + - **`derived` is covered by construction.** A derived measure's `of` operands are + base measures of the same dataset, so a dataset carrying a refused base measure + never finishes compiling and no `derived` op can be handed its output — including + when the selection names only the derived measure. + - Tiered "cannot answer, do not block" like every sibling probe: no + `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose + column lives on a joined object) leaves the pair unjudged. + + ## ⚠️ Scope: the compile leg executes the TEMPORAL rows only + + > ⚠️ **Superseded within the same release window.** This section was accurate when it was + > written and is kept as the record of where the compile leg stopped. Two later cards + > widened it before any of the three entries shipped, so at the version that compiles this + > entry the scope below is no longer the platform's: **#16099** judged `sum` / `avg` over + > every remaining field class (including `sum` over a `percent`), and **#17560** (director + > ruling, decision batch #127, 2026-09-13) judged `min` / `max` over every class the table + > refuses. ⇒ Three sentences in this section are false at that version and are corrected + > where they stand: the string rows are **not** awaiting a table amendment, `sum` over a + > `percent` does **not** compile as it did before, and `avg` / `sum` over a temporal field + > are **not** the only pairs whose behaviour changes. Read all three entries together. + + The gate judges only a measure whose field is declared `date` / `datetime` / + `time`; a field of any other class is never handed to the predicate. The + verdict for the pairs it does judge is the table's — no row is restated — but + which FIELDS are judged is narrower than the table, on purpose: + + - **String rows** (`min` / `max` over `text`, `select`, `lookup`, + `autonumber`, …) are **not enforced here**. ⚠️ This card recorded them as + 「under #16785, **ruled C** — the table itself is to be amended to accept + them」, because `measureResultType` (#15768) already typed those results as + `'string'` and pinned them end to end, so enforcing them from here would + pre-empt that ruling. **Both halves of that sentence turned out to be + wrong.** `16785` resolves to no issue, and decision batch #127 (#17560, + 2026-09-13) found no ruling C anywhere behind the citation — the one recorded + ruling on this table, decision batch #59, refuses the string rows. ⛔ The + table is **not** amended; #17560 enforces those rows and retires the + `measureResultType` opinion that disagreed with them. + - **Boolean rows** are not a refusal at all any more: #16685 was ruled A and + #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the + table ACCEPTS them and this gate never judged them. + - The table's `sum` × `percent` row is likewise **not** executed by this leg; + `sum` over a `percent` compiles exactly as it did before. ⚠️ True of this + card only — #16099 executes that row in the same release. + + ⇒ The only pairs whose behaviour changes **because of this card** are `avg` / + `sum` over a `date` / `datetime` / `time` field. ⚠️ ⛔ Not a statement about the + release: the full-table leg is #16099's and landed, and the `min` / `max` leg is + #17560's and landed, so at the shipping version every pair the table refuses is + refused at the compile door. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | + | `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | + | `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | + + ⭐ A duration is not recoverable from an aggregate over instants on any backend. + Where an "average cycle time" is wanted, the cycle length has to exist as a number + before it can be averaged. + + ## What is deliberately untouched + + `date` / `datetime` used as a **dimension** — grouping, bucketing, date-range + filtering — is unchanged; this is about aggregation only. `avg` over a genuine + numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` + over anything all behave exactly as before. + + ⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in + `compileDataset` and reads a `declaredFieldType` probe, so it applies only where + a host wires one: `/analytics/query` — the non-dataset face, whose measures a + Cube infers rather than an author declaring them — is NOT covered, and neither + is any other `compileDataset` caller that passes no probe (those stand down + unjudged rather than guessing). Closing those is #16099's, not this card's. + + Alongside the refusal, `service-analytics`' contradictory annotations about what a + SQLite `Field.datetime` column physically holds are reconciled to one statement — + **seven** source sites plus two test narratives, not the four the card quoted. Some + said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it + IS an INTEGER epoch. Neither is current: since #3912 the column has ONE + storage form, canonical UTC text, with the epoch surviving only in a database not + yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on + `AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. + No behaviour changes from that half. +- 3c557e2: **The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains + one new optional key, `translateSelectOptions`** — the surface the level is graded against, + per the same "a new key on a published exported type is the mechanical floor for clause ②" + rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key, + no wire-shape change), so `minor` rather than `major`. + + A dataset's `select`-field dimension now renders its option label in the request's locale on + a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list + grid) already renders for the identical field. + + `dimension-labels.ts` resolved a select dimension's category label straight out of field + metadata's authored `options[].label` — always the author's own-language text, since + `SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted + cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field + name never matches a key in the BASE object's own field map, so `resolveDimensionLabels` + skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on + that path, and a regression test now pins that it is never even consulted. + + `DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the + plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`) + — the SAME translator the object-metadata REST endpoint already uses — against the + deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec + key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before, + and a kernel with no i18n service configured (or nothing for the requested locale) falls back + to exactly today's authored-label text. + + A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup / + master_detail labels resolve through the separate `fetchRecordLabels` capability (a related + RECORD's display name, not a field's authored `options[]`), which this change does not touch. + It does lower the cost of adding translated lookup-record labels later, though — the i18n + service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already + wired into this package and is a `ctx.getService('i18n')` away from reuse. +- e66da5c: feat(service-analytics)!: a dataset measure applying `sum` or `avg` to a field whose declared type cannot carry it is refused at compile time, for every field type and not only the temporal class (#16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, continuing the + one #16778 began. A dataset measure pairing `aggregate: 'sum'` with a `text` field (or + `avg` with a `select`, `json`, `lookup`, `formula`, … field) used to compile and reach + the backend; it is now refused by `compileDataset` with `DATASET_INVALID` / **400** + before any query is built. Shipped as `minor` under the repo's launch-window convention + for accept-set narrowings. + + ⛔ This changeset adds no rows to any table and restates none. The verdict is + `AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in + #16353 under the director ruling of decision batch #59 ("both legs, table in spec") — + read through `isAggregateCompatibleWithFieldType`. + + ## What was wrong + + #16778 landed the compile leg SCOPED to temporal source fields, leaving "every other + non-temporal pair the table refuses" as a stated residual that had never been driven. + Driven on this card, through the real service door: + + ``` + sum × text the table refuses the pair the compile leg does NOT throw SQL IS emitted + sweep 6 aggregates × 49 field types = 294 pairs; 155 refused by the table; + minus 6 temporal (#16778's) minus 42 `min`/`max` × the string classes; + residual 107 — and 107 of 107 were ACCEPTED by the compile leg + control avg × datetime / date / time → DATASET_INVALID / 400, no SQL emitted + ``` + + The control is what makes that a reading of the tree rather than of a blind harness: the + same service, door and `sourceFieldMeta` hook sees the pairs #16778 enforces refused. + + So `sum` over a `text` column reached whichever backend the object is bound to, and the + answer was a property of the dialect rather than of the data — the shape Prime Directive + #12 exists to remove, and the same shape #16778 closed for one field class. + + ## What it does now + + - `compileDataset` judges a measure whose aggregate DERIVES a number (`sum` / `avg`) + against the table for **every** declared field type, and refuses an unaccepted pair + with `DATASET_INVALID` / **400** — naming the measure, the field, its declared type + and the accepted set read off the table. Nothing reaches the driver. + - `sum` × `percent` is refused at last: the row `analytics-service.ts` has called + "incoherent" in a comment since before the table existed. `avg` × `percent` is still + ACCEPTED by the same table, which is what makes it a row and not a class. + - The refusal's closing prescription is now chosen by the source field's class: the + temporal sentence #16778 measured is kept verbatim for temporal fields, and a + non-numeric field is pointed at `count` / `count_distinct`, which accept every type + because they read no arithmetic off a value. + - Unchanged: `derived` is covered by construction (a dataset carrying a refused base + measure never finishes compiling), and the three "cannot answer, do not block" tiers — + no `sourceFieldMeta`, an unresolvable field, a `relationship.field` path. + + ## ⚠️ Scope: the DERIVING aggregates — and see #17560, which closed the other half + + > ⚠️ **Superseded within the same release window.** This section was accurate when it was + > written and is kept as the record of why this change stopped where it did. #17560 + > (director ruling, decision batch #127, 2026-09-13) then judged `min` / `max` too, so at + > the version that ships this entry **every** pair the table refuses is refused at the + > compile door. Read that entry beside this one. + + `min` / `max` SELECT one of the stored values; `sum` / `avg` DERIVE a number. This is the + line this package already draws — `measureResultType` branches on exactly that pair of + aggregates — and the defect is about a derived number, so the deriving aggregates are its + population. + + The `min` / `max` rows stayed with the table-amendment card (then **#17513**, since closed + as a duplicate of **#17560**, which ruled and landed them), and that is measured rather + than assumed. + Enforcing the residual whole was tried on this card: with `min` / `max` × the string + classes subtracted, **15** cases in `measure-result-type.test.ts` still went red, every + one of them on `min` × `json` — a pair the table refuses, in no ruling's scope, driven + end to end by the same shared fixture as the string rows. One dataset compiles every + measure in that fixture, so one refused pair reds the whole section. ⇒ `min` / `max` is + one question, and it is the table-amendment card's. + + ## Upgrading — FROM → TO + + Nothing an author writes is removed or renamed: `DatasetMeasure.aggregate` and + `DatasetMeasure.field` keep their spellings and their types. What narrows is which PAIRS of + values are accepted. The one-line fix, per shape: + + | FROM (compiled before, refused now) | TO | + |---|---| + | `{ aggregate: 'sum', field: }` | `{ aggregate: 'count_distinct', field: }` — counting reads no arithmetic off the value | + | `{ aggregate: 'sum' | 'avg', field: }` | store the quantity you meant as its own numeric field and aggregate that | + | `{ aggregate: 'sum', field: }` | aggregate the formula's numeric INPUT column; a `formula` is virtual in SQL storage, so no arithmetic aggregate can be lowered to it | + | `{ aggregate: 'sum', field: }` | `{ aggregate: 'avg', field: }` — a rate averages, it does not add | + | `{ aggregate: 'sum' | 'avg', field: }` | unchanged from #16778: use `min` / `max` for a real instant, or store a duration as a number and aggregate that | + + `min` / `max` are **not** affected by this change at all, over any field type. + + No shipped dataset in this repository declares a newly-refused pair — every one of the + eleven shipped dataset measures resolves to `number`, `currency`, `summary` or `progress`. + The refusal names the accepted set for the aggregate, read off the table. +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- 86c5052: fix(analytics): a `dateRange` array that is not a two-bound window is refused, once, instead of meaning three different things (#17124) + + `AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no + length constraint, so `dateRange: ['2026-01-01']` is schema-valid and reaches the + analytics faces through `POST /analytics/dataset/query`, which types its selection + from `AnalyticsQuery` and never Zod-parses it. The four faces in this package that + read the arm answered it three different ways — measured over one authored + document and four rows: + + | face | `['2026-01-01']` meant | + |---|---| + | `ObjectQLStrategy.dateRangeBounds` | the point window `created_at >= '2026-01-01' AND <= '2026-01-01'` | + | `NativeSQLStrategy` | no time clause at all — the whole dataset | + | the draft-preview evaluator | an upper bound of the string `"undefined"`, which every ISO date sorts below — everything from that day onward | + | `DatasetExecutor`'s `compareTo` pass | the point window, shifted — compared against a primary pass that may have read all of history | + + For a dashboard that is one day's number, the whole dataset's, and everything + from that day onward, from the same document, decided by which backend answered. + `[]` and `[a, b, c]` split the same three ways, and `[null, null]` reached + `parseUTC(null)` as a bare `TypeError` — a 500 for a malformed request. + + One rule is now the single reading of the arm and all four faces call it; the + three divergent fallbacks are deleted. An array that is not exactly two string + bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 + envelope — the answer the contract already gives for a `dateRange` that does not + denote a window. A two-element window is untouched on every face, bound for + bound, including the inclusive upper reading a caller's bounds keep (#16179) and + the half-open bare-day widening on the SQL side (#3777). + + ### Write both bounds + + | wrote | write instead | + |---|---| + | `dateRange: ['2026-01-01']` | `dateRange: ['2026-01-01', '2026-01-01']` | + + That spelling already selects exactly that one day on every face, and it is the + same instruction #16322 shipped for the single-day string dialect. + + ⭐ Shipped as `patch`, not as a breaking narrowing, because nothing DECLARED + moves. The spec's own refusal wording already states that *"an explicit window is + the two-element array [start, end] of ISO dates or {date-macro} tokens"*, and + #16322's shipped migration table already told authors to write a single day as + `['2026-01-20', '2026-01-20']`. A one-element array was therefore never a valid + document; it was an invalid one that four faces answered arbitrarily, and a + behaviour that was never one behaviour is not a behaviour this removes. The Zod + type admitting the shape is weaker than the contract the same file states — + tightening it is a separate, spec-owned question. +- 40098a4: fix(service-analytics): an unrecognised `compareTo.kind` is refused, not answered with a previous-period window under a 200 (#17550) + + `shiftRange` had one branch and a fall-through — `previousYear` was named, and + **everything else** landed in the `previousPeriod` arm. No `default`, no + exhaustiveness check. So `compareTo: { kind: 'previousQuarter' }` came back as a + previous-period comparison under an ordinary **200**, and the caller was told + nothing. The wrong answer is a comparison **window**: a number a dashboard + renders and a person reads as fact, with no status, header or field in the + response to distinguish it from a real answer. + + `DatasetCompareTo.kind` has only ever declared two values + (`'previousPeriod' | 'previousYear'`), but `DatasetSelection` is a TypeScript + interface with no Zod schema anywhere, and `/analytics/dataset/query`'s door + parses only the seven members the selection shares with `AnalyticsQuery` — + `compareTo` is one of the four it projects away before its parse, and the route + forwards the caller's selection to the service untouched. So `kind` was checked + by `tsc` inside this repo and by nothing at all on the wire. + + ## FROM → TO + + | Input | Was | Now | + |:--|:--|:--| + | `compareTo: { kind: 'previousPeriod' }` | the equal-length window before | **unchanged** | + | `compareTo: { kind: 'previousYear' }` | the same window one year back | **unchanged** | + | `compareTo: { kind: }` | a previous-period window, **200** | `DATASET_INVALID` / **400**, naming the value received and both legal ones | + + The fix is to name one of the two declared windows, or drop `compareTo` — which + is what the refusal says. No accept set widens, no new error code is minted: the + refusal is the fourth member of the `datasetInvalidError` family + `resolveCompareDimension` already raises three times for the same document, so it + arrives at the route through the envelope that route already classifies on. + + ## Why this is a `patch` + + It pulls behaviour back onto the contract the type has always declared, rather + than narrowing past it: every input `DatasetCompareTo` permits returns + byte-identical windows, pinned by a control in the same change. What flips from + 200 to 400 is input the declared contract never permitted. The reachable-today + population for that input was measured on the tree — the dashboard authoring path + is already doored (`DashboardWidgetSchema` parses the widget's `kind` as a + `z.enum`, so a third kind cannot arrive through a parsed widget), and no producer + in this repository sends a third value. What is not enumerable from here is a + consumer outside it calling the published `shiftRange` export, or posting a + hand-rolled body to the dataset route; for those, the refusal replaces a wrong + answer with a located one. + + `alignedCompareBucketKey` reads the same two-valued `kind` and deliberately gains + no refusal of its own: it is not on the package's public surface, and its only + caller runs `shiftRange` first — both pinned, so exporting it turns the pin red + rather than silently reopening this defect. +- 113050e: A dataset dimension over a `user` or `tree` field renders the referenced record's display name, the same way a `lookup` dimension already did. A "by person" chart's axis is people's names, not a column of user ids. + + `packages/spec` declares one reference class — `REFERENCE_VALUE_TYPES` = `lookup`, `master_detail`, `user`, `tree`, "value points at another record … a record-id string in stored form" — and this service already treated it as one class where it annotates measure result types (`measure-result-type.ts` imports that very set). The label resolver, one file away, hand-wrote a two-member subset of it (`lookup`, `master_detail`), so within a single dataset query one axis came back as a name and the other as a raw id, for two fields that differ in one word: + + ``` + Field.user({ label: 'Person' }) -> { type: 'user', reference: 'sys_user' } + Field.lookup('sys_business_unit', { … }) -> { type: 'lookup', reference: 'sys_business_unit' } + ``` + + - **The subset is gone, not extended.** The resolver now asks `referenceTargetOf` (`@objectstack/spec/data`) — the declared single arbiter of "what does this reference field point at" — at all three sites that classified a dimension: the display pass, the `#3680` sort-key hook's `isLabelBearing`, and its `resolveLabels`. Adding two literals to a private `Set` would have left the next member of the class to be re-reported by the next user. + - **A `user` field authored without `reference` resolves too.** `sys_user` is a constant of the type, which `referenceTargetOf` materializes; requiring an author to restate it is exactly the disagreement between two readers of one field that arbiter exists to end. + - **The label read stays scoped (`#3602`).** Turning a user id into a name is a read of `sys_user`, and it travels the same `LabelScopeResolver` path every other member of the class travels — the referenced object's own RLS is resolved and ANDed into the lookup, and an unresolvable scope still fails closed to the raw id rather than fetching unscoped. This is the half of the change that had to land with it, not after it. + - **Nothing degrades into an error or a blank.** An orphaned or RLS-hidden user id, a `sys_user` with no display field, and a user object unknown to the engine all leave the raw id in place and answer the query, which is the pre-existing contract for an unresolved lookup id. + + No new authorable key and no new export: `DatasetDimensionSchema` is untouched, and a dimension's own declared `type` still does not decide this — the resolver reads the object field's type, as it always has. +- 54b3d1d: fix(service-analytics): a fail-closed row-scope refusal can no longer be served as an empty chart (#17130) + + `queryDataset` degrades to `{rows: [], fields: [], totals: []}` when a BARE error looks like a driver reporting an absent table — a deliberate leniency (#5033) so a dashboard widget over an unmounted object renders "no data" instead of failing. The test is a substring match over the message, and three of its six limbs — `not registered`, `unknown object`, `is not a registered object` — are exactly the phrasings a registry or security refusal reaches for. + + Both sites of the row-scope RESOLUTION stage refused with a bare `throw new Error(…)`: the `security` bridge in `AnalyticsServicePlugin`, and `AnalyticsService.resolveReadScopes`. They propagated only because their wording happened to miss all six — so any reword, or any refusal added to that stage later, could silently turn a fail-closed gate into a `200` with no rows. + + Both now declare `READ_SCOPE_COMPILE_FAILED` / `500` — the code the sibling read-scope LOWERING stage has answered with since #5367, so the registered wire vocabulary is unchanged. Two visible consequences for a deployment whose wired `security` service cannot answer a row-level read scope: + + - the refusal reaches the caller as a declared `500` instead of relying on its phrasing to escape the degradation path; + - its message is withheld from the response body by declaration (the operator still gets the full text, at `error`, from the producing site) rather than echoed. + + Every refusal message is byte-unchanged, and #5033's leniency is untouched: a genuine absent source table still degrades to the empty result with its `warn`, and a deployment with NO security service still runs unscoped exactly as before. A guard derived from the source (`refusal-wording-collision.test.ts`) now walks every `throw` in the package and fails if an un-enveloped refusal can be read as a missing source table. +- f3b28eb: Draft-preview analytics: `avg` answers the mean of the NON-NULL operands, and `null` when there are none — matching every live face + + A dataset measure `{ aggregate: 'avg', field: 'amount' }` compiles to the cube + metric `{ type: 'avg', sql: 'amount' }`, and the draft-preview evaluator built + its operand list with `rows.map((r) => Number(r[field]))`. `Number(null)` is `0` + and `Number.isFinite` accepts it, so every NULL entered the average as a zero + OPERAND and was counted in the divisor. `AVG(col)` is defined over non-null + values in every SQL dialect, so a drafted chart showed a different number than + the published one, silently — and where a group's column was NULL in every row + the number it showed was `0`: a plausible-looking average that a reader cannot + tell from one somebody measured. + + Measured on one dataset, one row set, two `AnalyticsService` instances differing + only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated + SQL on a real SQLite). Rows `{meals, null}` and `{meals, null}` answered + `avg_amount` null live and `0` on preview; rows `{travel, 10}`, `{travel, 20}`, + `{travel, null}` answered 15 live and 10 on preview. Both cells now answer the + live number. + + The empty answer is READ from the platform's own ruling rather than restated + here: `emptyGroupValueFor` (`@objectstack/spec/data`) returns the identity `0` + where counting or summing nothing is a measured fact and `undefined` — spelled + `null` on this wire — where there is nothing to answer. It is the same function + `fillEmptyGroups`, `sql-driver` and `driver-turso` read, and the one #16203 cited + when it moved `min`/`max` off the same idiom in this function. + + Unchanged, and pinned by the same differential: `sum` over a group with no values + still answers the ruled identity `0`, `count` over one still answers `0` + (#16218), `min`/`max` still answer `null` (#16203), and `avg` over a group that + has values still answers its mean. `sum` and the numeric `default` arm keep their + existing operand list — `0` is the additive identity, so the coercion never moved + `sum`'s answer, and the `default` arm serves the custom-SQL metric types, which + have no live standard to be moved towards. + + The `null` fires on an EMPTY group and never on an incoherent one. "No numeric + operand" is two different situations: no row carried a value at all — the empty + group the policy rules on — or rows carried values that do not read as numbers, + such as a `date` column under `avg`. The second is an incoherent + aggregate/field-type pair that #16099 owns and no layer refuses yet; it keeps the + numeric identity it has always had, since the live face answers a different + number again (SQLite's numeric affinity over a TEXT column) and a `null` there + would invent a third answer. That boundary is pinned from both sides — by + `preview-aggregate-operand-type.test.ts` (#16203) and by a control in the new + differential. + + The live path is unchanged. + + Bumped `patch` rather than `minor`, on the same reasoning the sibling #16218 + shipped under: the package's published surface is byte-unchanged — `src/index.ts` + is not in this diff and does not re-export `preview-evaluator.ts` at all, and + `aggregate()` is module-private — and the only user-visible effect is a drafted + chart's number moving to the number the published chart already showed. A value + correcting toward the live standard is a fix, not the backwards-compatible + feature addition `minor` denotes. It is a real value change for a consumer + reading the preview response (`0` becomes blank), which is why the card was filed + separately rather than ridden along with #16203 — but the `0` it replaces was + never a number the platform promised. +- fd5cff2: Draft-preview analytics: `count` over a declared field counts its non-null values, matching every live face + + A dataset measure `{ aggregate: 'count', field: 'payer' }` compiles to the cube + metric `{ type: 'count', sql: 'payer' }`, and the draft-preview evaluator carried + that field in and never read it — it answered the ROW count, nulls included, + while every SQL face lowers the same measure to `COUNT("payer")`, defined over + non-null values. A drafted chart therefore showed a different number than the + published one, silently, and the number it showed was the one `count(*)` gives: + the author's choice to count a specific column had no effect on the preview path. + + Measured on one dataset, one row set, two `AnalyticsService` instances differing + only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated + SQL on a real SQLite): rows `{meals, 'bob'}` and `{meals, null}` answered + `payer_count` 1 live and 2 on preview. Both now answer 1. + + Unchanged, and pinned by the same differential: `count` with no field and `count` + with `field: '*'` still answer the row count (the compiler writes + `sql: m.field ?? '*'`, so the star is the "no field declared" spelling), and + `count_distinct` still answers a cardinality. A group in which no row carries a + value counts `0`, never null — `emptyGroupValueFor` rules counting nothing the + identity `0`. + + The live path is unchanged. + + Bumped `patch` rather than `minor`: the package's published surface is + byte-unchanged — `src/index.ts` is not in this diff, `aggregate()` is + module-private and `evaluateAnalyticsQueryOverRows` is not on the barrel — and + the only user-visible effect is a drafted chart's number moving to the number + the published chart already showed, which is a correction toward the live + standard rather than the backwards-compatible feature addition `minor` denotes. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 376df4e580..877a81b434 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-analytics", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)", "type": "module", diff --git a/packages/services/service-automation/CHANGELOG.md b/packages/services/service-automation/CHANGELOG.md index 7bfd87c6e3..a51cf50133 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,652 @@ # @objectstack/service-automation +## 17.5.0 + +### Minor Changes + +- c8a006f: An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A). + + **The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers. + + ``` + FROM service.decide(requestId, { decision: 'approve' }, ctx) + -> { finalized: true, decision: 'approve', runId: '', resumed: true } + // identical to a healthy composition's answer — no caller can tell + + TO service.decide(requestId, { decision: 'approve' }, ctx) + -> { finalized: true, decision: 'approve', runId: '', resumed: true, + resumeError: "RESUME_FAILED: … its own flow run '' resumed, but the " + + "subflow parent above it — run '' — consumed its suspension " + + "and is now stranded: ", + resumeFailure: { code: 'RESUME_FAILED', runId: '', status: 'stranded', repairable: true } } + ``` + + **Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history. + + **What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here. + + **New public surface — the reason for `minor` on both packages, not `patch`.** Getting the parent's strand from the engine to the approvals door without touching `packages/spec` or the wire-visible `AutomationResult` (which a raw REST `POST …/resume` also serves verbatim, so a field there would leak an undeclared key onto every subflow resume, not only an approvals-mediated one) needed a small new internal channel: + + - `@objectstack/service-automation`: `AutomationEngine` gains a new public method, `takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined` — read-once (deletes on read), populated only by `bubbleToParent`'s `'stranded'` exit. `SubflowParentStrand` is a new exported interface (`{ runId, repairable: true, error }`). + - `@objectstack/plugin-approvals`: `ApprovalResumeSurface` (already exported from the package entry) gains a matching optional member, `takeSubflowParentStrand?(childRunId): { runId, repairable, error } | undefined`. + + Both are additive and optional; nothing existing changes shape or behaviour. Neither reaches any wire payload — `AutomationResult`, the REST resume door's response, and every other published contract are byte-for-byte unchanged. +- 2f1a6f6: A flow screen field can now express a numeric bound, help text and a lookup target — spelled with the object field's own key names + + + + `ScreenFieldConfigSchema` was `.strict` over exactly + `name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`, + so three ordinary authoring intents had **no expression at all**. They did not + degrade quietly — `max`, `helpText` and every lookup-target spelling were + refused BY NAME — but a loud refusal with no landing key is still a dead end, + and the reference app worked around all three in prose: a discount ceiling + interpolated into the `label` and the `placeholder` (with a comment explaining + why there was no `max`), and a `type: 'lookup'` field whose `placeholder` asked + a human to type a record id because the picker could not be pointed anywhere. + + Four keys land, and **their names are derived from `FieldSchema`, not invented** + — one platform, one field vocabulary, so a name learned on an object field means + the same thing on a screen field: + + | Key | Derived from | | + |:---|:---|:---| + | `min` / `max` | `FieldSchema.min` / `.max` | the bound pair | + | `inlineHelpText` | `FieldSchema.inlineHelpText` | help under the input — `FieldSchema` renames `help`/`helpText`/`hint`/`tooltip` onto it, so a screen-local `helpText` would have been a second contract for one question | + | `reference` | `FieldSchema.reference` | the object a `type: 'lookup'` field picks records from | + + **The bound is enforced, not advisory.** It rides to the client on + `ScreenFieldSpec` so the user is stopped at the input, **and** + `validateScreenInputs` re-checks it when the run resumes (`min_value` / + `max_value`, both already in the ADR-0114 D2 field-error catalog — no new error + code). A screen field's declared contract is the only contract behind it, so a + bound the dialog alone applied would be bypassed by any caller posting to + `resume` directly — the gap #4477 closed for `required`. + + That sentence needs no "when the value is a number" qualifier, because the + value SHAPE is checked first: on a `type: 'number'` field a present value that + is not a finite JSON number is refused with `invalid_type` (also already in the + catalog — still no new code), ⛔ **not coerced**. Before this, a bound pass that + compares numbers was satisfied by anything that never reached it, so `"25"` + under a `max` of `20` was conformant. One member of the open `type` vocabulary + is read as a value domain; every other widget hint stays open, and a bound on a + non-numeric field still constrains nothing. + + **Delivered with its rendering, not ahead of it.** The executor forwards all + four onto the wire and the Studio designer form offers all four as repeater + columns; `builtin-node-form-zod-ledger.test.ts` reconciles the two key sets + against the Zod in both directions, so a key declared here and absent from the + form fails that test rather than shipping as a field nobody can author. + + **BREAKING** in the accept-set sense, in TWO places — landing as `minor` on + both packages because the launch-window guard (`check-changeset-no-major`) + keeps breaking changes off `major` outside pre-mode, not because the narrowing + is small. Both were ruled (maintainer ruling A′, decision batch #130 item 1, + 2026-09-13); this release is **not** purely additive. + + 1. `reference` is **required** when `type` is `lookup`, as it is on an object + field. A picker with no target object resolves nothing — ADR-0078's own + example of silently-inert metadata — and a degraded shape that ships today + is not a reason to bend the contract to it. A stored flow with a bare + `lookup` screen field parsed before and does not now. There is **no lossless + conversion**: nothing in the metadata says which object the author meant, so + this is an ADR-0087 **semantic** migration entry — a structured TODO + (`screen-field-lookup-reference-required`) that names the flow and the field + for a human to answer — and ⛔ never a D2 conversion that would have to + invent a target. + 2. A non-number submitted for a `type: 'number'` screen field is refused on + resume (`invalid_type`) instead of passing silently. A resume bag that was + accepted before can be refused now; it was never doing what its author + declared. + + Everything else is additive: the bound itself fires only on a field that + declares one, which nothing did before this release. + + The neighbouring spellings are refused **with their landing key** rather than + with a bare key list: `help`/`helpText`/`hint`/`tooltip` name `inlineHelpText`, + and `object`/`referenceTo`/`targetObject`/`lookupObject`/`relatedTo`/`target` + name `reference`. ⚠️ `object` means different things one level apart — on the + screen **node** it renames to `objectName`, on a screen **field** it can only + mean the lookup target — so it earns its own row on both. + + **One stale claim corrected in passing, because this change falsified it.** The + flows translation surface documented `help`'s exclusion as *"`ScreenFieldConfig` + declares nothing help-shaped at all"*, in `translation.zod.ts`'s guidance string + (which enumerated the old key set verbatim), its doc block, and + `i18n-resolver.ts`'s `FLOW_SCREEN_FIELD_COPY_KEYS`. The screen field now + declares `inlineHelpText`, so the copy is real. The exclusion **stands** — the + flows bundle still carries `label` and `placeholder` only, and growing that face + is a ruled step against the #7646 enumeration, not a resolver-side accretion — + but its reason is now stated as a not-yet instead of telling an author the field + has no help copy when it has. ⛔ No translation key was added and no resolver + behaviour moved. +- 92865f6: fix(service-automation)!: a whitespace-only `config.condition` is refused at `registerFlow`, the rule the edge door has carried since #15807 (#17322) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): a flow + node's `config.condition` — a `decision` node's predicate, and on a `start` node + the **trigger gate** — is now refused at `registerFlow` when its source is blank + after trimming, where it used to register clean and answer a **silent `false`** + at every evaluation. + + Two doors, the same authored value, two fates until now. `FlowEdgeSchema.condition` + composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is + refused at `FlowSchema.parse`, by name. A node's `config` is an open + `z.record(z.string(), z.unknown())`, so the same value passed through verbatim, + reached `AutomationEngine.evaluateCondition`'s empty-source arm — `exprStr.trim() + === ''` — and returned `false`, under a comment that names that arm as being for + an **unauthored** condition. `' '` was authored. The branch never ran, forever, + with nothing said at any layer. + + ```yaml + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } # the flow was gated shut + - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } # the same blank, through the envelope key + ``` + + > An expression in an evaluated slot needs a non-blank `source`: the expression + > engine evaluates `source` (the canonical persisted form) and + > cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` + > that is blank after trimming, would validate and register and then fault at + > run time. Write `{ dialect: 'cel', source: '…' }`. + + - **The rule is imported, not re-derived.** `registerFlow`'s structural pass runs + the condition's source through `EvaluatedExpressionInputSchema` itself, so the + node door and the edge door cannot drift into two notions of "blank" or two + sentences for it — the property the #15662 campaign built the shared refusal + for. Nothing is exported from this package to carry it, and no new export was + added. + - **Applied to the SOURCE, not to the whole value**, deliberately: the union + would also refuse an envelope with no `dialect` or with a dialect outside its + enum, and this slot admits both (`structuralConditionRefusal`'s docblock, + #4336). The narrowing is exactly the blank population and nothing else — a + `cron` envelope with a real source still earns its own pre-existing verdict, + and a bare string with a `{…}` brace trap still earns #1491's. + - **`evaluateCondition` is unchanged and still answers `false`.** It is the + shared evaluator and a public method on an exported class, so its throw + behaviour is itself a contract; and a stored flow reaches it whatever the + producer refuses. This change is at the producer only. + - **`structuralConditionRefusal` is unchanged.** A string is still a well-shaped + condition; the new refusal sits behind the shape one and in front of the CEL + one, and answers the evaluated-slot sentence rather than + `STRUCTURAL_CONDITION_SHAPE_REFUSAL`. + + **What an author does with a refused condition.** A whitespace-only condition was + never a predicate — the engine answered `false`, so the branch never fired, and on + a `start` node the flow never triggered. **Remove the `condition` key** if the node + was meant to be unconditional, or **write the expression** if it was meant to + branch. ⚠️ Those two are not interchangeable: a refused condition never fired, + while an absent `condition` on a decision node is an unconditional branch that + always fires and an absent one on a start node is a gate that always opens. + Deleting the key to clear the refusal inverts the node rather than preserving it. + Every condition with a non-blank source is unchanged, and nothing is renamed or + retired. + + **A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole flow, + not just the branch.** Stored flows are deliberately not canonicalized by + `applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same + skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`); they canonicalize + at `registerFlow`, and each of the three boot paths in + `service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one + `warn` naming the flow, and continues. So a node condition that used to answer a + silent `false` while the rest of the flow ran now takes the flow down with it: it + is never registered, its trigger is never armed, and the announcement is that one + warn line — `[Automation] failed to register flow` at boot, `[Automation] + cold-boot flow bind: failed to register flow` at the kernel:ready bind, + `[Automation] flow re-sync: failed to register flow` on a re-sync. The warn line + is also the locator: the refusal names the node and the slot, e.g. `node 'gate' + (start) condition`. A stack authored in config files has a second door, + `objectstack validate` — see the note below for what that door does **not** yet + say. + + **A repo-wide census on this branch found zero authored `config.condition` values + of this shape**, against a lit control: a textual probe over all 8,123 tracked + source files found **461** non-blank `condition:` string literals and **zero** + blank-after-trim ones in any authored flow (the four blank hits are two prose + examples inside #15807's own changeset and two `packages/lint` test fixtures). + There is nothing in this repository to rewrite. + + ⚠️ **Two follow-ups this change does not carry, both outside this card's package.** + (1) The ADR-0087 D3 entry named above, + `flow-edge-condition-evaluated-slot-source-required`, registers the decision this + change is a second face of — an evaluated slot requires a non-blank `source` — but + its `surface` and `acceptanceCriteria` name only `edges[].condition`. They need + widening to `config.condition` so a consumer replaying the chain is told to sweep + the node key too; that file is in `packages/spec`. + (2) `@objectstack/lint`'s `validate-expressions` applies only + `structuralConditionRefusal` to a structural condition, so `objectstack validate` + still reports nothing for a blank `config.condition` that `registerFlow` now + refuses — the two doors disagree until that rule is rebound as well. +- 9540590: `restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit + + `resumeInternal`'s catch arm journalled the consumed suspension of the run that + threw, and nothing else. For a nested run the ancestors were handled on both + paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` + and calls `failSuspendedRun` on each suspended ancestor) and delegation (the + parent frame sees a failed child with no retryable code and calls + `failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, + 'failed')` plus a `failed` log record — it journalled nothing. + + So the leaf was restorable while every ancestor was recorded `failed` with its + pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered + `NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent + that never continues: `bubbleToParent` found no parent suspension and logged. + The operator ended up worse off than before using the exit. + + `failSuspendedRun` now journals the pause it consumes whenever the descendant + whose failure consumed it is itself repairable — from the same single producer + and onto the same durable terminal row as the strand's own snapshot, so the + chain is repairable from any replica and after a restart, not only from the + process that stranded it. `restoreConsumedSuspension` then repairs the chain as + one unit: it walks down to the stranded descendant and up through the ancestors + it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes + resumable only after the run it is parked awaiting is parked again. The entry + point does not matter — naming any member of the chain repairs all of it — and + the continuation is then re-issued once, on the run that was named. + + Additive on the wire and in the type: the result's existing fields still + describe the run the caller named, and the new `chain` key is present only when + the repair was a chain repair. `ChainRestoreEntry` is exported for it. The + narrower `IAutomationService.restoreConsumedSuspension` contract in + `@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the + door answers `{ runId, restored, reason }` as it always did. + + Every member goes through the same per-run call as a flat restore — its own + in-process claim, its own strict live-suspension read, its own two-witness read, + its own durable park — so idempotence and the #14333 advance claim hold per run + in the chain: a second restore finds every member parked and answers + `RUN_SUSPENDED` without minting a second pause anywhere. + + ⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run + that consumed its OWN pause and then threw downstream, and nothing re-arms an + ancestor by resuming it; stamping it would send an operator to retry a recovery + that cannot succeed. The parent frame's delegation result still carries no + status at all, and an ancestor's repairability is carried by the journal and by + this verb's answer. + + Journalling is EARNED, not applied to every cascade: an ancestor whose + descendant is beyond repair is still consumed without a snapshot, because + re-arming it would promise a chain repair that could not be completed. + + **`@objectstack/plugin-approvals`** reports the consequence rather than causing + it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed + ancestor whose descendant is repairable now comes back `runState: + 'repairable'` instead of `'unrepairable'`, and restoring either row repairs the + pair. `'unrepairable'` keeps its other causes — a run that never paused, a + snapshot no longer held, and a cascade whose descendant was itself beyond + repair. No plugin logic changed; the docblocks that documented the old + limitation did. +- 775e5ec: A run's durable history row records the terminal status the run actually reached — `completed`, `failed`, `cancelled` or `timed_out` — instead of folding all four into two. A restart no longer changes a run's answer. + + `RunRecord.status` declared two members (`'completed' | 'failed'`) while `AutomationEngine.recordLog`'s own terminal predicate admitted four and `ExecutionStatus` (`@objectstack/spec`) has declared them all along. Both ends of the store folded to match the narrower declaration: the write mapped everything that was not `completed` to `failed`, and the read mapped everything that was not `failed` back to `completed`. The distinction was therefore not hidden — it was **destroyed at write time**, so no later change could recover it for a row already stored. The cost was that one run answered differently depending on where you read it: `getRun` prefers the in-memory ring entry and said `cancelled`, while after a restart or a ring-buffer eviction the durable row answered, and it said `failed`. + + - **The write side.** `recordLog` writes the status its own terminal predicate admitted, resolved once into a `const` that also decides whether a row is written at all. The predicate is now the single declared vocabulary, `TERMINAL_RUN_STATUSES` (`engine.ts`) — three sites had a copy of that list and only one of them was ever going to be updated together with the writer. + - **The read side.** `ObjectStoreSuspendedRunStore` resolves the row's status once in the gate that already decided whether the row is terminal at all and hands the member to `deserializeTerminal`, which no longer re-reads or folds it. `listHistory`'s filter was the second copy of the two-member list — left alone it would have replaced a wrong status with a *missing row*, dropping cancelled runs out of the Runs list entirely. + - **The stored column.** `sys_automation_run.status` accepts the two added members, and the retention scope (`lifecycle.retention.onlyWhen`) counts them as terminal — a widened writer over a two-member sweep scope would have left `cancelled` and `timed_out` history rows never ageing out, on a table whose whole retention posture (ADR-0057) is that history is telemetry. `refused` is deliberately not added: `ExecutionStatus` declares it (#14945) but no engine path produces it, and an option nothing can write is declared-but-inert metadata (ADR-0078). + - **Rows already stored keep reading `failed`.** The information they lost is not recoverable and this change does not pretend otherwise — there is no backfill, because there is nothing to backfill *from*. Rows written from this release forward carry the distinction. + - **`TerminalRunStatus`** is exported for the same reason `ConsumedSuspensionDropNotice` is: `RunRecord` is barrel-reachable, and a host store implementing `recordTerminal` / `loadTerminal` has to be able to name the field it round-trips. + + Not a breaking change, and deliberately carries no breaking-change banner: the published contract (`IAutomationService.getRun` / `listRuns` return `ExecutionLog`, whose `status` is `ExecutionStatus`) has declared all four members since before this row existed. What changes is that the implementation stops under-reporting one the contract already promised — a consumer written against the declared contract is unaffected. Also no ADR-0087 migration entry: that ADR governs authorable metadata shapes on `sys_metadata`, and this is an engine-owned system data table whose existing values stay valid under the widened option set. +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. + +### Patch Changes + +- 216b066: A run whose nodes all succeeded is no longer answered `failed` — or, under `errorHandling.strategy: 'retry'`, RE-EXECUTED — because its terminal run-history write threw (#16274) + + `AutomationEngine.execute()` and `executeWithoutRetry()` each called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. This is the initial-execution half of the pattern fixed on the resume path in 17.4.0; that fix deliberately scoped these two sites out. + + **The consequence was measured, and it is a double run, not just a mislabelled one.** `execute()`'s node-failure arm ends at the retry strategy branch, which hands the false `failed` result to the retry loop; the loop reads `result.success` and therefore re-enters `executeWithoutRetry()` — the whole flow, every node, again. Driven with `maxRetries: 2`: a flow whose node always succeeded ran it **three** times and wrote three `failed` rows, unattended, inside one `execute()` call, with the node's side effects repeated each time. Controls on the same instrument: the identical flow on healthy sinks runs the node once, and a genuine node failure runs it three times (retry working correctly). + + **What can throw there is a host surface, not in-repo code** — which is why it could not be reproduced from inside the package and why the package owed the fix: + + - the run-summary line `logger.info(line, meta)`, on by default (`runSummaryLog: 'info'`) and calling a **host-injected** `Logger`. This one needs no store at all. + - `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise — the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection. Both stores shipped in this package are `async` methods and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. (A store returning a non-thenable escapes identically: `write.catch` is then itself a synchronous `TypeError`.) + + On that second variant the old code did not even answer `failed`: the node-failure arm's own `recordLog({ status: 'failed' })` threw again out of the same store and escaped `execute()` entirely — a rejected promise where `AutomationResult` is declared. + + What changes: + + - **Each completion-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller is told the truth — `success: true`, no `status`, the flow's `successMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first — the node runs exactly once, and one `completed` row is recorded rather than `1 + maxRetries` `failed` ones. + - **The swallowed failure is reported once per run at `error`**, with the consequence and the fix in the first line: the run completed, its terminal history row never landed, nothing retries it, and the run must not be re-run. The thrown text rides the structured slot. + + ⛔ No `catch` arm's meaning is widened: a genuine node failure still reaches the node-failure arm, is still recorded `failed`, still carries the node's own text, and is still retried the full `1 + maxRetries` times. +- bea41f6: A run that genuinely failed is still answered in the declared shape when its own terminal run-history write throws (#17562) + + `AutomationEngine.execute()` and `executeWithoutRetry()` each ended their node-failure `catch` with an unguarded `recordLog({ status: 'failed' })`. That `catch` **is** the handler for node failures and there is no outer one, so a throw out of the history write escaped the method entirely and left `execute()` a **rejected promise**, where its declared return type is an `AutomationResult`. This is the failure-arm half of the completion-path guard shipped just before it, and the same shape already landed on the resume path's failure arm in 17.4.0. + + **What is lost is the shape, not the verdict.** The run really did fail, so nothing misleads an operator: there is no false `failed` and no double run. But a caller that branches on `{ success: false, status: 'failed' }` gets an exception instead, so the transport's `status` arm is bypassed and `errorMessage` (the author's failure text) and `summary` (how far the run got before dying) never arrive — a REST route or SDK caller sees a 500-class throw for a run that had a perfectly good failure envelope waiting, and the node's own error text is replaced by the history driver's. + + Reproduced with a control, the identical flow and the identical node failure differing only in the store: + + ``` + store = SYNC-THROW -> {"kind":"threw","error":"run-history driver refused the terminal row"} + store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"} + ``` + + **What can throw there is a host surface, not in-repo code** — the same two statements the completion-path fix names: the default-on run-summary line `logger.info(line, meta)`, which calls a host-injected `Logger` and needs no store at all; and `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise, which the `void write.catch(...)` beneath that call cannot see. Both stores shipped in this package are `async` and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. + + What changes: + + - **Each failure-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller now receives the envelope it was always promised — `success: false`, `status: 'failed'`, the **node's** own text in `error`, the flow's `errorMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first. + - **The retry budget survives the loss.** On the retry path the throw used to reject out through the retry loop and `execute()` both, ending the run early; the remaining attempts now run as the author's policy says. + - **The swallowed failure is reported once per abandoned write at `error`**, with the consequence and the fix in the first line: the run failed, its terminal row never landed, nothing retries it, and the caller *was* told the run failed so nothing needs re-driving. The thrown text rides the structured slot. + + ⛔ No `catch` arm's meaning is widened: the suspend arm, the input-schema refusal and the retry strategy branch are untouched, and a genuine node failure against healthy sinks is answered exactly as before. +- a36b526: `sys_automation_run.variables_json` states its presence discriminator in ONE direction, and a row-rebuilt snapshot no longer claims its steps are the pause's + + Three corrections to text this package ships. No behaviour changes; every shape + described below is the ruled design, measured as it already is. + + **`variables_json` said `⇔` where only `⇒` holds.** The field description + declared "present on a completed/failed row" and "the row's run had a pause its + resume consumed before a downstream node failed" to be equivalent. The forward + direction holds — nothing but the consumed-suspension path writes that column on + a terminal row. The reverse does not, for one shape: a run that stranded, was + restored and then finished. `recordTerminal` upserts the SAME `run_` row + with all four snapshot columns explicitly `null` — deliberately, so + "restorable" cannot outlive the condition it describes — which leaves that row + equal, across every column the discriminator is read from, to the row of a run + that never paused at all. Absence means "nothing to restore now", never "this + run never had one", and the restore verb already refuses in exactly those terms: + it names the status it observed and declines to say which. The description now + says so. + + **A snapshot rebuilt from a row does not carry the step log as of the pause.** + `deserializeConsumedSuspension`'s docblock said its `steps` are the log "AS OF + THE PAUSE". That is true of the engine's process-local journal copy only, which + slices `run.steps` back to the step count at the pause; the trimmed array is + never persisted. `steps` are the one field the rebuild takes from the row's own + `steps_json`, which is the terminal row's log of the WHOLE run — and both bounds + on that column keep the failure on purpose (history compaction retains every + failure; the byte cap trims the head). A row-rebuilt snapshot therefore carries + steps the pause did not have. It re-arms the same run regardless: the pause is + `nodeId` plus `variables` / `context` / `correlation`, none of which the step log + feeds. + + **`recordTerminal` now names the verb that reads what it writes** — the + restore path in `engine.ts` — and the three properties of the write that are + that verb's inputs rather than local detail. Its summary line also said + "completed / failed" where the terminal vocabulary has had four members since + the fold was removed from both ends of this write. + + Both falsifying shapes are pinned in `suspended-run-store.test.ts`, including the + indistinguishability itself: the restored-then-finished row and a never-paused + row compare equal across those five columns, with the same comparison separating + them while the snapshot is still there. +- ae6dcf6: `notify` now reports the recipients it addressed, so a run that notified nobody stops reading like a run that had nobody to notify + + A `notify` node whose delivery count came back zero contributed `acted: 0` and nothing else to the run summary. A flow whose only effect-bearing node is that one then folded to `selected: 0, acted: 0, unmeasured: 0` — byte for byte the summary of a run that had nothing to notify about, and of a run whose `notify` node never executed. The run read healthy, and the only trace was a log line. + + `emit()` returns `delivered: 0, enqueued: 0` on several paths, each after logging and nothing else: an audience that resolved to no recipient, a preference filter that suppressed every (recipient × channel) pair, a dedup hit, every enqueue failing. A stack with no messaging service installed lands in the same place. All of them were silent in the summary, so this is not one cause being fixed — it is the whole class becoming visible. + + The node now reports `selected` — the recipient entries it addressed — on every path that reaches a recipient list, alongside the `acted` / `unmeasuredEffect` rules it already had. Those two are unchanged, so a delivering run keeps its existing `acted` (inline) or `unmeasured` (outbox) reading and stays outside the broken-sweep filter; a zero-delivery run now reports `selected: N, acted: 0` with no `unmeasured`, which is the platform's declared "matched N, acted on none, and that zero is trustworthy" signature and puts the run **inside** `selected > 0 AND acted = 0 AND unmeasured = 0` — the filter that exists for exactly this, and whose first clause the old reading could never satisfy. + + The zero is deliberately NOT reported as `unmeasuredEffect`. That flag means the count is unknown; this count is known and it is zero, and claiming otherwise would take the run out of the very filter it belongs in. + + `selected` counts audience entries, not resolved users: the entry (`role:manager`, a bare id) is what the node has, since expansion happens inside the messaging service and is not reported back. +- a2509d7: fix(service-automation): a `null` / `undefined` envelope is refused attributed, not as a raw `TypeError` (#16439) + + `AutomationEngine.evaluateValueEnvelope` derives its verdict from `valueEnvelopeRefusals` — the same call `registerFlow` makes — so registration's reject set and evaluation's reject set are one set by construction. That covered every malformed **envelope**, and exactly two shapes fell outside it: `null` and `undefined`. Neither published primitive judges them (the shape rule is a no-op on anything not `isExpressionEnvelopeShaped`, and `validateExpression` reads an absent `source` as "not authored"), so both returned no findings and the method went on to read `envelope.source` off nothing — `TypeError: Cannot read properties of null (reading 'source')`, with no `where`, no source and no rule. Driven across the ten shapes the card enumerates, eight failed attributed and only these two did not. + + Both now fail attributed like the other eight, led by the published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` sentence and carrying the `where` and the source. The rule is stated in the **shared** refusal, never as a guard in the evaluator: a reject reason living only on the evaluation side would end the very property this design has. + + Refused rather than admitted, and the asymmetry with the predicate path is deliberate: `structuralConditionRefusal` admits `null` / `undefined` because the condition *field* is optional, so absence there means "the author wrote no predicate". A value slot's envelope **is** the value, so an absent one is a caller handing nothing where a value was required. + + **Why `patch`, not `minor` and not nothing.** Nothing changes for authored metadata: the only production call site guards with `isExpressionEnvelopeShaped`, which neither shape satisfies, and the value-role feeder emits only envelope-shaped objects, so `registerFlow` never presents a nullish value to the shared refusal — measured, and pinned. An authored `null` in an `assignments` slot is still a literal, still parses and still registers. What does move is the runtime behaviour of a **public method on an exported class**: a direct caller that passed a nullish envelope used to get a language-level `TypeError` and now gets an attributed `Error`. That is a published surface, so it is not silent — but it adds no API, no option and no capability, and no correct caller has to adapt, which is what makes it a patch rather than a minor. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [de62769] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 4d387a6be3..568e38652e 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-automation", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Automation Service for ObjectStack — implements IAutomationService with plugin-based DAG flow execution engine", "type": "module", diff --git a/packages/services/service-cache/CHANGELOG.md b/packages/services/service-cache/CHANGELOG.md index 10b104a812..4731ed7ffa 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-cache +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 3df43d3cb2..07a3bfa15c 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cache", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Cache Service for ObjectStack — implements ICacheService with in-memory and Redis adapters", "type": "module", diff --git a/packages/services/service-cluster-redis/CHANGELOG.md b/packages/services/service-cluster-redis/CHANGELOG.md index 5469fb2b69..fa5f6c70b6 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/service-cluster-redis +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/service-cluster@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index a96d1985f9..4bbcfcd8c2 100644 --- a/packages/services/service-cluster-redis/package.json +++ b/packages/services/service-cluster-redis/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster-redis", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Redis cluster driver for ObjectStack — implements IPubSub/ILock/IKV/ICounter against Redis using ioredis.", "type": "module", diff --git a/packages/services/service-cluster/CHANGELOG.md b/packages/services/service-cluster/CHANGELOG.md index 2184ca65b4..06bdbee049 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/service-cluster +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index e868357c6e..5395f0d1eb 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Cluster Service for ObjectStack — pluggable PubSub/Lock/KV/Counter primitives. Memory driver included; postgres/redis drivers ship separately.", "type": "module", diff --git a/packages/services/service-datasource/CHANGELOG.md b/packages/services/service-datasource/CHANGELOG.md index 95bf060b58..2efd00cd98 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,203 @@ # @objectstack/service-external-datasource +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [7c2c5ae] +- Updated dependencies [758ac40] +- Updated dependencies [be5c602] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [9ccc417] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [88a9330] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/driver-mongodb@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 3951961fe2..d217927d3f 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-datasource", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "The datasource service (ADR-0015): external-table federation (introspect/draft/import/validate) + runtime UI datasource lifecycle (list/test/create/update/remove + REST routes). Open-source mechanism; the tier line falls on which ICryptoProvider / driver factory a host injects.", "type": "module", diff --git a/packages/services/service-i18n/CHANGELOG.md b/packages/services/service-i18n/CHANGELOG.md index 799e3fbaad..146087ef3a 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,134 @@ # @objectstack/service-i18n +## 17.5.0 + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 9a906c141a..2fc00c05c5 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-i18n", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading", "type": "module", diff --git a/packages/services/service-job/CHANGELOG.md b/packages/services/service-job/CHANGELOG.md index 67420b72cf..9b900bc8aa 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,133 @@ # @objectstack/service-job +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 5b347a805d..4318d70992 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-job", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Job Service for ObjectStack — implements IJobService with setInterval and cron scheduling", "type": "module", diff --git a/packages/services/service-knowledge/CHANGELOG.md b/packages/services/service-knowledge/CHANGELOG.md index 74def67373..61ea763b69 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/service-knowledge +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 53bc307d47..c85ef7d257 100644 --- a/packages/services/service-knowledge/package.json +++ b/packages/services/service-knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-knowledge", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Knowledge Service for ObjectStack — orchestrator implementing IKnowledgeService over pluggable IKnowledgeAdapter backends (RAGFlow, LlamaIndex, Dify, in-memory).", "type": "module", diff --git a/packages/services/service-messaging/CHANGELOG.md b/packages/services/service-messaging/CHANGELOG.md index 7144589d70..dd02b2d011 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,230 @@ # @objectstack/service-messaging +## 17.5.0 + +### Minor Changes + +- a370073: `sys_inbox_message` rows now carry **`actor_id`** — who caused the notification — and the actor travels there end to end from the `emit()` that raised the event. + + Until now an inbox row could not answer "did I cause this?". The actor stopped one layer upstream on `sys_notification.actor_id`, and the shipped default permission sets grant a member no read on `sys_notification`, so the value was behind an FK hop into an object the reader cannot open. Consumers implementing the standard "do not notify me of my own action" rule had nothing to compare, and the visible failure was the notification that says *you* just did the thing you just did. + + The path, one leg per seam, no new read anywhere: + + - **`Notification.actorId?: string`** (`channel.ts`) — the per-recipient unit every channel implementation consumes gains an optional member, with the same semantics as `sys_notification.actor_id`. + - **`emit()`** projects `EmitInput.actorId` onto that unit on the P0 inline path, and **`enqueueDeliveries`** snapshots it into the delivery row's payload on the P1 outbox path — beside the rendered title/body, under the rule the enqueue path already states in its own comment: an event edited after enqueue cannot rewrite an in-flight send. `DeliveryPayload.actorId?: string` is declared rather than left to that type's index signature. + - **The dispatcher** reads it back off that snapshot in `processRow`. It deliberately does **not** re-read `sys_notification`, which would cost one read per delivery and break the snapshot rule. + - **The inbox channel** writes `actor_id: n.actorId ?? null`, and `sys_inbox_message` declares `actor_id` as a `sys_user` lookup. + + **A digest row keeps `actor_id` null by construction.** A collapsed group has no single actor, so asserting "you caused this" over a message that also carries other people's events would be wrong; `processDigestGroup` sets no actor and the object's own description says so. + + **Existing rows read `actor_id` null**, which a consumer's `row.actor_id === currentUserId` evaluates as "not mine" — the pre-change behaviour for rows written before this release. Nothing is backfilled: the value was never captured on those rows, so any backfill would be invented. +- 690f083: `NotificationDispatcher` reaps once per tick instead of once per claim, backs off while the outbox is idle, and `emit()` wakes it (#17610) + + **What an idle dispatcher cost.** Against an EMPTY `sys_notification_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` and `claimDigest()` in each — and each of those opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **32 statements a tick, 16 of them the identical reap UPDATE**, on a fixed 500 ms interval that never let up, one loop per warm kernel. On remote Turso every statement is an HTTP round trip. + + **Now:** + + - **The reap runs once per tick**, before any claim — an idle tick is `1 + 2 × partitionCount` = 17 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. + - **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s; `MessagingServicePlugin` option `dispatchMaxIdleIntervalMs`). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks instead of 1,201. + - **`emit()` wakes the dispatcher.** `MessagingService.setOutbox(outbox, { onEnqueued })` fires once per `emit()` that enqueued at least one delivery; the plugin points it at the new `NotificationDispatcher.wake()`, which ticks immediately — or once more, right after a tick already in flight. + + **Latency bound.** A notification emitted in the process that runs the dispatcher goes out on the tick `wake()` starts, no later than before. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): a deferred delivery coming due (retry schedule, quiet hours, digest window), a row enqueued by a process that does not run this dispatcher, and a crashed node's expired claim (recovered within `claimTtlMs` + `maxIdleIntervalMs`). Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. + + **Contract additions — all optional, nothing to change on upgrade.** `INotificationOutbox` gains an optional `reap(opts: ReapOptions)` — the visibility-timeout recovery `claim()` / `claimDigest()` already open with, as a method of its own — and `ClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost; implementing `reap()` and honouring `skipReap` is what earns the once-per-tick cost. Direct callers of `claim()` / `claimDigest()` are unaffected: without `skipReap` they reap exactly as before. Also new: `NotificationDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, and `MessagingService.setOutbox`'s optional second argument. +- e7fea46: `sys_notification_delivery` reaps its terminal-failure rows after **7 days** instead of 90 (#17611) + + **⚠️ Operational consequence, stated plainly: `dead` and `suppressed` delivery rows are now deleted 7 days after they were created.** Any report, SLA reading, dashboard or manual investigation that consulted them — "which notifications failed to send, and why" — must now read inside that window. Before this change those rows survived for 90 days. Nothing else about the table changes: `pending`, `in_flight` and `success` rows keep the same 90-day window they have always had, and no row is reaped sooner than before except the two terminal-failure statuses. + + **What was wrong.** Fan-out writes one delivery row per `(event × recipient × channel)`. A tenant with no transport configured for one of those channels dead-letters that channel's row on its **first** attempt, and every `notify` writes another one. Measured on a production tenant: 2,876 `email`/`dead` rows against 2,876 `inbox`/`success` rows, `max(attempts) = 1`, zero pending, growing +316 rows/day. Those rows carry no work — nothing ever claims, retries or acks them again — but they sat in the table the dispatcher's claim query reads on every hop for the full 90-day window, so the cost of every claim rose linearly with time. + + **The change** is one declaration on the object, using spec keys that already ship and are already consumed by the platform Reaper: + + ```ts + lifecycle: { + class: 'telemetry', + ttl: { field: 'created_at', expireAfter: '90d' }, + retention: { + maxAge: '7d', + onlyWhen: { status: { $in: ['dead', 'suppressed'] } }, + }, + }, + ``` + + `retention.onlyWhen` scopes the short window to the terminal-failure statuses — the same shape `sys_job_queue`, `sys_automation_run` and `sys_upload_session` already declare. No channel interface member, no new status value, no change to fan-out. + + The `ttl` leg is not new behaviour: it restates the 90-day bound the object has always declared. `lifecycle.retention` is a single block, so scoping it to terminal rows would otherwise have left `pending` / `in_flight` / `success` with **no age bound at all** — unbounding the larger half of this table's growth on the very change that exists to bound it. Both legs run: `LifecycleService.reapObject` takes `ttl` and `retention` in independent branches. `success` is deliberately outside the scope; delivery history stays at the table window. + + **If you override this object's lifecycle windows through the `lifecycle` settings namespace, re-read your configuration.** `retention_overrides.maxAge` for `sys_notification_delivery` used to move the whole table's window; it now moves the **terminal-failure** window only, and `expireAfter` moves the table window. An override left in place keeps parsing and keeps applying — to a narrower set of rows than it did before. + + **⚠️ This is worth nothing where the Reaper does not run.** The whole benefit is delivered by `LifecycleService`, which `OS_LIFECYCLE_DISABLED=1` or the plugin switch turns off. A deployment with lifecycle disabled kept these rows forever before this change and keeps them forever after it; a declaration is not a sweeper. Check that the Reaper is enabled before reading this entry as a bound on your table. +- a9096af: `HttpDispatcher` reaps once per tick instead of once per partition, backs off while `sys_http_delivery` is idle, and `enqueueHttp()` / `redeliverHttp()` wake it (#17623) + + **What an idle dispatcher cost.** Against an EMPTY `sys_http_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` in each — and each claim opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **16 SQL statements a tick, 8 of them the identical reap UPDATE**, on a fixed 500 ms `setInterval` that never let up, one loop per warm kernel. It is the shape #17610 removed from `NotificationDispatcher`, still running beside it. On remote Turso every statement is an HTTP round trip. + + **Now:** + + - **The reap runs once per tick**, before any claim — an idle tick is `1 + partitionCount` = 9 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. + - **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s, the notification dispatcher's default). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks and 216 statements instead of 1,201 ticks and 19,216. + - **`MessagingServicePlugin`'s `dispatchMaxIdleIntervalMs` sets the ceiling for both dispatchers**, the way `dispatchIntervalMs` and `partitionCount` already govern both. + - **Writes in this process wake the dispatcher.** `MessagingService.setHttpOutbox(outbox, { onEnqueued })` fires after an `enqueueHttp()` that enqueues a delivery — not one that parks an undeliverable record, which is `dead` on arrival — and after a `redeliverHttp()`. The plugin points it at the new `HttpDispatcher.wake()`, which ticks immediately, or once more right after a tick already in flight. + + **Latency bound.** A delivery enqueued or redelivered in the process that runs the dispatcher goes out on the tick `wake()` starts. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): + + - a retry coming due is attempted less than `min(its delay + intervalMs, maxIdleIntervalMs)` late, because the backoff restarts from `intervalMs` at the attempt that scheduled it; + - a row enqueued by a process that does not run this dispatcher; + - a crashed node's expired claim, recovered within `claimTtlMs` + `maxIdleIntervalMs` (about 35 s at defaults, where it was about 5.5 s). + + Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. + + **Contract additions — all optional, nothing to change on upgrade.** `IHttpOutbox` gains an optional `reap(opts: HttpReapOptions)` — the visibility-timeout recovery `claim()` already opens with, as a method of its own — and `HttpClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlHttpOutbox`, `MemoryHttpOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost. Direct callers of `claim()` are unaffected: without `skipReap` they reap exactly as before. Also new: `HttpDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, the `HttpReapOptions` type, and `MessagingService.setHttpOutbox`'s optional second argument. + + **One loop, not two copies.** The timer loop — idle backoff, collapsing wakes into one follow-up tick, `stop()` — moved out of `NotificationDispatcher` into a module both dispatchers share. `NotificationDispatcher`'s behaviour and public surface are unchanged; its #17610 tests pass as they were. +- 4be4e04: `IHttpOutbox.ack()` takes an optional third argument, the claim credential, and `HttpDispatcher` now always passes it (#17634). A late ack from a claim the visibility-timeout reap had taken back — a send that outran `claimTtlMs` while another dispatcher re-claimed the row — used to write its outcome by row id over that dispatcher's live attempt: a delivery still in progress could be marked `dead`, or one attempt's outcome overwrite another's. Handed the credential, `SqlHttpOutbox` and `MemoryHttpOutbox` perform the compare-and-set `INotificationOutbox.ack()` has performed since #11859: the outcome is written only while the row is still `in_flight` under the same (`claimedBy`, `claimedAt`) pair `claim()` stamped on it. A lost claim writes nothing and throws the new `HttpAckError` (`DELIVERY_NOT_ELIGIBLE`, the code this package already raises for a delivery row in the wrong state); the dispatcher logs `http-dispatcher: ack refused, claim no longer held`, carries on with the rest of its batch, and whoever holds the row re-drives the delivery. + + Nothing written against the two-argument `ack(id, result)` has to change. An `IHttpOutbox` implementation that does not read the third argument compiles and works as before, and a caller that does not pass it gets the by-id write it always got — that arity is deprecated, because it checks no ownership. New exports: `HttpClaimCredential` and `HttpAckError`. A subclass that overrides a built-in store's `ack()` should forward the third argument to `super.ack()`, or its dispatcher acks keep the old unchecked write. +- a2c2852: Notification fan-out asks a channel whether the tenant can send on it before writing anything, so a channel with no transport no longer produces `sys_notification_delivery` rows that exist only to dead-letter (#17732). + + `MessagingChannel` gains one **optional** member, `isAvailable(ctx, { organizationId })`, answering `{ available: true }` or `{ available: false, reason }` from the closed vocabulary `CHANNEL_UNAVAILABLE_REASONS` (today: `transport_not_configured`). `emit()` consults it once per channel per emit — availability is a property of `(tenant × channel)`, not of a recipient — and a channel that answers unavailable gets no delivery row and no `send()` call on either the outbox (P1) or the inline (P0) path. + + - **Optional means available.** A channel that does not implement the member is treated exactly as before. Every existing implementation, in this repo and in yours, keeps working unchanged with no edit; the same is true of a channel that is registered but unknown to this version. ⛔ There is no way to configure the opposite default. + - **The suppression is recorded, not swallowed.** `sys_notification` gains one key, `suppressed_channels` — `[{ channel, reason }]`, `NULL` when nothing was suppressed — written in the *same* insert that creates the event row, so the feature costs no additional write. `EmitResult` gains the matching `suppressed` array, so a caller is never handed a delivery count that silently omits a channel it asked for. + - **The `email` channel answers from the transport it was handed** — a service-registry lookup, no I/O, nothing cached. Mail configuration in this tree is the `mail` settings namespace at `scope: 'global'`, materialised into a single in-memory transport that the settings change bus hot-swaps, so there is no per-tenant row to read and a memoized answer would survive the settings save that fixed it. The query still takes the tenant context so a future tenant-scoped transport needs no interface change. + - **A probe that throws is treated as available** and logged at `warn`: a broken availability check degrades into today's behaviour, never into a silent notification outage. + - ⚠️ **Unchanged on purpose**: a channel named in `channels` that is not *registered* at all keeps its existing path — the inline fan-out reports it as a failed delivery, the outbox enqueues a row the dispatcher dead-letters. It has no implementation to ask, and widening this ruling to cover it is filed separately. + +### Patch Changes + +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index ba231c205a..7c21a4cb8b 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-messaging", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", "type": "module", diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md index 9100c037c2..b3b13dc524 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,130 @@ # @objectstack/service-package +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 3af381fdcd..7a0f1dec18 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-package", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Package management service for ObjectStack — publish, install, and manage packages", "type": "module", diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md index ea83913bab..b06c03c020 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,133 @@ # @objectstack/service-queue +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index fd7f8ecd93..59ba6d7bf8 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-queue", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Queue Service for ObjectStack — implements IQueueService with in-memory and durable DB-backed (sys_job_queue) adapters", "type": "module", diff --git a/packages/services/service-realtime/CHANGELOG.md b/packages/services/service-realtime/CHANGELOG.md index 9089ad39e3..532638b6bd 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,133 @@ # @objectstack/service-realtime +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 6de40730c7..573f810743 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-realtime", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Realtime Service for ObjectStack — implements IRealtimeService with WebSocket and in-memory pub/sub", "type": "module", diff --git a/packages/services/service-settings/CHANGELOG.md b/packages/services/service-settings/CHANGELOG.md index 7c756981ad..c0dd5755ee 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,189 @@ # @objectstack/service-settings +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index ff1cbda03e..928d29d057 100644 --- a/packages/services/service-settings/package.json +++ b/packages/services/service-settings/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-settings", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Settings service for ObjectStack — manifest registry + K/V resolver (OS_* env > Tenant > User > Default) + REST routes. See ADR-0007.", "type": "module", diff --git a/packages/services/service-sms/CHANGELOG.md b/packages/services/service-sms/CHANGELOG.md index 84b1d5ad0e..da8869c852 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,141 @@ # @objectstack/service-sms +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [ee6fbd7] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [c9246fa] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [45c2cf9] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [9ca49eb] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 4acf73ea57..a47e1a83ed 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-sms", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "SMS service for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).", "main": "dist/index.js", diff --git a/packages/services/service-storage/CHANGELOG.md b/packages/services/service-storage/CHANGELOG.md index 3b943e6fbe..65dad7f7b3 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,215 @@ # @objectstack/service-storage +## 17.5.0 + +### Minor Changes + +- 6ff5b56: **Clause-②: yes** — a new REQUIRED member on two published option types (`S3StorageAdapterOptions.keyPrefix`, and the `s3` member of `StorageServicePluginOptions`), so the accept set a consumer writes against narrows. Contract-review tier. + + **BREAKING** — `S3StorageAdapterOptions` and `StorageServicePluginOptions.s3` now require `keyPrefix: string | null`. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + The **S3 adapter can now be confined to a key namespace**, and the confinement is structural rather than conventional: a caller holding the adapter has no door through which it can reach an unprefixed key. + + `keyPrefix` is applied on `upload` / `download` / `delete` / `exists` / `getInfo`, on both presigned doors, on every multipart door, and into `list()`'s `Prefix` — and it is stripped off every key and every `list()` cursor coming back. Callers therefore supply and receive unprefixed keys at every door, in both directions, and `list('')` enumerates this adapter's namespace and nothing else. Keys are concatenated, never path-joined, so a caller key such as `../elsewhere` stays a literal key inside the namespace instead of escaping it. + + **Why it is required rather than optional.** A shared bucket with no namespace has one thing keeping one deployment out of another's objects: that every `sys_file` metadata check above the adapter was written correctly. On a route that takes an identifier out of a request, one missed check is a cross-deployment read the object store cannot refuse, because what it sees is a well-formed key. An optional prefix reproduces exactly that gap the first time a host forgets to set it, silently — so the choice is made at the call site or the code does not compile. `null` is the written, greppable way to ask for bucket-root keys, and it produces byte-identical keys to those written before this option existed. + + For the same reason an empty or whitespace-only string is **refused at construction** rather than treated as "no prefix": that is what an unset environment variable looks like after interpolation. A leading `/` and any `..` segment are refused too, and a missing trailing `/` is appended — the last of those is load-bearing, not tidiness: S3 `Prefix` is a raw string match, so `tenant_1` without the delimiter also matches `tenant_10/...`, and one namespace would enumerate its neighbour through the isolation mechanism itself. + + Two further seams move with it: + + - `StorageServicePlugin` carries the **host's** namespace onto every adapter a `storage` settings re-read rebuilds, and deliberately reads no prefix out of the settings values. A boundary an administrator inside the deployment can set or clear is a preference, not a boundary; without this, one settings save returned a hosted deployment to a shared, unprefixed key space. A host that declared no `s3` constructor options expressed no namespace, and settings-configured S3 stays bucket-root as before. + - `resolveStorageTarget` puts the namespace in the target's **`location`**, not merely its fingerprint: two prefixes in one bucket are two disjoint object sets, so moving the prefix strands what the old one held exactly as moving the bucket does, and the swap must print the migration warning. `env_7` and `env_7/` normalise to one target, so the same namespace spelled two ways is not read as a move. + + `LocalStorageAdapterOptions` is deliberately unchanged: `resolvePath()` already refuses any `..` and joins every key under `rootDir`, so the local adapter's containment boundary exists and a second mechanism would be two ways to say one thing. + + **Migrating:** every `new S3StorageAdapter({ ... })` and every `new StorageServicePlugin({ adapter: 's3', s3: { ... } })` gains one member. Single-tenant deployments write `keyPrefix: null` and their keys do not move. Deployments sharing a bucket write the namespace they want and should treat the change as a store move — existing objects are not migrated into the new namespace. + + + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [305e7fc] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index d85b4a1f68..43ed6fbcb0 100644 --- a/packages/services/service-storage/package.json +++ b/packages/services/service-storage/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-storage", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Storage Service for ObjectStack — implements IStorageService with local filesystem and S3 adapter skeleton", "type": "module", diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index a1b11b77e1..374427bcd8 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,4402 @@ # @objectstack/spec +## 17.5.0 + +### Minor Changes + +- 7382c5d: feat(spec): `element:filter` and `element:form` are refused BY NAME at the node, and the typo suggester stops renaming authors into retired types (#15110) + + Two halves of one vocabulary defect, and only one of them is a narrowing. + + **BREAKING** — a bare `element:filter` / `element:form` component node no longer + parses. Both elements were retired whole at element grain (ADR-0049 + enforce-or-remove): no renderer for either ever shipped in objectui, framework + or cloud. Every authorable key became a `retiredKey` tombstone at the time, but + the node itself kept parsing, and each schema's own docblock recorded that as a + limitation rather than an intention: + + > A bare node with empty `properties` parses clean (the open `type` union + > accepts any string, so a node-level refusal is not expressible here) + + It is expressible one level up. Both names join + `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them with + a located prescription — the same door already built for `user:profile`. + + ``` + FROM PageComponentSchema.safeParse({ type: 'element:filter' }) + -> { success: true } // nothing renders it; the console + // drew the unknown-type panel + + TO PageComponentSchema.safeParse({ type: 'element:filter' }) + -> { success: false, + issues: [{ code: 'custom', path: ['type'], + params: { retiredComponentType: 'element:filter' }, + message: '`element:filter` was removed in @objectstack/spec 17 …' }] } + ``` + + **The prescription is not new prose.** Each node message is the element-grain + TAIL of that element's own `retiredKey` tombstones with the `property ` + clause dropped, so the node door and the props door carry one text — pinned + byte-for-byte in `component.test.ts`. An author who writes `element:filter` is + told to delete the component and use a view's `userFilters` quick-filter bar or + the list toolbar's filter builder; an author who writes `element:form` is sent + to the object-bound `object-form` block. + + **What does NOT change.** The rows stay in `ComponentPropsMap` — deleting one + would demote a loud retirement to a silent skip on every reader that dispatches + on it — so both rows keep refusing each retired key with its own per-key + prescription, and `isKnownComponentType` still answers `true` for both. The open + string arm is untouched: `object-grid`, `mcp:connect-agent`, `custom.widget` and + every live `element:*` member parse exactly as before. The two D2 conversions + still strip the keys and still leave the node; what changes is that the node + they leave is now refused by name instead of sitting inert, and their prose says + so. + + **The other half is a plain bug fix, no accept set involved.** + `KNOWN_COMPONENT_TYPE_CANDIDATES` — the typo-suggestion pool behind the + `component-type-unknown` authoring rule — was derived from every known type, + retired ones included. Measured through the rule: + + ``` + FROM type: 'element:fitler' -> hint: "Rename `element:fitler` → `element:filter`." + TO type: 'element:fitler' -> hint: "Use a declared component type from the standard + vocabulary, or … give it its own namespace …" + ``` + + The tool was renaming an author INTO a retired element — a rename the parser + refuses. The pool is now the known set minus whatever the vocabulary retired, + derived from the retirement map rather than restated beside it, so a type + retired tomorrow leaves the pool the day it lands. Live spellings are + unaffected: `global:serch` still proposes `global:search`, `record:detials` + still proposes `record:details`, `element:butotn` still proposes + `element:button`. + + Also corrected: the vocabulary docblock described the `ComponentPropsMap` row + set as a superset of the enum by "exactly" the string-arm registrations plus the + two tombstoned elements — one member short since `user:profile` joined it. + + +- ea2940d: fix(spec): `ActionEngineFacade.delete` declares the id ARRAY the runtime has always accepted, and says which convention is the contract (#15117) + + `delete(object, id: string)` declared one id. The runtime facade + (`buildActionEngineFacade` in `packages/runtime`) has accepted `string | string[]` + all along — normalising the argument and issuing one `ql.delete` per id — and + described that in a comment as a tolerance two handler suites happened to cause. + The declaration was simply behind the behaviour, and the one first-party suite on + the array form could only reach it by hand-rolling a private copy of the + interface (a copy that had already drifted on `find`). + + The slot is now `delete(object: string, idOrIds: string | string[])`, and the + member's doc comment states the contract instead of leaving it to be inferred + from a runtime comment two packages away: + + - **Both spellings are contract.** One row is `delete(object, id)`; a set is + `delete(object, ids)` — a handler holding a list does not have to unroll it + into a loop to stay on the contract. + - **The array form is a convenience over the same per-row path** — not a bulk or + atomic delete. There is no transaction around the set: a failure part-way + leaves the ids before it deleted. An empty array deletes nothing and resolves. + + Nothing is removed and nothing narrows: every existing single-id call still + type-checks, and no runtime behaviour changes — this release makes the published + type describe what was already being served. That makes it non-breaking, not a + patch: widening a published parameter is a purely additive widening of a public + surface, which takes at least `minor` whatever the commit type says. Handler authors who copied the + facade into a local context type to reach the array form can delete the copy and + annotate with `ActionHandlerContext` / `ActionHandler` from `@objectstack/spec/ui`. +- fe71032: feat(driver-sql,objectql,cli)!: the ADR-0104 file-family column step, and the kernel→driver supply that arms it (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`. A deployment that runs `os migrate files-to-references --apply` now has its media columns **retyped and their values rewritten** into the bare-`sys_file`-id encoding, and its driver writes bare ids from the next boot. This completes the maintainer ruling on #15041 (「15041 应该改为实际 id 保存。选A,其他同意」) whose encoding half shipped in the previous release. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + ## The column step + + `os migrate files-to-references --apply` gains a further step, run **only after** the backfill and its self-check report zero blocking rows — and it moves nothing at all until three gates pass: + + 1. the migration's own gate (zero blocking rows); + 2. **every** abort pre-check, across **every** planned column, before a single statement runs; + 3. no refusals — a column the driver could not plan stops the columns it could. + + **PostgreSQL** and **SQLite** only. ⛔ MySQL is refused by name and belongs to #17788, where its statement ORDER is settled against a real instance rather than transcribed. + + Per column, the shape is read off the column's **physical type**, not off the dialect: a `json` column is retyped (`ALTER … TYPE varchar(2048) USING (col #>> '{}')`), while a column that is already `varchar` — the population `os generate migration --format sql` creates and a JSON-arm driver fills with quoted ids — has its values unquoted in place. SQLite has only the second shape, since it has no json type. + + ### ⛔ The abort clause is NOT the one the ADR sketched + + The #15041 addendum prescribed the retype with nothing in front of it while *requiring* the step to abort "on the first cell that is not a JSON string". Those two sentences contradict each other, and which was wrong was settled by running it. Measured on live PostgreSQL 16.13, `USING (col #>> '{}')` is **accepted** over a row holding an inline metadata blob, because `#>> '{}'` extracts *any* json type as text: the bytes survive, but the column is no longer `json`, so an object becomes a plain string in a column whose declared contents are ids — silently, in a migration that reports success. The director ruling (decision batch #120 item 1) replaced the clause with the pre-check that implements the requirement: `json_typeof(col) IS DISTINCT FROM 'string'` on PostgreSQL, and `json_valid(col) AND json_type(col) <> 'text'` on SQLite, where excluding invalid JSON is what keeps a re-run idempotent over cells a previous run already moved. + + Both the destructive form and the guarded one are executed side by side, on one fixture, in this release's own test suite — so the difference stays a measurement rather than a comment. + + ## The kernel→driver supply seam + + `SqlDriverConfig.fileColumnsMoved` shipped last release and no host outside the driver supplied it. It is supplied now: `ObjectQL.registerDriver` hands every driver that has the seam a closure over the new `ObjectQL.haveFileColumnsMoved()`, which reads `sys_migration.columns_moved_at` — and requires the `adr-0104-file-references` flag to be verified **as well**, since the stamp alone would attest a column move with nothing attesting the values inside it. + + ⭐ **Every way of not knowing still answers "not moved".** The option omitted, a resolver that throws or rejects or answers a non-`true` value, a resolver that never runs because the host never calls `initObjects`, a driver with no such seam, no `sys_migration` object, no row, an unreadable table, a null or empty stamp — all the JSON arm. That is the encoding every deployment in the world is on, and a driver that guessed the other way would write bare ids into a JSON column. + + ⛔ **A host that names `fileColumnsMoved` in its own config wins**, in either polarity. The engine only ever fills an empty slot, and never contradicts an explicit composition: overruling a declared `false` is precisely the bare-ids-into-a-JSON-column failure this mechanism exists to prevent. + + ## New published surface + + - `@objectstack/spec` — `hasMovedFileColumns(flag)`, the single arbiter of the conjunction above, beside `isDataMigrationFlagVerified` and `authorisesIrreversibleAction`. + - `@objectstack/objectql` — `ObjectQL.haveFileColumnsMoved()`, sharing one memoized read (and one `invalidateDataMigrationFlags()`) with `isFileReferencesMigrationVerified()`, so the two answers can never come out of one another's date. + - `@objectstack/platform-objects` — `recordFileColumnMove(engine, migrationId)`, which refuses to stamp a deployment with no verified flag row. `readDataMigrationFlag` now carries `columns_moved_at`; it previously dropped it, which made a moved deployment indistinguishable from an unmoved one to every caller. + - `@objectstack/driver-sql` — `SqlDriver.setFileColumnsMovedResolver()`, `SqlDriver.planMediaColumnMove()`, and the statement builders `mediaColumnMovePlan` / `mediaColumnMoveDialect` / `isJsonColumnType` with `MEDIA_COLUMN_MOVE_DIALECTS`, `MEDIA_COLUMN_MOVE_ROLLBACK_NOTES` and `MEDIA_ID_MOVE_WIDTH`. The statements live in the package that owns the dialects and measured them; a second copy in the CLI would be a second copy of the clause the ruling got wrong. + + ## What does NOT change + + A deployment that does not run `--apply` is byte-for-byte where it was: the column stays `json`, the write still JSON-encodes, and the read still accepts both encodings. A backfill re-run does not set the stamp and — deliberately — cannot clear it either: `recordDataMigrationRun` omits the key rather than writing a preserved value, so a ledger read that FAILS cannot demote a moved deployment back onto the JSON arm. A partial or failed column step records nothing at all, which leaves such a datastore on the arm that reads both encodings. + + `multiple: true` media is untouched on both arms: its value is a list of ids and a JSON column on every deployment. +- 6059b29: feat(spec): `IScopedObjectRepository.updateById` declares its answer — the record or `null`, not `any` (#16786) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, and PR #17255 for this card's `objectql` half, which was re-graded from `patch` to `minor` mid-round for exactly this reason). + + `updateById(id, data)` declared `Promise` — the last wide member of a contract whose siblings answer what they mean. It now declares `Promise | null>`: the written record, or `null` when the id matched nothing. + + The declaration is what every layer under it already says, measured rather than inherited: + + - the engine door it forwards to, `IDataEngine.update`, declares `Promise | number | null>`; + - that door's by-id exit calls `IDataDriver.update(object, id, data)`, which declares exactly `Promise | null>`; + - `packages/objectql`'s `ObjectRepository.updateById` declared `Promise` to MATCH this member rather than independently of it, and PR #17255 said so in its own docblock when it deliberately left this half open. + + The `number` limb `update` carries — the affected-row COUNT a predicate write resolves — is **not** declared here, and that is a measurement too: the implementation binds both the payload id and a pure-id `where` and never declares `multi`, so the shared update dispatch answers `by-id` for every call this signature admits. A falsy id (`0`, `''`) is a REFUSAL, not a `null`: it identifies no row, so the dispatch rejects and the call throws. + + Ruling A on #16231 settled the rule — #15823's `find()` narrowing extends to the sibling doors — and enumerated `scoped-context.ts:148` / `:164`, not this member. It is narrowed because the measurement says the declaration was wider than every implementation and wider than the door it forwards to, ⛔ not because a ruling named it. + + A hook or service that assigned the result into a record slot, or read a field off it, through an `IScopedObjectRepository`-typed door now separates the `null` arm first. No runtime behaviour changes. The in-repo census through the interface-typed door is the contract's own suites, which already answer the narrow shape. + + +- 88a072e: fix(spec): an object permission that declares a depth axis beside the super-user bit which short-circuits it is now REFUSED, instead of being stored and counted as coverage (#16870) + + **BREAKING** — `ObjectPermissionSchema` no longer accepts a `readScope` beside + `viewAllRecords: true`. Two sibling shapes are refused with it, read off the + same resolver lines rather than guessed at. + + The pair was accepted with **zero diagnostics**, materialised into + `sys_permission_set.object_permissions`, and counted by a capability census + reading the deployed shape as coverage — while the read stayed org-wide. + `PermissionEvaluator.getEffectiveScope` answers `org` on the super-user bit + **before** it consults the depth key, and `getDeclaredScope` (the ADR-0090 D10 + delegated-path input) carries the identical short-circuit ahead of the identical + read, so the declared narrowing was dropped from the delegation fold as well. + + ⇒ the author declared a narrowing, the platform stored it, an audit of the + deployed shape reported the capability as exercised, and the read was still + org-wide. That is ADR-0049 `declared ≠ enforced` at the capability container + itself, and the accept set is the only door that stops the declaration from + being STORED: a diagnostic raised later fires after the shape is already there. + + ``` + FROM ObjectPermissionSchema.parse({ allowRead: true, viewAllRecords: true, + readScope: 'own_and_reports' }) + -> { …, viewAllRecords: true, readScope: 'own_and_reports' } // stored, unread + + TO -> ZodError, located at ['readScope']: + "readScope: 'own_and_reports' is declared beside viewAllRecords: true, + which already grants org-wide read. … Delete readScope if the org-wide + read is intended, or set viewAllRecords: false if the narrowing is." + ``` + + **Which pairs move, and the one that deliberately does not.** The refusal is the + two short-circuits, transcribed: + + | declaration | resolver | verdict | + |:--|:--|:--| + | `readScope` + `viewAllRecords: true` | `opClass === 'read' && (viewAllRecords \|\| modifyAllRecords)` | **refused** | + | `readScope` + `modifyAllRecords: true` | same disjunct | **refused** | + | `writeScope` + `modifyAllRecords: true` | `opClass === 'write' && modifyAllRecords` | **refused** | + | `writeScope` + `viewAllRecords: true` | the write short-circuit does not name `viewAllRecords` | **accepted — honoured, and refusing it would delete a real grant** | + + ⛔ **What `viewAllRecords: true` GRANTS is untouched.** This changes which + declarations are accepted, never what an accepted one does — a permission- + semantics change is not in this change's remit. `viewAllRecords: true` alone, + `viewAllRecords: false` beside a `readScope` (the ordinary, honoured shape), and + a bare `readScope` all parse exactly as before; each is pinned as a + cost-direction guard in `permission.test.ts`, and an ablation that widens the + refusal one shape too far turns the `writeScope`-beside-`viewAllRecords` pin red. + + **The wire surface stays tolerant.** The refinement rides on the AUTHORING + wrapper only; `EffectiveObjectPermissionSchema` extends the unrefined base, so a + server still running an older toolchain can return a stored pair in an + effective-permission response without crashing a client (#4001's authorable/wire + split). `AccessMatrixEntry` likewise keeps describing the pair: it is a derived + SNAPSHOT shape whose committed `access-matrix.json` may predate this refusal, and + its tolerance is now stated with that reason in `explain.test.ts` rather than + reading as evidence that the platform accepts the declaration. + + **Scope is one object-permission entry**, which is exactly the resolver's input — + `resolveObjectPermission` returns a single entry (explicit, else the `'*'` + wildcard) and never merges two. A super-user bit in one permission set widening + past another set's `readScope` is ADR-0090's documented additive "widest wins" + semantics, not a contradictory declaration, and is not judged here. + + **Nothing in the fleet moves.** Measured across shipped defaults, both seeded + examples, two built access matrices, the built artifact fixture and every tracked + `.ts` / `.json`: **0** object permissions carry any refused pair, with lit + controls on every probe (130 nodes declaring `viewAllRecords`, 53 of them `true`, + 18 declaring `readScope`; 133 brace-local `viewAllRecords: true` literals). + + +- 1e20f81: feat(spec)!: `ListViewSchema.sort` retires the bare string clause — the PRODUCER half of the sort seam, so the contract stops minting documents its own consumer refuses (#17053; objectui#8221, decision batch #77 option B) + + + + **BREAKING** accept-set narrowing at `view.sort` — the list-view doors + (`ListViewSchema`, and the `ObjectListViewSchema` copy behind `object.list` / + `object.listViews.*`) — shipped as `minor` under this repo's launch-window + convention for breaking changes, the same grade its sibling + `object-block-sort-item-array` took for the two `ComponentPropsMap` doors. The + mechanical prescription is registered under protocol major 18 as + `list-view-sort-string-clause-to-array`. + + **Why this is graded on the seam, not on the string.** objectui ruled one sort + orthography platform-wide — the array (objectui#8221, decision batch #77, + 2026-09-07, option B) — and objectui PR #8758 executes it: `convertSortToQueryParams` + refuses a runtime string and its diagnostic names the array form. `ListViewSchema` + is the producer of exactly those documents: `object.list.sort` is what + `deriveRelatedLists` reads. So until this release a view authored with + `sort: 'created_at desc'` **validated here, cleanly, and then failed downstream** — + the contract minting a shape its consumer rejects, with the author told off by + the wrong layer. Re-measured on this tree before the change, with `bogusProp` + refused by name on the same call as the firing control: `'name desc'`, `'-name'` + and the array form all returned `success: true`, and only a bare number was + refused (`sort/invalid_union`). + + `sort` survives as a key, one union arm lighter, so this is a VALUE narrowing with + no `retiredKey()` tombstone to hang a prescription on. The surviving array member's + own `error` map carries it, keyed on `issue.input` being a string — the same shape + `view.type`'s retired `'page'` value and `view.exportOptions`' retired `'pdf'` value + already use in this schema. Every other invalid value (a number, an object, a + string reaching a *descendant* such as a misspelled `order`) keeps zod's default + report, so nobody is told a clause they never wrote "was removed". + + **Migration** (`list-view-sort-string-clause-to-array`, a D2 conversion, not a + semantic TODO — the rewrite is lossless and wholly mechanical): + `sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`; + a bare field name meant ascending, so `sort: 'created_at'` becomes + `sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required on the entry + and is written out rather than omitted; a comma-separated clause becomes one array + entry per key, in the same order. `os migrate meta --from 17` lists these edits for + author sources, and stored rows replay them through `applyConversionsToStoredItem`. + + **The narrowing was not free, and the population was measured rather than assumed.** + A tree-wide census over both the TS and JSON spellings of a string-valued `sort`, + read as STRUCTURES rather than counted as tokens, found the clause authored on + three live in-tree sites, all converted here: the shipped showcase list view + `examples/app-showcase/src/ui/views/task.view.ts` (`'estimate_hours desc'`, carried + since objectui#2601 as a deliberate live coverage fixture for the string form), the + frozen `packages/lint` snapshot of that same shipped shape, and the published + `skills/objectstack-ui` list-view rule. The census fired: it *found* documents, and + `tsc` independently reds on the first two the moment the arm is removed. Sites + deliberately NOT converted, having been read rather than grepped: ObjectQL + `query.sort` and the wire `normalizeSortNodes` (different doors, different + dialects), `packages/spec`'s `book`/`doc` field-mapping records whose `sort: 'order'` + is an unrelated key of the same name, and the `packages/lint` rule fixtures, which + feed the PRE-parse walker and never reach this schema. + + **Not moved by this release.** `RecordRelatedListProps.sort` keeps its declared + string arm. That string is the `'field'` / `'-field'` dialect normalised by + objectui's own `RelatedList.normalizeSortSpec`; it never reaches + `convertSortToQueryParams`, and retiring it was not ruled. For the same reason the + conversion above declines any clause that does not parse as ` [asc|desc]`: + guessing a direction for `'-name'` would invent an ordering the author never wrote, + so on a list view it meets the door's prescription instead. +- 38472ce: `CalendarConfigSchema` now declares **`allDayField`** — the fifth field binding on a calendar config, and the one key the rest of this package already published as a member while the schema refused it by name. + + **The trap this closes.** The `object-calendar` door refuses a flat `allDayField` and prescribes, verbatim: *"Write this as a key of the `calendar` config object instead — `calendar: { startDateField, endDateField, titleField, colorField, allDayField }`."* That block's `calendar` prop `.describe()` publishes the same five-key shape, and it ships to `content/docs/references/ui/component.mdx`. An author who followed the prescription on a stored view was refused a **second** time, by a different schema with a different message — `Unrecognized key(s) on this calendar configuration: allDayField` — and neither message said the key was not a member at all, so the natural next move was to assume a typo and try more spellings. + + **Why the schema was the wrong half, measured rather than assumed.** The key is honoured, not inert. At the objectui pin this repo builds against, `ListView`'s `collectViewFields` reads `calendar.allDayField` into the fetch projection and its calendar branch forwards the authored block onto the `object-calendar` node, where `getCalendarConfig` resolves it; objectui then made it load-bearing in the render itself. Trimming the prescription instead would have left a shipped capability with no protocol carrier — and the mirror that carries it today keeps `.passthrough()` explicitly so the key is not stripped, which means a later hardening there would silently drop it. + + **What is authorable, and what still is not.** + + ```ts + // accepted + calendar: { startDateField: 'start_date', endDateField: 'end_date', + titleField: 'subject', colorField: 'status', allDayField: 'is_all_day' } + + // still refused — one key per concept, not a second authorable spelling + { type: 'object-calendar', allDayField: 'is_all_day' } + ``` + + `allDayField` **names a boolean field, not a value**: a record whose flag is true draws as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit it and the renderer's existing inference is untouched — an event with no end date draws as all-day — so every calendar that never authored the key renders exactly as before. + + **The opening is one key wide.** `defaultView` stays refused on this config: it is the renderer's initial view mode, a UI preference rather than a field binding, and it already has its own declared home as an `object-calendar` component prop. Unknown keys are refused in the same shape as before, and `startDateField` is still required. + + Purely additive: nothing that parsed before is refused now, and no key is renamed or removed. +- 146c291: feat(spec)!: retire the `scheduled` cache-warmup strategy — the cron it selected left in this same major, and nothing ever warmed on a cadence (ADR-0049) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` (the + lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness + is carried by this banner plus the ADR-0087 disposition above). + + `CacheWarmup.strategy` no longer accepts `'scheduled'`. + + | | before | after | + |:--|:--|:--| + | accept set | `'eager' \| 'lazy' \| 'scheduled'` | `'eager' \| 'lazy'` | + | describe | `… lazy (on first access), scheduled (cron)` | `… lazy (on first access)` | + | a document writing it | parsed green | **refused**, with the prescription | + + **The one-line fix:** write `strategy: 'eager'` (warm at startup) or `strategy: 'lazy'` + (warm on first access). For a warmup on a **cadence**, declare a `job` — that is the one + cron slot this platform evaluates: + + ```ts + defineStack({ + jobs: [{ name: 'warm_config_cache', schedule: { expression: '0 * * * *' }, handler: 'warmConfigCache' }], + }); + ``` + + ## Why + + `cron-typed-positions-retired` (17.x → 18, #16320) deleted `CacheWarmup.schedule`, the + cron key this enum member selected, and left the member standing on the reading that it is + "a value, not a position the ruling names". That was a statement about that ruling's + **scope**, not a finding that the value was sound. After the deletion the member declared a + warmup cadence with **no key left to configure it and no engine that has ever run one**, + while its own `.describe()` still promised `(cron)` — ADR-0049 declared-not-enforced, in + the form Prime Directive 10 names outright: a capability advertised that the runtime does + not deliver. + + Nothing on the platform reads `CacheWarmupSchema`: outside its declaring file it resolves + to the generated reference page's import line, the `declaration-map` / `export-origins` + catalogues, the ADR-0058 D7 ledger comment and two of this package's own test files — zero + runtime consumers, measured beside a lit control (`ConnectorSchema`, 46 files, same sweep). + So **no runtime behaviour changes**: no warmup has ever run on a schedule, before or after. + What changes is that the contract stops promising it. + + ## The retirement kit + + - the member leaves `z.enum(['eager','lazy','scheduled'])` and the `.describe()` stops + saying `(cron)` (`system/cache.zod.ts`) + - the prescription hangs on **the enum's own `error` map, dispatched by `issue.input`** — + the established route for an enum-VALUE retirement (`crypto.hash` on + `HookBodyCapability`, `object.managedBy: 'system'`, `HotReloadConfig.stateStrategy`). + There is no value-level analogue of `retiredKey()` and none is invented here. Only the + value that **used to be legal** gets the "was removed" sentence; `strategy: 'sheduled'` + keeps zod's own enum message, which already lists the legal values + - an **ADR-0087 D3 semantic entry**, `cache-warmup-scheduled-strategy-retired` — a semantic + entry rather than a D2 conversion because there is **no source to rewrite**: `CacheWarmup` + is bound to no metadata type and embedded in no stack collection, so no authored document + and no stored row has ever carried this value, and `os migrate meta` has nothing to list. + That is also why the prescription carries **no `os migrate meta` sentence** — it would + promise a listing the tool cannot produce, which is the very defect this card is about + - **nothing in `RETIRED_KEYS_BY_MAJOR`** — no authorable *key* changed — and **no + `retiredKey()` tombstone**, which tombstones keys, not values + - pin tests (`system/cache.test.ts`): the refusal and its prescription, a **lit control** + that a typo is *not* told it "was removed", and that the surviving members and the + `'lazy'` default still parse. `cron-typed-positions-retirement.test.ts`'s warmup fixture + moves to `'eager'`, since a fixture must be well-formed under the current schema + + ## ⚠️ The four surface ratchets are byte-identical across this change, and that is correct + + An enum-VALUE narrowing moves no position, no exported name and no expression-typed slot: + `authorable-surface/` keys on **positions** (`system/CacheWarmup:strategy` stays — the key + is untouched), the ADR-0058 D7 ledger on **expression-typed slots**, and `api-surface/` / + `json-schema.manifest/` on **names**. None of them reads a def's *value set*, so none of + them can fail on this change — the `crypto.hash` precedent measured exactly this. The pin + tests above are therefore not a formality: they are the only instrument this retirement + has, and a green CI run on its own says nothing about whether the value is gone. +- bdb247d: `@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + + The execution context a seed write must use — `isSystem`, `skipTriggers`, + `seedReplay` — had **no exported form**, so every seeder held a private copy of + it and nothing held the copies equal. There were three on `main`: + `SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), + `SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock + already recorded that it "mirrors" the first) and `SEED_CONTEXT` + (`@objectstack/verify`'s fixture writer, which spelled it a third time + specifically because the runtime kept its copy module-private). + + **Why a shared constant rather than three accurate copies.** `skipTriggers` is + what suppresses "on create" automation for seed rows, and `isSystem` alone does + **not** suppress dispatch. A seed path that lost that flag once seeded with + automation live while the main path had it suppressed — a self-trigger loop that + wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging + defect is a kernel semantic, not a local detail. + + **What is exported, and what deliberately is not.** The **inner** + `ExecutionContext` value, and nothing wrapped around it: + + ```ts + import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + + await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); + ``` + + The `{ context: … }` options bag stays at the call site. It is what all three + sites ultimately hand to `insert`, but it is an options envelope rather than the + posture: its type differs per engine method, so freezing one bag onto the + protocol surface would serve `insert` and no other operation, and it is + precisely the convenience bundle this export is not. + + ⛔ **No behaviour change.** The value is byte-identical to all three previous + copies, the three flags keep their existing meanings, and no seed path changes + what it writes or how. The three former copies now read this export, so the two + option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` + context is the export itself. + + **Additive, so `minor` on `@objectstack/spec`**: one new name on the existing + `./kernel` entry point, no existing export removed, renamed or narrowed. The + three consumers take `patch` — their published `dist` changes (an import edge, + and the constant now resolves through `@objectstack/spec/kernel`) while their + own public surfaces do not move. +- 48203ff: feat(spec)!: retire `ObjectKanbanProps.quickAdd` — the `object-kanban` board forwarded it and nothing ever read it (ADR-0049) + + + + **BREAKING** — `quickAdd` is retired from the `object-kanban` component props. Executes the + objectui#8285 director-seat ruling (decision batch #91, 2026-09-08, standing maintainer + delegation), ruled **option B**: the key leaves the board and stays only on the `kanban-ui` + block, where a React host can supply the runtime function the control needs. + + | | before | after | + |:--|:--|:--| + | `object-kanban` | `quickAdd: true` parsed clean and did nothing | refused by the tombstone, with the prescription | + | `kanban-ui` (objectui block) | the control works when the host passes `onQuickAdd` | **unchanged** | + + **What was actually wrong.** Measured at the `.objectui-sha` pin this repo builds against + (`53ded82bf`): the board FORWARDS the key — `ObjectKanban.tsx:931` spreads the authored bag + into `KanbanRenderer`, which passes `quickAdd={schema.quickAdd}` alongside + `onQuickAdd={schema.onQuickAdd}` (`plugin-kanban/src/index.tsx:196`) — but `KanbanImpl` + gates the affordance on **both** (`:355`, `:368`), and `onQuickAdd` is a host-supplied + FUNCTION that JSON cannot carry and that no producer puts on an `object-kanban` node. + `ObjectKanban.tsx` names neither half of the pair (0 occurrences each, against 6 for the + sibling `onCardClick` in the same file), so the gate was permanently false. + + **And the drop was not silent, which is what made it worse than silence.** objectui's html + tier reported the published key as `unknown-prop` — the same diagnostic a typo gets — and + its registry↔spec ledger records it as `ESCALATED (object-kanban.quickAdd — measured NOT + honoured)`. An author following the published contract met a tool that contradicted it, with + nothing in either message to say which side was wrong. The tombstone collapses both halves + onto one answer. + + ## What to write instead + + Nothing, on this board: there is no per-column quick-add affordance on `object-kanban` and + there never was one. Delete the key. + + ```ts + // before — parsed clean, rendered nothing + { type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status', quickAdd: true } } + // after + { type: 'object-kanban', properties: { objectName: 'crm_task', groupBy: 'status' } } + ``` + + The control itself is not withdrawn from the platform. It stays on the `kanban-ui` block, + which a React host renders directly and can hand the `onQuickAdd` slot to — that is what the + ruling preserved deliberately. + + Existing sources: `os migrate meta --from 17` lists the mechanical edits; apply them by hand. + + The retirement kit: + + - a `retiredKey()` tombstone on `ObjectKanbanPropsSchema` — `tsc` types the key `never`, and + a value reaching the parse raises the prescription rather than a bare unknown-key verdict + - the D2 conversion `object-kanban-quick-add-removed` (`RETIRED_KEYS_BY_MAJOR[18]` entry + `ui/ObjectKanbanProps:quickAdd`, wired into the protocol-18 chain step) — a **pure lossless + delete**, since the key never had an effect to preserve, scoped by component `type` so the + live `kanban-ui` spelling stays out of its reach + - the `authorable-surface/ui.json` row becomes `ui/ObjectKanbanProps:quickAdd [RETIRED]`, and + the generated reference page prints the prescription in place of the old describe + - the schema docblock's read-point list is corrected in the same stroke: it named `quickAdd` + among the keys reached "via the forwarded schema", a sentence true about the FORWARD and + false about the READ — which is how the key kept re-authorizing itself + - pin tests (`ui/component.test.ts`): the refusal carries the prescription; a clean parse does + not materialize the key; and the control pair separating the tombstone's answer from the + strict unknown-key arm's, so a shape that had merely DROPPED the key could not pass + - no liveness-ledger row (component props are not an enrolled ledger type) and no form or + i18n edit: zero `object-kanban` components are authored anywhere under `examples/` or + `apps/` (control: `object-grid` 3, `object-metric` 8 in the same corpora, same instrument) + - `api-surface/` is unchanged, correctly: it ratchets export existence, and no export leaves — + `ObjectKanbanProps` still exists, one key narrower +- 2f1a6f6: A flow screen field can now express a numeric bound, help text and a lookup target — spelled with the object field's own key names + + + + `ScreenFieldConfigSchema` was `.strict` over exactly + `name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`, + so three ordinary authoring intents had **no expression at all**. They did not + degrade quietly — `max`, `helpText` and every lookup-target spelling were + refused BY NAME — but a loud refusal with no landing key is still a dead end, + and the reference app worked around all three in prose: a discount ceiling + interpolated into the `label` and the `placeholder` (with a comment explaining + why there was no `max`), and a `type: 'lookup'` field whose `placeholder` asked + a human to type a record id because the picker could not be pointed anywhere. + + Four keys land, and **their names are derived from `FieldSchema`, not invented** + — one platform, one field vocabulary, so a name learned on an object field means + the same thing on a screen field: + + | Key | Derived from | | + |:---|:---|:---| + | `min` / `max` | `FieldSchema.min` / `.max` | the bound pair | + | `inlineHelpText` | `FieldSchema.inlineHelpText` | help under the input — `FieldSchema` renames `help`/`helpText`/`hint`/`tooltip` onto it, so a screen-local `helpText` would have been a second contract for one question | + | `reference` | `FieldSchema.reference` | the object a `type: 'lookup'` field picks records from | + + **The bound is enforced, not advisory.** It rides to the client on + `ScreenFieldSpec` so the user is stopped at the input, **and** + `validateScreenInputs` re-checks it when the run resumes (`min_value` / + `max_value`, both already in the ADR-0114 D2 field-error catalog — no new error + code). A screen field's declared contract is the only contract behind it, so a + bound the dialog alone applied would be bypassed by any caller posting to + `resume` directly — the gap #4477 closed for `required`. + + That sentence needs no "when the value is a number" qualifier, because the + value SHAPE is checked first: on a `type: 'number'` field a present value that + is not a finite JSON number is refused with `invalid_type` (also already in the + catalog — still no new code), ⛔ **not coerced**. Before this, a bound pass that + compares numbers was satisfied by anything that never reached it, so `"25"` + under a `max` of `20` was conformant. One member of the open `type` vocabulary + is read as a value domain; every other widget hint stays open, and a bound on a + non-numeric field still constrains nothing. + + **Delivered with its rendering, not ahead of it.** The executor forwards all + four onto the wire and the Studio designer form offers all four as repeater + columns; `builtin-node-form-zod-ledger.test.ts` reconciles the two key sets + against the Zod in both directions, so a key declared here and absent from the + form fails that test rather than shipping as a field nobody can author. + + **BREAKING** in the accept-set sense, in TWO places — landing as `minor` on + both packages because the launch-window guard (`check-changeset-no-major`) + keeps breaking changes off `major` outside pre-mode, not because the narrowing + is small. Both were ruled (maintainer ruling A′, decision batch #130 item 1, + 2026-09-13); this release is **not** purely additive. + + 1. `reference` is **required** when `type` is `lookup`, as it is on an object + field. A picker with no target object resolves nothing — ADR-0078's own + example of silently-inert metadata — and a degraded shape that ships today + is not a reason to bend the contract to it. A stored flow with a bare + `lookup` screen field parsed before and does not now. There is **no lossless + conversion**: nothing in the metadata says which object the author meant, so + this is an ADR-0087 **semantic** migration entry — a structured TODO + (`screen-field-lookup-reference-required`) that names the flow and the field + for a human to answer — and ⛔ never a D2 conversion that would have to + invent a target. + 2. A non-number submitted for a `type: 'number'` screen field is refused on + resume (`invalid_type`) instead of passing silently. A resume bag that was + accepted before can be refused now; it was never doing what its author + declared. + + Everything else is additive: the bound itself fires only on a field that + declares one, which nothing did before this release. + + The neighbouring spellings are refused **with their landing key** rather than + with a bare key list: `help`/`helpText`/`hint`/`tooltip` name `inlineHelpText`, + and `object`/`referenceTo`/`targetObject`/`lookupObject`/`relatedTo`/`target` + name `reference`. ⚠️ `object` means different things one level apart — on the + screen **node** it renames to `objectName`, on a screen **field** it can only + mean the lookup target — so it earns its own row on both. + + **One stale claim corrected in passing, because this change falsified it.** The + flows translation surface documented `help`'s exclusion as *"`ScreenFieldConfig` + declares nothing help-shaped at all"*, in `translation.zod.ts`'s guidance string + (which enumerated the old key set verbatim), its doc block, and + `i18n-resolver.ts`'s `FLOW_SCREEN_FIELD_COPY_KEYS`. The screen field now + declares `inlineHelpText`, so the copy is real. The exclusion **stands** — the + flows bundle still carries `label` and `placeholder` only, and growing that face + is a ruled step against the #7646 enumeration, not a resolver-side accretion — + but its reason is now stated as a not-yet instead of telling an author the field + has no help copy when it has. ⛔ No translation key was added and no resolver + behaviour moved. +- 23fc5d6: An action can now **declare which bulk dispatch contract its body is written for**, and a list view that wires it the other way is refused at authoring time instead of handing the body the opposite input in silence. + + A list view has always been able to wire the same declared action two ways, and the two deliver opposite shapes to the same body: `bulkActions: ['']` promotes the action to a def and dispatches it **once per selected row** (that row's `recordId`, no `_selectedIds`), while a `bulkActionDefs` entry with `execution: 'aggregate'` makes **one** dispatch for the whole selection (every id in `params._selectedIds`, no `recordId`). The action declared neither, so both mismatches failed quietly and in opposite directions — an aggregate body wired bare-string read `_selectedIds` as `undefined`, fell into its single-record branch and reported success for one row out of ten; a per-record body wired aggregate found no `recordId` and threw its own "nothing selected", which reads like a selection bug. Nothing caught either: `recordId` and `_selectedIds` are both built-in action params (ADR-0104), so the strict params gate admits either bag without a word, and the wiring lives on the view while the declaration would live on the action, so no single parse has both halves. + + - **`ActionSchema` gains `execution`**, and it is `bulkActionDefs`' own vocabulary — the same key, the same two values (`'perRecord' | 'aggregate'`), the def's `BulkActionExecutionSchema` **imported rather than re-declared**, so there is no second spelling to drift. The near-miss keys (`dispatch`, `dispatchContract`, `bulkExecution`, `bulkDispatch`) rename onto it; ⛔ `mode` deliberately does **not**, because on an action `mode` is a declared key of its own. + - **`@objectstack/lint` gains `action-dispatch-contract-mismatch`** (severity `error`), a member of the reference-integrity suite, so it runs on `os validate`, `os lint` and `os compile` at once. It names the action, the view and **both** contracts — the declared one and the wired one — and offers both ends of the fix, because which end is wrong is the author's call. It judges every list tier: a view's `list`, each `listViews.`, and an object's own `listViews`. + - **⛔ No silent default.** `execution` is optional and an action that omits it is *undeclared*, never defaulted to a contract — which is also the honest state of a body written to serve both (it reads `recordId` *and* `_selectedIds`), and why no third enum member was added. Existing sources are migrated by the new ADR-0087 semantic entry `action-bulk-dispatch-contract-undeclared`, which derives the declaration from the view wirings where they are unambiguous and hands back a structured TODO where one action is wired both ways. + + Nothing about dispatch changes: this release adds a declaration and a build-time refusal measured against it. Existing apps are unaffected until they declare the key — the new rule has nothing to judge on an undeclared action, by construction. +- 0252320: feat(service-analytics)!: `min` and `max` are judged by the aggregate × field-type table too — all 74 refused pairs answer `400 DATASET_INVALID` through one compile door (#17560) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, and the last + one this table owed. A dataset measure pairing `aggregate: 'min'` (or `'max'`) with any + of the **37** field types outside the numeric, temporal and boolean classes — for example + `text`, `select`, `lookup`, `autonumber`, `json`, `multiselect`, `file`, `location`, + `vector` or `formula`; the ADR-0087 entry registered below carries the full list — used to + compile and reach the backend; it is now refused by + `compileDataset` with `DATASET_INVALID` / **400** before any query is built. Shipped as + `minor` under the repo's launch-window convention for accept-set narrowings. + + ⛔ This changeset adds no rows to any table and restates none. The verdict is + `AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in + #16353 under the director ruling of decision batch #59 ("both legs, table in spec") — + read through `isAggregateCompatibleWithFieldType`. + + ## What was wrong + + The table refused these 74 pairs from the day it was declared, and **four declarations + gave three different answers about them**: + + | declaration | what it said about `min` × `text` | + |---|---| + | `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec) | refused | + | `dataset-compiler`'s compile leg | never judged — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` | + | `measureResultType` (service-analytics, #15768) | a supported `'string'` result | + | two shipped test files, in prose | "ruled C — the table is to be AMENDED to accept it" | + + Driven through the real service door before anything was written, `min` / `max` over 13 + sampled refused pairs all compiled and emitted SQL, with `avg` × `datetime` as the + firing control (refused, `DATASET_INVALID` / 400, no SQL) — so the zero was a reading of + the tree rather than of a blind harness. + + The fourth row had nothing behind it. The card it cited (#17513) is closed as a + duplicate carrying zero rulings, and the one recorded ruling on this table says the + opposite. ⇒ The director ruling of decision batch #127 (2026-09-13) settled all three + sub-questions in one pass, because one shared fixture drove members of both halves: + + 1. **the string classes** (42 pairs) stay refused, as batch #59 ruled — ⛔ the table is + not amended; + 2. **the non-string classes** (32 pairs) are refused **and enforced**; + 3. **`formula`** is refused on the table's own storage ground — it is VIRTUAL in SQL + storage, no column is emitted, so no aggregate can be lowered to it whatever + `returnType` says. + + The divergence is real, and for these two aggregates it is the **ORDER** rather than the + arithmetic: string order is collation-dependent, so two backends answer two different + "smallest" values for one metadata document, and `min(jsonb)` does not exist on + PostgreSQL at all. + + ## What changed + + - **`dataset-compiler`**: the scope condition is gone. `assertAggregateFieldTypeCompatible` + judges all six `AggregationFunction` members against the table, through the same + `DATASET_INVALID` / 400 door. The refusal message names the divergence its own + aggregate class really has (`min` / `max` SELECT a stored value and diverge on order; + `sum` / `avg` DERIVE a number and diverge on arithmetic) and prescribes accordingly. + - **`measureResultType`** asks `isAggregateCompatibleWithFieldType` before it answers, so + the rule and the table agree **by construction**. Its `STRING_SOURCE_FIELD_TYPES` + branch and its `formula` branch are retired with them; `min` / `max` over the temporal + class still answers `'time'`, unchanged. + - **`AnalyticsServiceConfig.sourceFieldMeta`** no longer declares `returnType`. It was + carried (#16236) for one reader — the retired `formula` branch — and a declared input + nobody consumes is the declared-not-enforced shape Prime Directive #10 refuses. + + ⚠️ **That key was never released, so against every published version this removal is a + no-op.** #16236 is still a pending changeset in the same release window as this one; + the last published entry (17.4.0) says in as many words that `FieldSchema.returnType` + "is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape". The key was + therefore added and removed inside one window and no published tarball ever carried it. + + **Host fix, one line:** drop `returnType` from whatever your `sourceFieldMeta` returns. + You do not have to — the hook is a function RETURN position, so an extra key is not an + excess-property error and is simply ignored at runtime — but keeping it declares an + input nothing reads. Hosts on `AnalyticsServicePlugin` need no change at all: the plugin + stopped relaying the key in this same change. + + ## FROM → TO, and the one-line fix + + | you wrote | write instead | + |---|---| + | `{ aggregate: 'min' \| 'max', field: }` | `count` / `count_distinct` if you were counting; a **sort** on the list/report if you wanted the first or last RECORD | + | `{ aggregate: 'min' \| 'max', field: }` | store the quantity you meant as a numeric or temporal field and aggregate that | + | `{ aggregate: 'min' \| 'max', field: }` | a formula emits no column; aggregate the stored field the formula reads, or persist the computed value | + + ⚠️ **Untouched:** those field types used as a **DIMENSION** (grouping, labelling, + bucketing, filtering), `count` / `count_distinct` over any type, `min` / `max` over the + numeric, temporal and boolean classes, and every `sum` / `avg` row #16778 and #16099 + already settled. The refusal also still stands down rather than guessing wherever the + declared type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a + `relationship.field` path whose column lives on a joined object. + + ⚠️ The hand-migration prescription ships as the ADR-0087 semantic TODO registered above, + which names the measure and the field type per affected pair — no lossless conversion + exists, because nothing can compute "the smallest text value" in a way every backend + agrees on. +- e04a0af: `$contains` on a multi-valued / JSON column is a MEMBERSHIP test, compiled per dialect so SQLite, MySQL and PostgreSQL answer the same rows. + + `$contains` is the membership spelling on a `multiple: true` field or a `JSON_COLUMN_TYPES` member — the one operator that kept working on a JSON column after the scalar-comparison family was refused there, and the spelling that refusal's own message prescribes. It was lowered like any other text operator, so each backend was asked about the SERIALIZATION rather than about the members, and the three answered three different things: SQLite matched a substring of the stored array text, MySQL coerced its `json` column for `LIKE` and matched the same substring, and PostgreSQL raised SQLSTATE 42883 (`operator does not exist: json ~~ text`) — a `DATABASE_ERROR` 500 for a filter the spec accepts. + + `driver-sql` now compiles a real membership construct per dialect: `jsonb` containment on PostgreSQL, `JSON_CONTAINS` on MySQL, a `json_each` scan on SQLite. `$notContains` moves with it as its exact complement. + + **Behaviour change on SQLite and MySQL, in the narrowing direction.** Where the substring reading matched ACROSS element boundaries it no longer does: `{ tags: { $contains: 'red' } }` stops answering a row whose only tag is `redwood`, and `{ nums: { $contains: '1' } }` stops answering a row holding `[10, 21]`. Those rows were wrong answers, not a contract — a filter that needs the old reading is asking for a substring search over a serialization and should be written against a scalar column. On PostgreSQL the same filters change from a 500 to the member rows. + + Unchanged: `$contains` on a scalar string column is still the case-sensitive substring test, and the rest of the text family (`$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) keeps the lowering it had on every column. + + `packages/spec`'s `StringOperatorSchema` docblock — published source — now states the membership reading and records, per face, which runtimes answer it. +- 98bd798: feat(spec)!: the three `kernel/plugin-lifecycle-advanced.zod.ts` duration keys carry their unit in the key name (#17780, ruling A on #15939) + + + + **BREAKING** — the health-check period, the health-check deadline and the hot-reload debounce + now carry `Ms` in the key name. + + | | before | after | + |:--|:--|:--| + | `PluginHealthCheck` | `interval: 30000` | `intervalMs: 30000` | + | `PluginHealthCheck` | `timeout: 5000` | `timeoutMs: 5000` | + | `HotReloadConfig` | `debounceDelay: 1000` | `debounceDelayMs: 1000` | + | values, defaults, min bounds | ms; 30000 / 5000 / 1000; min 1000 / 100 / 0 | **unchanged** | + + ## Migration + + ```diff + const health = PluginHealthCheckSchema.parse({ + - interval: 30000, + - timeout: 5000, + + intervalMs: 30000, + + timeoutMs: 5000, + }); + + hotReload.registerPlugin('my-plugin', { + - debounceDelay: 1000, + + debounceDelayMs: 1000, + }); + ``` + + Rename the keys. Every value is the same number of milliseconds it always was, and the + 30000 / 5000 / 1000 defaults are unchanged; nothing else on either def moves. + + ## Why + + Each key named milliseconds in a source JSDoc — "Health check interval in milliseconds", + "Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — + and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. + Measured by the `check:duration-unit-keys` census on this tree, all three read + `[name: -] [prose: -]`: no unit in the name and none in the published prose either. + `interval` was the sharpest of the three — its describe carried one unit-shaped token, the + parenthetical "(default: 30s)", naming SECONDS for a value the schema bounds and defaults in + MILLISECONDS. Executes director-seat ruling A on #15939 (2026-09-11, maintainer 「同意」, + decision batch #115), the per-file remediation of the #14478 rule. + + The suffix is the family's own spelling, counted on this tree: 100 key-position `*Ms` + declarations across `packages/spec`, `timeoutMs` 29 of them and `intervalMs` 3. + `debounceDelay` takes the plain suffix rather than a shortened form because it is the only + debounce-shaped key spelling in the repo (no `debounceMs` variant anywhere) while the + Delay-plus-`Ms` pairing is already attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, + `delayMs`) — so unlike the `Ttl`-versus-`TTL` question the sibling round settled, there was no + competing family spelling to choose between. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value + reaching the parse raises the rename prescription instead of being silently stripped — + neither `PluginHealthCheckSchema` nor `HotReloadConfigSchema` is `.strict()`, and here the + stripped value would land on a `setInterval` period, a race deadline and a `setTimeout` delay + - the ADR-0087 D3 semantic entry `kernel-health-check-and-hot-reload-durations-unit-in-key` and + three `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: neither def is an authorable + surface — both are library parameters a host passes to `PluginHealthMonitor` / + `HotReloadManager` in TypeScript — so the chain has no seam that runs on them, the same + reading `plugin-auto-restart-never-reinitialised` and `hot-reload-watch-placeholder-retired` + recorded for keys on these two defs + - `@objectstack/core` moves with the rename: `PluginHealthMonitor` and `HotReloadManager` read + the suffixed keys, and each class's registration-time refusal table gains a row so a host + still passing an old spelling is answered with an ADR-0112 `VALIDATION_ERROR` / 400 naming + the rename, rather than getting `undefined` where a duration belongs + - pin tests on both schemas and both classes: the refusal carries the rename prescription, the + suffixed keys parse at the magnitude the retired ones carried with the same defaults, and the + describes publish the unit. The two minimum-bound pins were rewritten rather than left: spelled + through the bare keys they would have stayed green off the tombstone's refusal instead of the + bound, so they now assert the `too_small` issue code on the suffixed keys + - `HotReloadConfig.shutdownTimeout` is deliberately NOT renamed with them — its JSDoc reads + "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape the + #14478 gate leaves outside its verdict, not part of this row set +- cbcae14: feat(spec)!: the fifth `kernel/plugin-security-advanced.zod.ts` duration — `RuntimeConfig.resourceLimits.timeout` — carries its unit in the key name (#17781, ruling A on #15939) + + + + **BREAKING** — the execution timeout on a plugin sandbox's runtime block carries its unit in the + key name. + + | | before | after | + |:--|:--|:--| + | authored key | `resourceLimits.timeout: 60000` | `resourceLimits.timeoutMs: 60000` | + | published describe | `Maximum execution time` | `Maximum execution time in milliseconds` | + | value + bound | milliseconds, `int().min(0)` | **unchanged** | + + ## Migration + + ```diff + resourceLimits: { + maxMemory: 1073741824, + - timeout: 60000, + + timeoutMs: 60000, + } + ``` + + Rename the key. The value is the same number of milliseconds it always was and the `int().min(0)` + bound rides along with it; nothing else on `RuntimeConfig` moves. + + ## Why + + This is the key #15678 deliberately left alone, and this changeset closes it. `#15678` renamed the + four other plugin-security durations on this same file and recorded, accurately, that this one was + out of its scope: `resourceLimits.timeout` named its unit only in the JSDoc above it — "Execution + timeout in milliseconds" — a channel `check:duration-unit-keys` does not read (it reads + `.describe()` and `.meta({ description })`), and its describe said "Maximum execution time" and + named no unit at all. So the gate listed the key among the duration-shaped keys without judging it, + neither an offender nor an exemption, and the reader who most needs the unit — the reader of + `content/docs/references/kernel/plugin-security-advanced.mdx`, who never sees the source JSDoc — + got a bare integer and could not tell 60000 milliseconds from 60000 seconds. That JSDoc-channel gap + was filed as #15939 and is now ruled: director-seat **ruling A** (2026-09-11, maintainer 「同意」, + decision batch #115) remediates the population per file. Under the #14478 rule, moving the unit + into the describe alone is itself a violation — unit in prose, none in the name — so the key is + renamed and the describe is corrected in one stroke. + + Spelled `Ms`, the same token `SandboxConfig.process.timeoutMs` on this very file already carries: + counted on this tree, the suffixed family spells it that way in every member (29 key-position + `timeoutMs` declarations across `packages/spec/src/**/*.zod.ts`, 40 distinct `*Ms` keys), and no + `timeoutMillis`, `timeout_ms` or `timeoutMS` variant exists anywhere in `packages/spec/src`. + + ⚠️ Two keys on this one file spelled `timeout` and both now retire to a key spelled `timeoutMs`: + `RuntimeConfig.resourceLimits.timeout` (this one) and `SandboxConfig.process.timeout` (#15678). + They are different keys on different shapes, so each refusal names its own shape — check which + block you are editing. + + ## The kit + + - a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching + the parse raises the rename prescription instead of being silently stripped (the nested + `resourceLimits` object is not `.strict()`) + - the ADR-0087 D3 semantic entry `kernel-runtime-config-timeout-unit-in-key`, which states + explicitly that it completes what #15678 left alone so the two read as a sequence, and the + `RETIRED_KEYS_BY_MAJOR[18]` row `kernel/RuntimeConfig:resourceLimits.timeout`. No D2 conversion: + a `RuntimeConfig` is the engine block of the `SandboxConfig` a host or a plugin security manifest + constructs, `stack.zod.ts` declares no sandbox, security-policy or runtime-config collection, and + it is not a stored `sys_metadata` row — so the chain has no seam that runs on it. That is the + same reading #15678 recorded for the four keys it renamed. + - the pin test that asserted this key stays bare is **replaced, not removed**: it now pins that the + bare spelling is refused with the rename prescription, that `timeoutMs` parses at the same + magnitude beside its siblings, that the describe publishes the unit, and that the two same-named + `timeout` retirements on this file name their own shapes apart + - `content/docs/references/kernel/plugin-security-advanced.mdx` regenerated by `gen:docs`: three + rows move and the tombstone prescription renders in place of the old describe + - no authorable-surface row moves — that ratchet records top-level keys per def, and this key is + nested under `resourceLimits` (measured: `kernel/RuntimeConfig:` carries exactly + `engine`, `engineConfig` and `resourceLimits` across `authorable-surface/` and + `authorable-surface.base.json`, and `check:authorable-surface` is green without regeneration) +- 8261ff7: feat(spec)!: the four `system/logging.zod.ts` duration keys carry their unit in the key name (#17782, ruling A on #15939) + + + + **BREAKING** — the HTTP log destination's batch flush, retry backoff start and request deadline, + and the logging buffer's flush, now carry `Ms` in the key name. + + | def | before | after | + |:--|:--|:--| + | `HttpDestinationConfig` | `batch.flushInterval: 5000` | `batch.flushIntervalMs: 5000` | + | `HttpDestinationConfig` | `retry.initialDelay: 1000` | `retry.initialDelayMs: 1000` | + | `HttpDestinationConfig` | `timeout: 30000` | `timeoutMs: 30000` | + | `LoggingConfig` | `buffer.flushInterval: 1000` | `buffer.flushIntervalMs: 1000` | + | values, defaults, bounds | ms; 5000 / 1000 / 30000 / 1000; positive int | **unchanged** | + + ## Migration + + ```diff + const destination = HttpDestinationConfigSchema.parse({ + url: 'https://logs.example.com/v1/logs', + - batch: { maxSize: 500, flushInterval: 10000 }, + - retry: { maxAttempts: 3, initialDelay: 1000 }, + - timeout: 30000, + + batch: { maxSize: 500, flushIntervalMs: 10000 }, + + retry: { maxAttempts: 3, initialDelayMs: 1000 }, + + timeoutMs: 30000, + }); + + const logging = LoggingConfigSchema.parse({ + name: 'app_logging', + label: 'App logging', + destinations: [], + - buffer: { enabled: true, size: 5000, flushInterval: 2000 }, + + buffer: { enabled: true, size: 5000, flushIntervalMs: 2000 }, + }); + ``` + + Rename the keys. Every value is the same number of milliseconds it always was, and the + 5000 / 1000 / 30000 / 1000 defaults are unchanged; nothing else on either def moves. + + ## Why + + Each key named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry + delay in milliseconds", "Timeout in milliseconds" — and the JSDoc above a key is not what + `content/docs/references/**` renders; `.describe()` is, and **none of the four carried one at + all**. Measured by the `check:duration-unit-keys` census on this tree before the change, all four + read `[name: -] [prose: -]`: no unit in the key, and no published prose to supply it either. So + `content/docs/references/system/logging.mdx` printed a bare `5000` / `1000` / `30000` / `1000`, + and nothing on the page decided milliseconds from seconds. Under the #14478 rule, moving the unit + into the describe alone would itself be a violation (unit in prose, none in the name), so each key + is renamed and given the describe it never had in the same stroke. Executes director-seat ruling A + on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the + #14478 rule. + + ⚠️ `flushInterval` was declared **twice** on this file, in two different defs and with two + different defaults — 5000 on the HTTP destination's `batch`, 1000 on the logging `buffer`. They are + two keys, not one; each gets its own tombstone, its own registered row, and a prescription that + names its def, so an author who lands on one is not sent to the other. + + The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` + declarations in `packages/spec/src` against 75 `*Seconds:`. The only competing unit spellings are + 3 `*MS:` and 9 `*Millis:`, and every one of them mirrors a name fixed outside this repo — MongoDB's + `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and + `connectionTimeoutMillis` on `PoolConfigSchema` — so unlike the `Ttl`-versus-`TTL` question a + sibling round had to settle, there was no in-repo alternative to choose between. All three target + spellings were already attested as key-position `*.zod.ts` declarations before this change: + `flushIntervalMs` 1 (on `kernel/events/integrations.zod.ts`, at the same 1000 default), + `initialDelayMs` 5, `timeoutMs` 30. + + ## The kit + + - a `retiredKey()` tombstone on each of the four old spellings, so `tsc` types it `never` and a + value reaching the parse raises the rename prescription instead of being silently stripped — none + of the four enclosing objects is `.strict()` (`HttpDestinationConfig` itself and its nested + `batch` and `retry`; `LoggingConfig`'s nested `buffer`) + - the ADR-0087 D3 semantic entry `logging-durations-unit-in-key` and four + `RETIRED_KEYS_BY_MAJOR[18]` rows, one per key. No D2 conversion: `stack.zod.ts` declares no + logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is + referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no + rehydration seam that runs on an authored logging document — the same reading + `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key + - pin tests per key: the refusal carries the rename prescription and names the def, the suffixed + key parses at the magnitude the retired one carried with the same default, and the describe + publishes the unit + - exactly one authorable-surface row pair moves, and it is the one that should: that ratchet records + top-level keys per def (`build-schemas.ts` reads `schema.properties` one level deep), and + `HttpDestinationConfig.timeout` is the only top-level key of the four — + `system/HttpDestinationConfig:timeout` becomes `[RETIRED]` beside a new + `system/HttpDestinationConfig:timeoutMs`, and the `authorable-defaults/` row is renamed with it. + The three nested keys move neither file, which is correct and not an omission + - the pinned objectui checkout is untouched by this rename: at `.objectui-sha` pin + `53ded82bf7a494f54e344e19099dbf00854b8694` it spells `flushInterval` 0 times, `initialDelay` 0, + `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 6409 tracked files, against lit + controls `useState` 2304 and `timeout` 702 on the same corpus +- 24489f1: feat(spec)!: the five `system/metrics.zod.ts` durations carry their unit in the key name (#17783, ruling A on #15939) + + + + **BREAKING** — the five metrics durations whose unit was stated only in a source JSDoc now carry + it in the key name, and each published `.describe()` states it too. + + | def | before | after | + |:--|:--|:--| + | `MetricDefinition` | `summary.maxAge: 600` | `summary.maxAgeSeconds: 600` | + | `ServiceLevelObjective` | `errorBudget.burnRateWindows[].window: 3600` | `errorBudget.burnRateWindows[].durationSeconds: 3600` | + | `MetricExportConfig` | `interval: 60` | `intervalSeconds: 60` | + | `MetricsConfig` | `collectionInterval: 15` | `collectionIntervalSeconds: 15` | + | `MetricsConfig` | `retention.period: 604800` | `retention.durationSeconds: 604800` | + + Every value is seconds, exactly as before, and every default (600, 3600 as authored, 60, 15, + 604800) is unchanged. + + ## Migration + + ```diff + summary: { + - maxAge: 600, + + maxAgeSeconds: 600, + } + + errorBudget: { + - burnRateWindows: [{ window: 3600, threshold: 14.4 }], + + burnRateWindows: [{ durationSeconds: 3600, threshold: 14.4 }], + } + + exports: [{ + type: 'prometheus', + - interval: 60, + + intervalSeconds: 60, + }], + - collectionInterval: 15, + + collectionIntervalSeconds: 15, + retention: { + - period: 604800, + + durationSeconds: 604800, + }, + ``` + + Rename the keys. Nothing else on these four defs moves, and the three same-named objects on this + file — `MetricAggregationConfig.window`, `ServiceLevelIndicator.window` and + `ServiceLevelObjective.period` — are untouched. + + ## Why + + Each key named its unit in a source JSDoc — "Max age of observations in seconds", "Window size in + seconds", "Export interval in seconds", "Collection interval in seconds", "Retention period in + seconds" — and nowhere else. Four of the five carried no `.describe()` at all and the fifth read + "Window size", so the text `content/docs/references/system/metrics.mdx` publishes named no unit: + 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of + milliseconds, and nothing on the page decided between them. Executes director-seat ruling A on + #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the + #14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit + in prose, none in the name), so each key is renamed and its describe corrected together. + + Three of the five new names are deliberately **not** the mechanical suffix, and this file supplied + the reason for each: + + - `burnRateWindows[].window` → **`durationSeconds`**, not `windowSeconds`. It is the fourth window + length on this file, and #15679 already settled that a window length here reads `durationSeconds` + so the measurements read alike. `windowSeconds` would stutter against the enclosing + `burnRateWindows` array — the same objection #15679 recorded against `window.windowSeconds` — and + on this tree `windowSeconds` is not an authorable key at all: its only key-position occurrence is + an alias-map entry in `ServerRateLimitConfigSchema` that maps the spelling *away* to `windowMs`. + - `retention.period` → **`durationSeconds`**, not `periodSeconds`. `period` is calendar vocabulary + elsewhere in this spec (`ServiceLevelObjective.period.type` selects rolling or calendar, + `PluginRegistryEntry.pricing.billingPeriod` is monthly or yearly), so `periodSeconds` would have + kept the ambiguous half of the name — the same objection #15679 raised against `sizeSeconds`. + - `collectionInterval` → **`collectionIntervalSeconds`**, keeping the qualifier, because + `MetricExportConfig.intervalSeconds` is a different cadence one def over that this same change + creates. + + The two mechanical spellings are attested: `maxAgeSeconds` is the token + `AccessControlConfig.maxAgeSeconds` already carries after this same rule renamed it on + `system/object-storage.zod.ts`, and it keeps the `age` stem that the sibling `ageBuckets` counts + buckets of; `intervalSeconds` is the token four seconds-valued cadences already carry. Counted in + key position across `packages/spec/src` at `fc28c1d38`, the base of this change, the seconds + suffixes run `Seconds` 40, `Sec` 1 (`maxExecutionTimeSec`) and `S` 0 — the two bare `*S` keys on + that corpus, `maxCommitTimeMS` and `enableRLS`, are a millisecond spelling and a boolean. This + change takes `Seconds` to 45 at `9b62f54671`. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching + the parse raises the rename prescription instead of being silently stripped (none of the five + enclosing shapes is `.strict()`) + - the ADR-0087 D3 semantic entry `system-metrics-jsdoc-durations-unit-in-key` and five + `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no metrics collection + and none of these defs is a stored metadata row — the reading + `system-metrics-window-durations-unit-in-key` already recorded for this file + - pin tests per key: the refusal carries the rename prescription and is not an `unrecognized_keys` + issue, the suffixed key parses at the magnitude the retired one carried with the same default, + and each describe publishes the unit + - two authorable-surface rows move, three do not: that ratchet records **top-level** keys per def, + so `MetricExportConfig:interval` and `MetricsConfig:collectionInterval` become `[RETIRED]` beside + their suffixed rows (and their `authorable-defaults` rows move with them), while + `summary.maxAge`, `burnRateWindows[].window` and `retention.period` are nested and move nothing +- fc28c1d: feat(spec)!: the `system/tenant.zod.ts` schema-cache TTL key carries its unit in the key name (#17784, ruling A on #15939) + + + + **BREAKING** — the schema-cache TTL on the `isolated_schema` tenant isolation strategy carries + its unit in the key name. + + | | before | after | + |:--|:--|:--| + | authored key | `performance.schemaCacheTTL: 3600` | `performance.schemaCacheTtlSeconds: 3600` | + | published describe | `Schema cache TTL` | `Schema cache TTL in seconds` | + | value + default | seconds, `3600` | **unchanged** | + + ## Migration + + ```diff + performance: { + - schemaCacheTTL: 3600, + + schemaCacheTtlSeconds: 3600, + } + ``` + + Rename the key. The value is the same number of seconds it always was, and the `3600` default is + unchanged; nothing else on `SchemaLevelIsolationStrategy` moves. + + ## Why + + The key named its unit in a source JSDoc — "Schema cache TTL in seconds" — and nowhere else. The + `.describe()` that `content/docs/references/system/tenant.mdx` renders said "Schema cache TTL" and + named no unit at all, so the one reader who most needs it, the reader of the published reference + page, was the only reader who never saw it: `3600` is a plausible number of seconds and a plausible + number of milliseconds, and nothing on the page decided between them. Executes director-seat ruling + A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the + #14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit + in prose, none in the name), so the key is renamed and the describe is corrected together. + + The new spelling is `Ttl`, not `TTL`: counted on this tree, every member of the suffixed family + already spells it that way — `cacheTtlSeconds` (11), `ttlSeconds` (3), `defaultCacheTtlSeconds` (1). + + ## The kit + + - a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching the + parse raises the rename prescription instead of being silently stripped (the nested `performance` + object is not `.strict()`) + - the ADR-0087 D3 semantic entry `tenant-schema-cache-ttl-unit-in-key` and the + `RETIRED_KEYS_BY_MAJOR[18]` row `system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL`. + No D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is + not a stored metadata row, so the chain has no seam that runs on it — the same reading + `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file + - pin tests on `SchemaLevelIsolationStrategySchema`: the refusal carries the rename prescription, the + suffixed key parses at the magnitude the retired one carried with the same `3600` default, and the + describe publishes the unit + - no authorable-surface row moves — that ratchet records top-level keys per def, and this one is + nested under `performance` (measured: 0 hits for the key across `authorable-surface/` and + `authorable-surface.base.json`, against 4 for the `system/MigrationPlan:` control) +- 6d64785: feat(spec)!: the four `system/tracing.zod.ts` duration keys carry their unit in the key name (#17785, ruling A on #15939) + + + + **BREAKING** — the OTel exporter deadline, the batch processor's two knobs and the background + span-export period now carry `Ms` in the key name. + + | | before | after | + |:--|:--|:--| + | `OpenTelemetryCompatibility.exporter` | `timeout: 10000` | `timeoutMs: 10000` | + | `OpenTelemetryCompatibility.exporter.batch` | `exportTimeout: 30000` | `exportTimeoutMs: 30000` | + | `OpenTelemetryCompatibility.exporter.batch` | `scheduledDelay: 5000` | `scheduledDelayMs: 5000` | + | `TracingConfig.performance` | `exportInterval: 5000` | `exportIntervalMs: 5000` | + | values, defaults, bounds | ms; 10000 / 30000 / 5000 / 5000; `int().positive()` | **unchanged** | + + ## Migration + + ```diff + const otel = OpenTelemetryCompatibilitySchema.parse({ + exporter: { + type: 'otlp_grpc', + - timeout: 10000, + + timeoutMs: 10000, + batch: { + - exportTimeout: 30000, + - scheduledDelay: 5000, + + exportTimeoutMs: 30000, + + scheduledDelayMs: 5000, + }, + }, + resource: { serviceName: 'api-server' }, + }); + + const tracing = TracingConfigSchema.parse({ + name: 'default_tracing', + label: 'Default Tracing', + - performance: { exportInterval: 5000 }, + + performance: { exportIntervalMs: 5000 }, + }); + ``` + + Rename the keys. Every value is the same number of milliseconds it always was, the + 10000 / 30000 / 5000 / 5000 defaults are unchanged, and nothing else on either def moves. + + ## Why + + Each key named milliseconds in a source JSDoc — "Timeout in milliseconds", "Export timeout in + milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — + and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is. + Measured on this tree: all four carried **no `.describe()` at all**, so the published reference + row for each was a bare integer with no unit anywhere on the page. That is a strictly worse + channel than the unit-in-prose shape #14478 already refuses — here the reference reader had no + prose to misread. All four magnitudes read plausibly in both units (10000, 30000, 5000, 5000), + and an operator who reads seconds sets an exporter deadline 1000x short. Executes director-seat + ruling A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file + remediation of the #14478 rule, and closes the last of that ruling's seven cards. + + The suffix is the family's own spelling, counted in key position across `packages/spec/src`: + 281 `*Ms` declarations over 42 distinct names, `timeoutMs` 65 of them and `intervalMs` 14, + against **0** key-position `timeoutSeconds`. The Delay-plus-`Ms` pairing is likewise already + attested (`maxDelayMs`, `initialDelayMs`, `retryDelayMs`, `delayMs`, `debounceDelayMs`) with no + competing `scheduledDelay` spelling anywhere. This file is milliseconds throughout and its own + landed precedent is `Span.duration → durationMs` (#15679) — the opposite of the sibling metrics + card, whose rows were seconds. + + `exporter.timeoutMs` and `exporter.batch.exportTimeoutMs` deliberately sit one nesting level + apart. The pair pre-exists the rename: the `batch` sub-object is the OpenTelemetry batch span + processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) + beside the exporter's own request deadline. Renaming either to something more distinctive would + depart from the vocabulary this shape mirrors, and the nesting already disambiguates every read + point — `exporter.timeoutMs` versus `exporter.batch.exportTimeoutMs`. + + ## The kit + + - a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value + reaching the parse raises the rename prescription instead of being silently stripped. Neither + `OpenTelemetryCompatibilitySchema` nor `TracingConfigSchema` nor any object nested inside them + is `.strict()`, so `unrecognized_keys` was never the alternative — a bare deletion would have + landed a default on an exporter deadline and a background export period + - the ADR-0087 D3 semantic entry `system-tracing-otel-exporter-durations-unit-in-key` and four + `RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no tracing + collection, no metadata-type binding or manifest embed carries either def, and a tracing + configuration is never a stored `sys_metadata` row — so the chain has no seam that runs on + them, the same reading `system-tracing-span-duration-unit-in-key` recorded for the other key + on this file + - pin tests: a refusal pin per row asserting the issue **code** (never a bare `toThrow()`) and + the FROM → TO prescription, an acceptance pin at each retired key's magnitude with the same + default, a bounds pin, and a describe pin proving the unit now reaches the published channel + - the `authorable-surface` / `authorable-defaults` ratchets move **nothing**, and that is the + correct outcome rather than an omission: those artifacts record top-level keys per def + (`build-schemas.ts` reads `schema.properties` one level deep) and every one of these four is + nested + - `Span.duration → durationMs`'s own entry is untouched — a predecessor's scoped record stays + true, and this round's entry opens by saying how it relates to it +- 134b410: The artifact-ingestion door no longer replays the **default-flip** class of ADR-0087 conversion, so an artifact carrying `defineApp({ hidden: true })` is registered with `hidden: true` — not as an unpublished app (#17885, #4829). + + `app-hidden-to-unpublished` rewrites `app.hidden: true` into `app._unpublished: true`. Both keys are live and they mean opposite kinds of thing: `hidden` is navigation presentation and *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the machine-managed publish gate `filterAppForUser` drops the app on for every user without `studio.access` / `setup.access`. Measured before the change, on an artifact declaring `engines.protocol: ^17.0.0` — the range `create-objectstack` stamps — against a 17.4.0 runtime: the door emitted the `app-hidden-to-unpublished` notice and the object that reached registration carried `hidden: undefined`, `_unpublished: true`. So an author who asked for "keep this out of the App Switcher" got "nobody but a builder can see this" — the incident the `_unpublished` split was introduced to end, arriving through the conversion layer. + + - **The entry is not withdrawn and no key moves.** It still fires where its precondition is a fact — the stored-row rehydration seams (a pre-split `hidden: true` row can only have come from the materialization path) and `os migrate meta`, where the operator asserts the source's age. What changed is that the artifact door, whose evidence is the artifact's **declared `engines.protocol` floor** rather than its age, no longer treats that guess as sufficient for a rewrite that reinterprets a live authorable key. + - **The retired window stays open.** Closing it wholesale would fix this and re-break #12772: an artifact built by 17.1.0 tooling carrying `allowRestore` / `allowPurge` would again be refused at the tombstone with no operator remedy. The door refuses one named class by id, with its reason written beside it, and the pin drives a retired conversion and a non-retired one through the same window to prove it. + - **New seam option, no new export.** `applyConversions` accepts `excludeConversionIds` — the seat-level spelling of "my evidence cannot carry this entry". `retiredFromLoadPath` cannot express it: that flag's jurisdiction is the authoring funnel and nothing else. + - ⛔ **The consumer is unchanged.** `filterAppForUser` withholding on `_unpublished` is correct; the defect was who writes `_unpublished`. + + Deployments whose apps were being served as unpublished purely because of a permissive `engines.protocol` range will see those apps again, for every user, on the next boot. No artifact file changes and no stored row is rewritten. +- 84e6b05: `@objectstack/plugin-approvals` is now registered as a second emitter of the already-registered `RESUME_FAILED` in `ERROR_CODE_LEDGER`, so the only correct implementation of `ResumeFailureReport.code` stops being refused by `check:error-code-provenance`. + + **The contradiction this closes.** `ResumeFailureReport` (`contracts/approval-service.ts`) declares `code: ErrorCode` as **required** — "a success answer has no envelope `code` to fall back on" — and its docblock prescribes `RESUME_FAILED` for a run that could not be advanced. But the ledger listed that code only under `@objectstack/rest`, so the first producer to fill the slot stamped a registered code its own owner key did not list, which the provenance gate refuses. The declaration shipped in a state where satisfying it tripped a sibling gate. + + **Measured, not derived.** With PR #17908's stamp site present and the ledger unchanged, the guard answers exit 1 and names it: `@objectstack/plugin-approvals stamps 'RESUME_FAILED' (objlit) at packages/plugins/plugin-approvals/src/approval-service.ts:3370 — not listed under its own owner key`. With this row, the same tree answers exit 0 with the site counted as listed. + + **A row, not a waiver — the precedent's own predicate decides it.** The `EXTERNAL_IMPORT_ERROR` waiver records "the door stamps this code itself for every throw and never reads the producer's declaration". Both halves fail for `resumeFailure`: it rides a **success** answer, which the REST approvals door serves with `res.json(out)` verbatim, and `packages/rest/src` spells `resumeFailure` nowhere. The producer's literal *is* the wire value, so the door names no vocabulary to waive it under. + + **One code, not the three the docblock names.** `RESUME_TARGET_LOST` is a thrown message prefix mapped by rest's catch and stays under rest's row; `RESUME_IN_PROGRESS` is compared and never constructed in this package, and is emitted by `@objectstack/service-automation`, which carries its own row. A row for a code the package does not stamp would be the dead weight this file's gate refuses. + + ⛔ **No wire byte moves and no accept set widens.** `RESUME_FAILED` was already in the registered union, so no response can now carry a code it could not carry before; the per-package rows are provenance, not identity. No exported symbol is added and no published payload gains a key. +- 5f392f0: feat(spec): the ADR-0112 error envelope gains a producer-side `refusal` declaration, so a deliberate 5xx refusal can keep its caller-authored `message` (#16335) + + `ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, **`refusal: true`** — the producer's declaration that the 5xx it named is a deliberate REFUSAL whose `message` is authored for the caller, so the boundary keeps that message verbatim instead of withholding it. Director ruling, decision batch #58 (2026-09-06, option C): the refusal/fault distinction is a producer-side declaration on the published envelope — not a status heuristic and not a second allow-list. + + The three cases are now documented side by side on the envelope's TSDoc: + + - **undeclared 5xx** (no `status` on the throw) — unchanged: the leak heuristic decides per message. + - **declared fault** (`status >= 500` + `code`, nothing declared here) — unchanged, and still the DEFAULT: `message` is withheld from the body and logged for the operator. + - **declared refusal** (`status >= 500` + `code` + `refusal: true`) — new: `message` is kept verbatim, bounded exactly as a 4xx message is. + + Purely additive: a producer that says nothing here gets exactly the previous behaviour. `true` is the only value — `refusal: false` fails parse instead of becoming a third state consumers would have to interpret. `userMessage` is orthogonal (end-user text; it never replaces `message`) and may ride the same envelope; the TSDoc reconciles this flag with the recorded reason `userMessage` is a text-carrying field rather than "a boolean beside `message`". + + This is the spec half. The relay half — the three withhold arms reading the declaration (two in `@objectstack/rest`: `declaredServerFaultAnswer`, and `resolveErrorResponse`'s own 5xx passthrough arm, which the `/references` door reaches; one at `@objectstack/runtime`'s dispatcher exit, `errorResponseBase`, which `objectstack serve` mounts and which never consults the first), plus retiring the route-local patch from PR #16143 on `/meta/:type/:name/references` — is #16146 for the REST pair and its sub-issue #17153 for the runtime exit; until they land, a declared refusal is still withheld at the wire. +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 929d9e3: feat(spec)!: delete the seven cron-typed positions nothing evaluated — export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules (ADR-0049) + + + + **BREAKING** — seven authorable positions across five schemas are DELETED. Executes the + maintainer ruling of 2026-09-06 (director decision batch #56, 「其他同意」 on the per-family + recommendation: option A — retire — per family) under ADR-0049 enforce-or-remove, by the + route the maintainer ruled on 2026-09-10: **直接删** — a bare deletion, with no + `retiredKey()` tombstone, no ADR-0087 D2 conversion and no D3 semantic entry. + + Seven positions declared a `CronExpressionInputSchema` slot that the parse normalized into + the `{ dialect: 'cron', source }` envelope and that NOTHING evaluated — the ADR-0058 D7 + ledger row `cron-declared-unwired` had every one of them `unevaluated`. + + | family | schema | deleted position | reachable from a stack manifest | + |:--|:--|:--|:--| + | export schedules | `ScheduledExport`, `ScheduleExportRequest` (`api/export.zod.ts`) | `schedule.cronExpression` (both) | no — API contract nothing serves | + | flow schedule state | `ScheduleState` (`automation/execution.zod.ts`) | `cronExpression` (was REQUIRED) | no — runtime state | + | connector sync | `DataSyncConfig` (`integration/connector.zod.ts`) | `schedule` | **yes** — `Connector.syncConfig`, `defineStack({ connectors })` | + | cache warmup | `CacheWarmup` (`system/cache.zod.ts`) | `schedule` | no | + | backup / DR testing | `BackupConfig`, `DisasterRecoveryPlan.testing` (`system/disaster-recovery.zod.ts`) | `schedule` (both) | no | + + **What an upgrading author actually observes.** None of the five schemas is `.strict()`, so + a bare deletion means Zod DROPS the key at the PARSE: an existing document still parses and + still loads, and the value is discarded there without a word. There is nothing for + `objectstack migrate meta` to list and nothing for the ADR-0087 chain to replay — the value + was already inert before this change, and it is inert after. + + The parse is not the only channel, and the two that speak are worth stating exactly, + because a reader who stops at "non-strict schema" will conclude the opposite: + + - **`os validate` / `os build` NAME the dropped key**, for the one deleted position a stack + manifest reaches (`connectors[].syncConfig.schedule`). `os validate` exits 0 and reports + `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its + value is dropped at load.` — in the text face and in `--json`'s `warnings`; `os build` + prints the same line under `Undeclared authoring keys — dropped at load (#3786)`. The + channel is `lintUnknownAuthoringKeys`, which walks every stack collection whose entry + schema is strip-mode, and `connectors` is one. **`os validate --strict` treats that warning + as an error and EXITS 1**, so a pipeline running `--strict` over an otherwise-clean stack + refuses the upgraded manifest until the key is deleted. `os migrate meta` still lists + nothing, in either direction. + - **`tsc`**: a TypeScript author annotating with `Connector`, `ScheduledExport`, + `ScheduleState`, `CacheWarmup`, `BackupConfig` or `DisasterRecoveryPlan` gets an + excess-property error at the key and deletes it. + + The other six positions are not reachable from a stack manifest, so no CLI walk visits them: + for those the parse-level strip really is the whole of it. + + **What stays, byte-identical:** every other key of the five schemas and every export — no def + leaves the public surface. `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep + their `timezone` (still defaulting to `UTC`); `ScheduleState` keeps `timezone`, `status` and + `nextRunAt`, and a state without `cronExpression` now parses (the requiredness left with the + key); `CacheWarmup.strategy` keeps its `scheduled` member — a value, not a position the + ruling names, and exactly as inert as before. + + **One published TS MEMBER does leave, and "no def leaves" does not cover it.** The required + `cronExpression: string` member is deleted from `ScheduleExportInput` in + `contracts/export-service.ts` — the input type of `IExportService.scheduleExport`, a + published runtime TS interface (both names are in `api-surface/contracts.json`). It follows + the two spec positions it mirrored: with `ScheduledExport.schedule.cronExpression` gone, an + input demanding the key would ask a provider for a cadence it cannot store. The interface, + the method and every other member stay. Measured blast radius: no source outside + `packages/spec` names `ScheduleExportInput` or `IExportService` — 0 hits in this repo + (positive control: a symbol of the same class resolves outside `packages/spec` in the same + sweep) and 0 in `objectui` (control: 1326 files there import `@objectstack/spec`). An + implementor that *does* exist off-tree drops the member from its object literal; a caller + constructing a `ScheduleExportInput` drops it from the literal it passes. + + **Not in scope, deliberately:** `CronSchedule.expression` (`system/job.zod.ts`, read by + `croner` — the ONE cron slot the platform evaluates), `KnowledgeRefreshPolicy.cron` + (experimental by design), `Object.titleFormat`, and the `PromptTemplate` pair (marked, not + retired, on its sibling card). + + ## This change states no before/after rewrite, because there is none + + A breaking changeset in this repo normally states the old spelling beside the new one. + This one has no such pair to state: the same document PARSES before and after, the value + was inert in both, and no conversion can be written for it — so a metadata upgrader has no + edit to make and `os migrate meta` has nothing to list. That is a statement about the + migration chain, not about silence: `os validate` / `os build` do name the dropped + connector key and `os validate --strict` refuses on it (above), and `tsc` names the key and + the line for a TypeScript author. What follows is guidance for authoring a cadence going + forward, not a rewrite of an existing document. + + ## What to write instead + + There is no replacement on any of the five schemas: no export scheduler, flow-state + scheduler, connector-sync scheduler, cache-warmup engine, backup engine or DR-test runner + exists to declare a cadence to. The one cron slot the platform evaluates is + `Job.schedule.expression` (`system/job.zod.ts`) — work on a cadence is a `job` whose handler + you write: + + ```ts + // A connector that used to carry `syncConfig.schedule: '*/15 * * * *'` declares + // the cadence as a job instead; the handler drives the connector. + defineStack({ + connectors: [{ name: 'sap_erp', label: 'SAP ERP', type: 'saas', syncConfig: { strategy: 'incremental' } }], + jobs: [{ name: 'sap_erp_sync', schedule: { expression: '*/15 * * * *' }, handler: 'syncSapErp' }], + }); + ``` + + The retirement kit, in the shape the 2026-09-10 ruling prescribes: + + - the key is DELETED at all seven sites (`api/export.zod.ts` ×2, + `automation/execution.zod.ts`, `integration/connector.zod.ts`, `system/cache.zod.ts`, + `system/disaster-recovery.zod.ts` ×2). Each site keeps a source comment recording what + left, why nothing ever read it, and what does work instead + - **no ADR-0087 registration at all** — no `RETIRED_KEYS_BY_MAJOR[18]` entry, no D2 + conversion, no D3 semantic entry, and nothing added to the protocol-18 chain step. That is + the ruling: 「直接删」, taken over the seat's written recommendation to keep the connector + family's D2, on the reading 「我们的客户也不会按照你的设想的版本按顺序升级」 + - the four baseline rows that existed (`automation/ScheduleState:cronExpression`, + `integration/DataSyncConfig:schedule`, `system/BackupConfig:schedule`, + `system/CacheWarmup:schedule`) are deleted from `authorable-surface/` in this same commit, + each carrying the #4650 proof the build computes for itself: the def is not reachable from + the 26 metadata-type roots. The three nested positions never had a row of their own + - no liveness-ledger row: none of the five schemas is an enrolled ledger type + - the ADR-0058 D7 expression-conformance ledger loses its `cron-declared-unwired` row (every + position it covered is gone, so discovery by roster name no longer sees them); the cron + dialect is now exactly the one evaluated slot plus the one experimental-by-design slot + - pin tests (`cron-typed-positions-retirement.test.ts`): per site, the authored value is + accepted and stripped and the enclosing block still parses, on the base schema and through + every nesting carrier (`Connector.syncConfig`, `stack.connectors[]`, the `/meta/connector` + door, `DisasterRecoveryPlan.backup`, `DistributedCacheConfig.warmup`); the `tsc` channel; + and — with lit and dark controls — that no `RETIRED_KEYS_BY_MAJOR` entry, no D2 conversion + and no D3 semantic entry names any of the seven + - generated baselines and docs follow the schema: the five reference pages are regenerated, + the published `objectstack-formula` skill's `cron` row drops the retired carriers and keeps + `Job.schedule.expression`, and `packages/spec/docs/SYNC_ARCHITECTURE.md` stops teaching + `syncConfig.schedule` + - `json-schema.manifest/` and `api-surface/` are unchanged, and correctly so: the first + ratchets def *names* and the second export *existence*; deleting keys removes neither +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- 1f0b565: fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. `options.stageOrder` was an ungated member of the widget `options` bag and parsed on every widget `type`; it is now refused at parse on every type except `funnel`. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying `stageOrder` on a non-`funnel` widget now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `dashboard-widget-stage-order-non-funnel-refused`. + + ## What was wrong + + The key never failed. It failed to *order*. + + `options` is the open renderer-extras bag, so nothing closed over `stageOrder`: a `horizontal-bar` widget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. + + Measured at this repo's `.objectui-sha` pin `53ded82b`: the forwarded `categoryOrder` prop has exactly **one** read in the charts plugin — `buildCategoryRank(categoryOrder)` at `AdvancedChartImpl.tsx:1514` — and it sits inside the `chartType === 'funnel'` guard opened at line 1473. The prop's only other occurrences in that file are its declaration (247) and its destructure (850). The producer has no gate either: `DatasetWidget.tsx:1468` builds the explicit order for **any** widget and forwards it whenever non-empty. + + So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there with nothing anywhere to say so. A chart rendered in an order the author did not ask for, and did not ask for it *visibly* — it just looked deliberate. That is ADR-0049's enforce-or-remove shape, and a doc sentence saying "only `funnel` reads this" is not enforcement: it is prose the author has to read first. + + ## What it does now + + `DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`. + + It has to be object-level: `stageOrder` lives inside `DashboardWidgetOptionsSchema` while the `type` that decides whether it means anything is that object's **sibling one level up**, so a per-field refinement on `stageOrder` cannot see it. The check is a named function chained on with `.superRefine(…)` — the idiom this file already uses for `GlobalFilterSchema`'s date-default rule, rather than a second shape invented for one key. + + The refusal lands at `options.stageOrder` and names three things, because the defect was silence and a bare "unrecognized key" answers silence with a shrug: the key, the `type` this widget carries, and the one `type` that honours it — plus where ordering lives for everything else. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` | + | `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it | + | `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant | + + ⚠️ Deleting the key changes nothing about what renders — the widget was already ignoring it. `sortBy` / `sortOrder` are what change it, and unlike a category order they lower into the dataset query as `order: { : 'asc' | 'desc' }` rather than re-sorting what it returned. + + ## What the gate does NOT cover + + Stated so the change is not read as complete: + + - ⚠️ **objectui's client-side authoring door.** This refusal is the **publish** door's, not the editor's. `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check — measured here: `z.strictObject(DashboardWidgetSchema.shape)` accepts a `horizontal-bar` carrying `stageOrder` and reports zero checks, while `.extend({})` keeps the refusal. At the pinned `.objectui-sha` that package re-attaches none of this spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard editor keeps accepting the key on a `bar`. That mirror also redeclares `type` as optional with no default, so a typeless widget would reach a re-attached check as `undefined` rather than as `metric`; the exported check defaults it itself for exactly that caller, so re-attaching is sufficient. + - **A widget whose `type` is outside `ChartTypeSchema`.** zod treats that `invalid_value` as aborting and skips object-level checks for the input, so `type: 'ziggurat'` plus a `stageOrder` reports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then; the two are never seen together. Pinned. + - **A widget that declares no `type`.** `type` carries `.default('metric')` and zod applies defaults before object-level checks, so an omitted `type` is indistinguishable here from an authored `metric`. The verdict is right either way — `metric` reads the key no more than `horizontal-bar` does — and that one case carries an extra sentence pointing at the missing `type` rather than a wrong one. + - **The array's contents.** Still unconstrained `string | number | boolean` members, unmatched against the dimension's picklist. A `funnel` carrying a misspelled stage parses and renders that stage in the sentinel position; whether a stored value exists is a fact about the dataset, not about the widget. + - **Consumers that derive this schema with `.omit()` / `.pick()` / `.partial()`.** zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and `.extend()` is unaffected. + + ## The siblings, measured and deliberately not touched + + `stageOrder` was the only member of that bag with this shape. `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443–455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. + + ## The other arm, deliberately not taken + + The card offered either/or: gate the key, **or** teach the ordered marks (`bar` / `column` / `horizontal-bar` / `line` / `area`) to honour it. The second is a renderer change in `objectstack-ai/objectui` and not this repo's to make. The asymmetry also favours gating: a narrowing that is later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author. +- 23aa83c: `DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. + + **What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. + + **Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. + + **Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. + + This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. +- 357f499: feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. A dataset + measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to + `AVG(col)` and reach the backend; it is now refused by `compileDataset` before any + query is built. Shipped as `minor` under the repo's launch-window convention for + accept-set narrowings; the hand-migration prescription is registered under protocol + major 18 as `dataset-measure-aggregate-field-type-refused`. + + The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table + `@objectstack/spec` declared in #16353 under the director ruling of decision batch + #59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and + restates none: the refusal reads the shipped predicate, so the contract has exactly + one statement. + + ## What was wrong + + The answer to `AVG` over a temporal column was decided by the SQL dialect rather + than by the data. Both halves measured on this card: + + ``` + -- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) + select typeof(submitted_at), submitted_at from clm_contract limit 1; + text|2026-05-19T00:00:00.000Z + select avg(submitted_at) from clm_contract; + 2025.5 <- text->numeric coercion: the average YEAR + + -- PostgreSQL 16.13 + select avg(submitted_at) from t; + ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 + ``` + + The silent half is the dangerous one, and SQLite is the default dev datasource: + `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned + `-0.85` and rendered on a tile labelled "average cycle time delta" — a number + indistinguishable from a correct one. Nothing refused it at any layer: not the + schema, not `os validate` / `os lint`, not the analytics service, not the renderer. + + ## What it does now + + - `compileDataset` refuses an incompatible `aggregate` × `field` pair with + `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and + the accepted set (read off the table, never restated). Nothing reaches the driver. + - It reads the declared type from the `sourceFieldMeta` a host already wires, via a + new optional `DatasetCompileOptions.declaredFieldType` probe. + - **`derived` is covered by construction.** A derived measure's `of` operands are + base measures of the same dataset, so a dataset carrying a refused base measure + never finishes compiling and no `derived` op can be handed its output — including + when the selection names only the derived measure. + - Tiered "cannot answer, do not block" like every sibling probe: no + `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose + column lives on a joined object) leaves the pair unjudged. + + ## ⚠️ Scope: the compile leg executes the TEMPORAL rows only + + > ⚠️ **Superseded within the same release window.** This section was accurate when it was + > written and is kept as the record of where the compile leg stopped. Two later cards + > widened it before any of the three entries shipped, so at the version that compiles this + > entry the scope below is no longer the platform's: **#16099** judged `sum` / `avg` over + > every remaining field class (including `sum` over a `percent`), and **#17560** (director + > ruling, decision batch #127, 2026-09-13) judged `min` / `max` over every class the table + > refuses. ⇒ Three sentences in this section are false at that version and are corrected + > where they stand: the string rows are **not** awaiting a table amendment, `sum` over a + > `percent` does **not** compile as it did before, and `avg` / `sum` over a temporal field + > are **not** the only pairs whose behaviour changes. Read all three entries together. + + The gate judges only a measure whose field is declared `date` / `datetime` / + `time`; a field of any other class is never handed to the predicate. The + verdict for the pairs it does judge is the table's — no row is restated — but + which FIELDS are judged is narrower than the table, on purpose: + + - **String rows** (`min` / `max` over `text`, `select`, `lookup`, + `autonumber`, …) are **not enforced here**. ⚠️ This card recorded them as + 「under #16785, **ruled C** — the table itself is to be amended to accept + them」, because `measureResultType` (#15768) already typed those results as + `'string'` and pinned them end to end, so enforcing them from here would + pre-empt that ruling. **Both halves of that sentence turned out to be + wrong.** `16785` resolves to no issue, and decision batch #127 (#17560, + 2026-09-13) found no ruling C anywhere behind the citation — the one recorded + ruling on this table, decision batch #59, refuses the string rows. ⛔ The + table is **not** amended; #17560 enforces those rows and retires the + `measureResultType` opinion that disagreed with them. + - **Boolean rows** are not a refusal at all any more: #16685 was ruled A and + #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the + table ACCEPTS them and this gate never judged them. + - The table's `sum` × `percent` row is likewise **not** executed by this leg; + `sum` over a `percent` compiles exactly as it did before. ⚠️ True of this + card only — #16099 executes that row in the same release. + + ⇒ The only pairs whose behaviour changes **because of this card** are `avg` / + `sum` over a `date` / `datetime` / `time` field. ⚠️ ⛔ Not a statement about the + release: the full-table leg is #16099's and landed, and the `min` / `max` leg is + #17560's and landed, so at the shipping version every pair the table refuses is + refused at the compile door. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | + | `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | + | `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | + + ⭐ A duration is not recoverable from an aggregate over instants on any backend. + Where an "average cycle time" is wanted, the cycle length has to exist as a number + before it can be averaged. + + ## What is deliberately untouched + + `date` / `datetime` used as a **dimension** — grouping, bucketing, date-range + filtering — is unchanged; this is about aggregation only. `avg` over a genuine + numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` + over anything all behave exactly as before. + + ⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in + `compileDataset` and reads a `declaredFieldType` probe, so it applies only where + a host wires one: `/analytics/query` — the non-dataset face, whose measures a + Cube infers rather than an author declaring them — is NOT covered, and neither + is any other `compileDataset` caller that passes no probe (those stand down + unjudged rather than guessing). Closing those is #16099's, not this card's. + + Alongside the refusal, `service-analytics`' contradictory annotations about what a + SQLite `Field.datetime` column physically holds are reconciled to one statement — + **seven** source sites plus two test narratives, not the four the card quoted. Some + said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it + IS an INTEGER epoch. Neither is current: since #3912 the column has ONE + storage form, canonical UTC text, with the epoch surviving only in a database not + yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on + `AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. + No behaviour changes from that half. +- a61ae59: Email templates: say where the `en-US` fallback floor is, and report a bundle that has none. + + `IEmailService.sendTemplate` matches `(name, locale)` exactly and retries exactly one rung — + the literal `en-US`. There is no language-subtag folding, so a bundle whose English row is + tagged `en` is unreachable from `en-US` and from every other tag it does not itself carry; + each such delivery raises `TEMPLATE_NOT_FOUND`, which classifies permanent, so it dead-letters + with no retry. An app declaring `i18n.defaultLocale: 'en'` and authoring `locale: 'en'` has + done the consistent thing throughout and still shipped a bundle with no floor — and it + validated, built and installed clean. + + - `EmailTemplateDefinitionSchema.locale`'s `describe` and TSDoc now state the exact match, the + single literal `en-US` rung, the absence of folding, and that the stack's own declared default + locale is the wrong tag whenever it is not spelled `en-US`. + - New exported `EMAIL_TEMPLATE_FLOOR_LOCALE` names that tag once: it is both the schema default + and the resolver's sole retry rung. + - `defineStack` now reports (advisory `console.warn`, warn-once per bundle) an `emailTemplates` + bundle that carries rows for the stack's own `i18n.supportedLocales` but none tagged `en-US`. + + Advisory only — no accept set moves. The stack still parses and is returned unchanged; the + resolver's ladder is unchanged. +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 4792049: feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A) + + + + **BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter` + (the `dataSource` binding every data-bound page component carries) and + `ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | + 'object-calendar'].filter` — shipped as `minor` under the repo's launch-window + convention for breaking changes; the migration prescription is registered under + protocol major 18 as ONE entry for the family. + + One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, + verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55, + verbatim 「同意」, option A: converge family-wide). Until this release the + binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it + refused the array the consumer's own pins author at that key, and + `element:record_picker` carried two orthographies at two keys resolved through + one `??` in the renderer — while the four `object-*` doors declared `z.unknown()` + and took the record, the ObjectQL AST tuple array and the rule array alike, + silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every + other `filter` door in the map already carried; the `FilterConditionSchema` + import that existed in `page.zod.ts` for this one site leaves with it. + + Sequenced measurement-first, as the family had to be: at the objectui pin + `a472b07` the `object-metric` aggregate path posted an array `where` that + `POST /analytics/query` refused (400 on every array form, #15828), so the + converge was parked behind the pin bump #16626. At the pin this repo builds + against (`53ded82b`, objectui#7754) the adapter lowers an authored array through + `translateFilterArray` and the spec's own `parseFilterAST` sink before the + wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` / + `ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams` + lowers it; the binding's composition seam AND-combines it with the named view's + rules through `mergeFilterNodes`. Nothing on those paths parses the value + against the installed spec. + + **Migration** (`element-data-source-and-object-block-filter-rule-array` — + listed by `os migrate meta --from 17` once the protocol major is 18): a + record-form `filter: { status: 'active' }` becomes + `filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an + operator object `{ status: { $ne: 'done' } }` becomes + `[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys + become several rules (they AND); an AST tuple array + `[['owner_id', '=', '{current_user_id}']]` becomes + `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` — + placeholders and date macros are unchanged. The record form is refused at + `filter` (`invalid_type`, expected array); the tuple array is refused at + `filter.0` (expected object). The dashboard widget `filter` + (`dashboard.zod.ts`) is a different family and is unchanged by this release + (#15829); `object-grid.defaultFilters` is a different key, not named by the + ruling, and is unchanged. + + In-repo authors migrated in the same change: four spec test fixtures at the + binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint + fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`, + `ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122: + `operator` normalizes on parse, so input ≠ infer at these five schemas now). +- 53ec0b1: feat(spec)!: `FlowEdgeSchema.condition` is an evaluated slot — it composes the new `EvaluatedExpressionInputSchema`, and `structuralConditionRefusal` no longer admits an `ast`-only envelope (#15807) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): the + edge condition of a flow — `FlowEdgeSchema.condition`, the branch predicate + `AutomationEngine.evaluateCondition` runs at every traversal — now refuses at + authoring an envelope the engine cannot evaluate, where it used to parse, + register, pass `objectstack validate`, and then answer a **silent `false`**: a + branch that quietly never fired. + + Two spellings of one seam, refused by ONE rule with one sentence + (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`, the rule #15430 introduced for the + `assignment` value envelope): + + ```yaml + edges: + - { id: e1, source: check, target: approve, condition: { dialect: cel, ast: { kind: const, value: true } } } # `ast` only — the engine never reads it + - { id: e2, source: check, target: reject, condition: { dialect: cel, source: ' ' } } # blank after trimming + - { id: e3, source: check, target: escalate, condition: ' ' } # the shorthand for the same blank source + ``` + + > An expression in an evaluated slot needs a non-blank `source`: the expression + > engine evaluates `source` (the canonical persisted form) and + > cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` + > that is blank after trimming, would validate and register and then fault at + > run time. Write `{ dialect: 'cel', source: '…' }`. + + - **New export `EvaluatedExpressionInputSchema`** (type `EvaluatedExpressionInput`), + the sibling of `ExpressionInputSchema` for an evaluated slot: the bare-string + shorthand still normalizes to `{ dialect: 'cel', source }`, but the string + must be non-blank after trimming, and the envelope arm composes + `EvaluatedExpressionSchema` (`source` required and non-blank) instead of + `ExpressionSchema`. `FlowEdgeSchema.condition` is the first slot to compose + it. An `ast`-only envelope and a blank bare string surface as one + `invalid_union` issue at the slot carrying the sentence above; a blank + `source` inside an envelope surfaces as one `custom` issue at `source`. + - **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain + the persistence contract (`source` OR `ast`), where `ast` is accepted as an + optional opaque structured value and carries no promise of becoming required. + If AST-only evaluation is ever chartered, `EvaluatedExpressionSchema` is the + one place to relax, and every evaluated slot follows. + - **`structuralConditionRefusal` no longer admits an `ast`-only envelope** on + either structural condition slot (`config.condition` on a node, + `edge.condition`). #15662's refusal admitted it on purpose through a + `rec.ast !== undefined` clause, because the spec still admitted the shape at + `edge.condition` and refusing it from the consumer side would have decided + #15430's question there; with the edge schema closed, that admission kept the + refusal deliberately holed for a shape the engine cannot run on either slot. + `STRUCTURAL_CONDITION_SHAPE_REFUSAL` now reads "an expression envelope + carrying a string `source`" and says why. Consequence on `config.condition` + (a start node's trigger gate, a decision node's predicate — an open record + with no schema in front of it): an `ast`-only envelope there is refused at + `registerFlow`, reported as a located `error` by `objectstack validate`, and + refused by `evaluateCondition` with the same sentence, instead of answering a + silent `false`. An `ast` BESIDE a string `source` is still admitted + everywhere. The whitespace-only STRING ruling on `config.condition` (#15662: + consistent `false` on both sides) is untouched. + - **Three doors agree, through the spec.** `registerFlow` refuses the flow at + `FlowSchema.parse` (edge) or at its structural pass (`config.condition`); + `objectstack validate` refuses it at its `ObjectStackDefinitionSchema` parse + (edge) or reports the structural refusal (`config.condition`); + `evaluateCondition` refuses the shape a stored flow or a direct caller hands + it. None of them grew a rule of its own. + + **What an author does with a refused edge condition.** An edge condition that + carried only `ast` has no evaluable form: author its `source`. A + whitespace-only condition — envelope or bare string — was never a predicate + (the engine answered `false`, so that edge never fired): remove the + `condition` key if the edge was meant to be unconditional, or write the + expression if it was meant to branch. Every edge condition with a + non-blank `source` is unchanged, and nothing is renamed, retired or rewritten — + the refusal itself carries the prescription. + + **A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole + flow, not just the edge.** The paragraph above is the author's remedy, at + `objectstack validate` / `POST /flows`; a stored row has no author in front of + it. Stored flows are deliberately NOT canonicalized by + `applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same + skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`) — flow-node + conversions need the automation engine's live executor registry, so flows + canonicalize at `registerFlow` instead, which parses through + `canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in + `service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one + `warn` naming the flow, and continues. So an edge that used to answer a silent + `false` while the rest of the flow ran now takes the flow down with it: it is + never registered, its trigger is never armed, and the only announcement is that + one warn line — `[Automation] failed to register flow` at boot, + `[Automation] cold-boot flow bind: failed to register flow` at the kernel:ready + bind, `[Automation] flow re-sync: failed to register flow` on a re-sync. That + warn line is also the locator: its `issues[].path` names the offending edge — + `edges[N].condition` — beside the sentence above, so nothing has to be exported + to find it. Author the `source` — or remove the key, if the edge was meant to + be unconditional — and republish. A stack authored in config files has a second + door, `objectstack validate`, which locates the same edge at + `flows.N.edges.N.condition`. Registered as the ADR-0087 D3 semantic entry + `flow-edge-condition-evaluated-slot-source-required`, which carries the same + judgment for a consumer replaying the chain. + + Not touched here: `start.config.condition` has no Zod schema to narrow (the + start node's `config` is an open record); its producer-side gate is the + structural refusal above, which this change tightens but does not type. +- f8e5790: fix(spec)!: `grouping.fields[].field` refuses a padded field name instead of handing three renderers a lookup that always misses (#17360, ruling C on objectui#7347) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata; it is now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying a padded grouping name now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-grouping-field-padded-refused`. + + ## What was wrong + + The padded name never failed anywhere. It failed to *group*. + + Measured on objectui (M1–M11, with live controls): the projection harvester `collectGroupingFieldRefs` **trims** the name when it builds `$select`, while **three** renderers bucket rows by the **raw** name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. So the server answers under `business_unit`, every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into one `(empty)` group (grid, gallery) or one `Uncategorized` lane (kanban) holding every record. + + That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant `(empty)` group has no way to tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. + + ## What it does now + + `grouping.fields[].field` carries a **non-padded** pattern — no leading and no trailing whitespace. The refusal lands at `grouping.fields[N].field` (the offending element's own key, not the view or the array) and names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, together with the trimmed name to write instead. + + ⛔ **Not a `.trim()`.** A trimming schema makes `' a '` and `'a'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. objectui's harvester trim stays as defence-in-depth; nothing is removed there. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `grouping: { fields: [{ field: ' business_unit ' }] }` | `grouping: { fields: [{ field: 'business_unit' }] }` | + | `grouping: { fields: [{ field: 'status\n' }] }` | `grouping: { fields: [{ field: 'status' }] }` | + + The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a view has been silently showing one `(empty)` group, re-authoring the name is also the fix for that. + + ## Scope — what is deliberately NOT narrowed + + - **The blank name is unchanged.** It is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank` (`400`, path `['grouping','fields',N,'field']`). This narrowing exists for the **silent** case; the empty string still parses here exactly as before. + - **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and this key deliberately does not take it: a grouping level is authored as a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The ruling asked for a non-padded pattern and this is exactly that — nothing wider, nothing narrower. + - **The sibling `groupByField` axis** (kanban / gantt / timeline) is symmetric and is **not** touched by this change. + + ## Who is affected, measured + + Every `grouping.fields[].field` spelling in this repo parses unchanged: 50 literal occurrences under a `grouping:` key across 19 files, harvested with the TypeScript parser and cross-checked against a deliberately over-approximating second pass over 906 shape-exact `{ field, order?, collapsed? }` literals in `packages/**`. The single harvested spelling this refuses is `' '` in `view-grouping-query.test.ts` — a **negative** fixture handed straight to `compileListViewGroupQuery` with no parse on its path, pinning that same `grouping_field_blank` refusal. Nothing in the tree reddens. + + Outside the repo, only metadata that was already grouping wrongly is affected: a padded name has never produced a correct grouped view on any renderer. + + ## Consumer + + **objectui#7347 unblocks on the INSTALLABLE RELEASE of this package, not on merge.** Its side of the work — a pin bump plus a regression test that a padded name is refused before it reaches any renderer — needs a published `@objectstack/spec` to depend on, so it stays `pm:blocked` until this ships in a release a consumer can install. The gallery and kanban sites are covered by this one producer fix and get no cards of their own. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- 681871e: feat(spec): `HookContext` admits a row-invariant-in-effect rewrite by per-row `previous` on a predicate write, kept safe by the engine's key-divergence refusal (#16074) + + The `hook.zod.ts` contract said that on a predicate (`multi: true`) write the per-row `previous` is supplied *so a guard can REFUSE (throw), not so a rewrite can be aimed*. Three shipped `beforeUpdate` provenance stamps (`sys_email_template`, `sys_sharing_rule`, `sys_webhook`) read `ctx.previous` per row and write `customized: true` conditioned on it — inside the letter of what the engine allows, outside the stated purpose of the input they use. Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06), option 1: **the contract admits the shape.** + + The amended D3 clause (`HookContextSchema.input` TSDoc, mirrored in `bulk-write-hook-conformance.ts`) now states: + + - Per-row `previous` is supplied so a guard can REFUSE, **and** so a `before*` hook can make a **row-invariant-in-effect rewrite** — one whose written KEY SET is the same on every matched row **and is assigned in place** (`ctx.input.data.customized = true`, not a wholesale replacement of `ctx.input.data`). + - What makes that shape safe is the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned **in place**, and if any two rows disagree the whole batch is refused **before any write**. In place is the condition the refusal rests on: a hook that REPLACES `ctx.input.data` leaves the dispatch unable to attribute keys, so the comparison is skipped and the batch is not judged at all. + - What an operator sees when it fires: an ADR-0112 envelope with `status: 400`, `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, `keys` (the sorted keys some rows' hooks wrote and others did not, e.g. `['customized']`), `rows` (how many rows the predicate matched), `object`, and a message that says "Nothing was written" before naming the remedy. A bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that is the engine working, not the hooks misbehaving, and the remedy is the caller's — write those rows by id, or from inside the handler through `ctx.api`. + - Three shapes the rule does **not** admit: a rewrite whose written key set differs across rows (that is the refusal itself); the same key written with a per-row VALUE — the engine judges key sets, never values, so that shape clears the check and applies the last dispatch's value to every row; and a row-conditioned REPLACEMENT of `ctx.input.data`, which silences the recording above so that shape is judged by nothing at all. All three stay out of contract. + + Purely additive at the contract: no schema key, type or accept set of `HookContextSchema` itself changes, and the engine's behaviour is unchanged — the three stamps become conforming by amendment, and the rule for the next hook author is written down where the contract lives. Option 2 (change the hooks to stop aiming by `previous`) was not adopted: #15302 measured that declining on a predicate write leaves unstamped exactly the rows the next boot overwrites, turning a visible 400 into silent loss of an admin edit. +- 54e8234: **BREAKING** `engine.registerHook` refuses an engine lifecycle event the engine never dispatches (#17713) + + `registerHook(event, handler)` took `event: string`. For a name outside the dispatched set it logged a warning and then **registered the handler anyway**, so the declaration succeeded and the handler never ran — ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam. + + The measured cost is a data-visibility one. A consumer registered **read filters** on `beforeFindOne` and `beforeCount`, expecting them to scope single-record reads and list totals. They sat inert through every boot behind ~40 warning lines: `findOne` was still filtered (`beforeFind` covers it, so the mistake gave no signal), `count` was not — a `limit`ed list answered a `total` counting rows the caller could not see — and `aggregate` was not either, so a `groupBy` was not narrowed at all. + + Six event names now throw at registration instead of registering inert. They are the engine's own lifecycle namespace — `before`/`after` × `OperationContext['operation']` — minus the eight the engine dispatches, derived in code rather than typed out. + + FROM → TO: + + | was | now | fix | + | --- | --- | --- | + | `registerHook('beforeFindOne', h)` | throws | register on `'beforeFind'` — it already fires for `findOne` | + | `registerHook('afterFindOne', h)` | throws | register on `'afterFind'` — same reason | + | `registerHook('beforeCount', h)` | throws | `count()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'count'` | + | `registerHook('afterCount', h)` | throws | same as `beforeCount` | + | `registerHook('beforeAggregate', h)` | throws | `aggregate()` dispatches no hook; use `engine.registerMiddleware(fn)` and read `ctx.operation === 'aggregate'` | + | `registerHook('afterAggregate', h)` | throws | same as `beforeAggregate` | + + One-line fix for a read filter that was on `beforeCount` or `beforeAggregate`: move it into `engine.registerMiddleware(async (ctx, next) => { if (ctx.operation === 'count' || ctx.operation === 'aggregate') ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, scope] } : scope; await next(); })` — the same seam RLS and sharing already use, so the predicate reaches the driver call. + + What is **not** affected: an event name outside the engine's lifecycle namespace (`'myPlugin:flush'`) still warns and still registers, so a plugin that dispatches its own events through `triggerHooks` keeps working. Metadata-authored hooks were never exposed — `HookSchema.events` is `z.array(HookEvent)` and `HookEvent` enumerates exactly the eight dispatched names, so the gap only ever existed on the code door. + + +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- 9788f1e: feat(spec)!: `object-grid` and `object-calendar` constrain the `sort` VALUE to the `SortItem` array — one sort orthography platform-wide reaches the last two unconstrained doors (#16553; objectui#8221, decision batch #77 option B) + + + + **BREAKING** accept-set change at two doors — `ComponentPropsMap['object-grid'].sort` + and `ComponentPropsMap['object-calendar'].sort` — shipped as `minor` under the + repo's launch-window convention for breaking changes; the migration prescription + is registered under protocol major 18 as `object-block-sort-item-array`. + + One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, + 2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is + objectui PR #8758, which drops the legacy string arm from + `convertSortToQueryParams`). Item 4 of that ruling is this release's subject: + 「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the + `sort` value to the array shape (today it accepts anything), so the spec, the + registrations and the helper agree; that is a pull-back to the declared contract, + ordinary tier」. + + Until this release both doors declared `z.unknown()` — no orthography at all. + Measured on `@objectstack/spec` 17.2.0 and re-measured on this tree before the + change: an array, the legacy string clause and a bare NUMBER all returned + `success: true`, while `bogusProp` was refused by name on the same call. So key + checking was live and only the VALUE was unheld, and an author following + objectui's own registrations (`plugin-grid/src/index.tsx:222` has published + `type: 'array'` all along) and an author following the legacy string each got a + silent success receipt for a different shape — while objectui's html tier + answered `type-mismatch` on the second one. Both doors now declare + `z.array(SortItemSchema)`, the array `ElementDataSourceSchema.sort`, + `ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already + carry: one shared schema, not a third copy. + + Sequenced measurement-first, as this family has to be. At the objectui pin this + repo builds against (`53ded82b`) the string is still lowered — + `ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto + `$orderby` beside the array arm, and `ObjectCalendar.tsx:431` hands `schema.sort` + to `convertSortToQueryParams`, whose string arm is still present at + `sort-query.ts:66-70`. This declaration therefore lands ahead of the pinned + consumer, which the ruling permits explicitly — either order, since the + registrations already declare the array — and the next pin bump carries the + retirement in. + + **Migration** (`object-block-sort-item-array`): `sort: 'created_at desc'` becomes + `sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name + `sort: 'created_at'` meant ascending and becomes + `sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in + `SortItemSchema`, so it is written out rather than omitted; a comma-separated + clause becomes one array entry per key, in the same order. The string is refused + at `sort` (`invalid_type`, expected array), as is a bare number; a misspelled or + absent direction is refused at `sort.0.order`. Metadata AT REST is not rewritten + and this disposition adds no D2 conversion — a stored page carrying a string + `sort` keeps loading and still renders at the pinned `.objectui-sha`; what + changes is that RE-SAVING it is refused at the `sort` door. + + **Not moved by this release.** `record:related_list.sort` keeps its declared + string arm: that string is the `'field'` / `'-field'` dialect read by + `RelatedList.normalizeSortSpec`, it never reaches `convertSortToQueryParams`, and + retiring it was not ruled — objectui#8221's own implementing round narrowed it, + established the dialect and reverted the narrowing byte-identically. + `object-grid.defaultSort` is a different key, already retired by #11805. Zero + authored `sort` values on either block exist in this repo (the two showcase pages + that author `object-grid` declare none), so nothing in-tree was converted. + + Type aliases are unchanged: `SortItemSchema`'s input equals its infer, so neither + block's parsed state moves for this key, and both already take the + `…PropsParsed` route for `filter` (ADR-0122). +- 5d527f7: fix(spec): `PageSchema`'s rejection guidance stops prescribing `assignedProfiles` as a page gate (#16929) + + The two wrong-layer prescriptions `PageSchema` hands an author at parse time both ended by pointing at `assignedProfiles`: the `visibleWhen` pointer said "or gate the page with `assignedProfiles`", and the `permissions` pointer said "reach it through `assignedProfiles`". Neither is true. `assignedProfiles` gates nothing. + + Measured 2026-09-10 on `origin/main` `e1eee43beb` and objectui `3fbdd4a2d`: `assignedProfiles` has **zero readers** in this repo — every one of its 25 matching files is a declaration, a generated artifact, prose, a `CHANGELOG`, the liveness ledger, or this schema's own round-trip test — and **zero readers** in objectui, whose three hits are a docs table row and two type/zod declarations. Lit controls in the same sweeps (`visibleWhen` 308 files, `PageSchema` 94 files in objectui; `visibleWhen` 168 files here) prove the instrument fired; a fabricated dark control read 0 in both. The key is also named for the concept **ADR-0090 D2** removed, which `security/permission.zod.ts` states to authors three times over. + + Prescribing it was Prime Directive #10's exact prohibition — advertising a capability the runtime does not deliver — delivered to the author in the error that is supposed to be teaching them the correct spelling. Both prescriptions now say only what the platform actually does: put `visibleWhen` on the component inside a region, and gate the DATA a page shows with the object's permission sets. + + **Nothing about what `PageSchema` accepts changes.** `assignedProfiles` remains an authorable key with its declaration untouched, and the `profiles:` / `assignedTo:` alias entries are untouched. Both channels edited here fire only from the `unrecognized_keys` path, so every key involved is rejected before this change and rejected after it, with identical `issue.code` and identical `path` — only the human-readable text moves. The key's own disposition (keep, rename, or remove) needs a ruling and stays open on #16929. +- 9165d5c: Declare the ASSEMBLED manifest stage on the installed-package read API. + + `GET /api/v1/packages` and `GET /api/v1/packages/:packageId` serve whatever a + package was installed with, and two stages reach that table through declared + doors: `POST /api/v1/packages` installs an authoring manifest (`manifest.objects` + = glob patterns), while a `defineStack()` host installs the assembled body + (`manifest.objects` = object definitions). Both response schemas typed every row + at the authoring stage alone, so the shipped `defineStack()` path served a + payload its own declared contract refused. + + Following the #14242 ruling — declare the assembled stage rather than widen the + authoring one — `@objectstack/spec/api` gains two exports: + `AssembledInstalledPackageSchema` (the assembled-stage counterpart of + `InstalledPackageSchema`) and `InstalledPackageAtEitherStageSchema`, a union + over the two whole closed stage declarations. `ListInstalledPackagesResponseSchema` + and `GetInstalledPackageResponseSchema` are bound to the union. + + This is additive at runtime, and the runtime parse is where the gain is: every + payload that parsed before still parses, payloads that were refused for their + manifest stage now parse, and a row belonging to neither stage — an `objects` + array mixing globs with definitions — is still refused. `ManifestSchema` is + unchanged. + + The STATIC gain is one-sided, and smaller than a union normally implies. + `AssembledPackageBodySchema` is annotated `z.ZodType, …>` + in `stack.zod.ts` — deliberately, for the declaration-size reasons recorded + there, and untouched by this change — so the assembled branch carries no field + typing. Measured against the built `.d.ts`: a plain `.manifest.version` read off + one of these two response types now yields `unknown` where it used to yield + `string`; narrowing toward the AUTHORING branch restores the whole of + `ManifestSchema` (`version: string`, `objects: string[]`), while narrowing away + from it yields `Record` — every manifest field `unknown`. In the + assignment direction the assembled branch admits any object at `manifest`, so a + garbage manifest and the mixed-stage row named above both typecheck clean even + though the runtime union refuses both. So: narrow at the point of use for the + authoring stage, and treat an assembled manifest as a record the runtime — not + the compiler — has checked. + + `@objectstack/spec/api` also gains a `browser` export condition. Declaring the + assembled stage makes this entry's module graph reach the datasource + declaration and with it the driver-config validators, whose postgres URL + refinement links `pg-connection-string` — a package whose `parse` statically + resolves `require('fs')`, so a browser bundler that reaches it fails on + `Can't resolve 'fs'`. The entry now resolves, for browser consumers only, to a + build with the pg-grammar arm swapped for its dependency-free twin: exactly the + boundary the four entries that already carry the condition use. Node resolution + and the Node bundles are unchanged, byte for byte. For browser consumers the + postgres `url` refinement degrades to the shape-only checks it already performs + before `parse` — the unix-socket short-circuit and the refusal of the + filesystem-reading `?sslcert=` / `?sslkey=` / `?sslrootcert=` query parameters + are kept; the "is this a URL `pg` can open" arm answers "no findings". Datasource + publish is a server-side act, so that arm never legitimately ran in a browser. +- 07150b3: `PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. + + Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: + + | Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | + |---|---|---| + | `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | + | `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | + + SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. + + **The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. + + **`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. + + **`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: + + - **`id`** — a non-string, or the empty string. + - **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. + - **`staticPath`** — a non-string. + - **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. + - **`default`** — a non-boolean. + - **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** + - **`description`** — a non-string. + - **`author`** — a non-string. + - **`homepage`** — a non-string, or a string that is not a URL. + + **`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. + + ⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. + + **What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. + + **What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. + + ⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. + + **Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. + + +- d2badf7: feat(spec): a repeater's property-panel table has column NAMES, and an untitled item schema is now loud (#17232) + + ## What was wrong + + Studio renders a `type: 'repeater'` form field as a table whose column headers + read `items.properties[k].title ?? k` off the JSON Schema served by + `GET /meta/types` — derived by `packages/metadata-protocol`'s `toJsonSchemaSafe`, + i.e. `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`. + The bundle overlay `resolveMetadataFormSchemaTitles` (#16458 / PR #17227) only + replaces a title that is already there, so an item schema carrying no + `.meta({ title })` falls through to the raw machine key — in **every** locale, + English included. The maker read `actionUrl`, `defaultCollapsed`, `dateGranularity` + inside an otherwise fully translated panel. This is a missing authoring label in + the contract, not a translation gap. + + PR #17227 titled exactly one repeater, `dashboard.header.actions`, and was scoped + by dispatch to that one. **The class stayed silent**: the next repeater to land + would reproduce the defect with every gate green. + + ## Measured on `origin/main` at `e758131b39` + + 22 repeater fields are declared across 11 `*.form.ts` files. Derived through the + platform's own predicate rather than a source regex: + + - **1** was fully titled — `dashboard.header.actions`, PR #17227's instance. + - **1** has no object row shape at all — `action.locations` is an array of enum + STRINGS, so it renders no column headers and leaks no key. It is **not** a + carrier, which is why the class is **20** untitled tables today and not the 21 + the card premised. + - **20** were untitled. + + ## What changed + + **Thirteen carriers are now titled** — every row property of `action.params`, + `app.areas`, `dataset.dimensions`, `dataset.measures`, `flow.nodes`, + `flow.edges`, `flow.variables`, `page.variables`, `page.regions`, + `page.interfaceConfig.sort`, `report.order`, `report.blocks` and + `skill.triggerConditions` carries a `.meta({ title })`. `page.interfaceConfig.sort` + is titled through the shared `SortItemSchema` it composes. + + **The silence is closed.** `packages/spec/src/kernel/repeater-item-titles.test.ts` + enumerates every repeater declared across every `*.form.ts` in the package, + derives each row schema through `z.toJSONSchema`, and requires a title on every + authorable row property. Carriers still owed one sit in an EXACT, shrink-only + ledger: a repeater absent from the ledger must be fully titled, and a ledger + entry whose debt has been paid must be deleted. A new repeater is therefore red + on the day it lands, and the ledger can only shrink. + + Two exclusions the pin makes deliberately, each with its own control: + + - a `retiredKey()` tombstone is a parse-time refusal, not an authorable column + (`flow.nodes[].outputSchema`); + - a scalar-item repeater has no row properties to name (`action.locations`), + and is pinned by name so an object-shaped one cannot land there silently. + + ## What is still owed, and why + + Seven carriers remain on the ledger because their item schemas live in files held + by other in-flight PRs at the time of writing — `dashboard.widgets` and + `dashboard.globalFilters` (`ui/dashboard.zod.ts`), `view.columns` / `view.sort` / + `view.tabs` (`ui/view.zod.ts`), and `field.options` + `object.fields.options` + (the one `SelectOptionSchema` in `data/field.zod.ts`). The pin OBSERVES them + without editing them, so the ledger states the whole class rather than the slice + one PR could reach. + + Localisation is additive and unchanged by this round. `.meta({ title })` is the + English authoring layer by contract — `translation.zod.ts` states it in those + words — and a bundle's `metadataForms..fields...label` + overlays it per locale. No form file here enumerates repeater children, so + `os i18n extract` emits no new catalog keys and no catalog moves. Until those + leaves are authored, a non-English panel shows the English title rather than the + machine key — strictly better than today, and the localisation layer is still owed. +- d64bcb6: **BREAKING** — retire the `adr-0030-notification-event` data migration. + + `migrateSysNotificationToEvent` had no way to be run: zero production callers + anywhere in the repo, and no `os migrate` sub-command, while the two sibling + members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel + export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's + pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed + together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform + on this line. + + ## What is gone, and what an upgrader does about it + + ⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to + adopt — every item below is a deletion, and the fix is to stop using it. + + - `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. + No replacement exists, and none is coming: an `os migrate notification-event` + sub-command was considered and refused. Delete the call. The compiler delivers + this one: the import fails to resolve. + - `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and + `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They + described that runner's own result, options and receipt and nothing else. + - `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a + three-member tuple and is now a two-member one holding + `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids + keep their sub-commands, their receipt rows and their birth attestation; only + the notification id left. Code typed against + `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification + id no longer compiles — delete that arm. + + `NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A + deployment attested at birth, or one that made the operator call while the runner + shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, + and the constant is that row's name. Nothing writes or reads a row under it any + more — `attestFreshDatastore` no longer includes it — and it is not a + registration: it gates nothing and never did. + + ## Reversal path + + Two answers were considered and both refused: an `os migrate notification-event` + sub-command is a permanent operator surface for a migration with no measured + demand, and a boot-time invoker is an unattended data rewrite nobody asked for. + ⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 + `sys_notification` rows. If a **named** deployment turns out to hold rows it + needs, the migration returns as an operator-runnable sub-command shaped exactly + like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, + documented consequence — under its own card. + + +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +- 3b1dab9: Declare `continueRestoredRun` on the `IApprovalService` contract, so the approvals half of the operator repair pair is reachable through the published interface rather than only off the implementation class. + + `IAutomationService.restoreConsumedSuspension` re-arms the pause a failed resume consumed and, by its own contract, does not replay the resume signal — the continuation must be re-issued. For an approval suspension nothing could re-issue it: every front door guards on a live request — `pending` for decide and send-back, `returned` for resubmit, and `pending` or the revise window for recall — and the stranding call leaves the row where none of them can issue the continuation it owes. The issuer landed as a class member on `plugin-approvals`; this declares it, so a caller programs against the contract instead of importing the implementation. + + Additive and OPTIONAL, the way `cancelRun` / `restoreConsumedSuspension` are declared on `IAutomationService`: an existing implementation still conforms, and a service that does not declare the member has no operator door for it — a caller must probe for presence and refuse fail-closed rather than answer success for a verb it could not dispatch, because promising a repair verb that will refuse is worse than promising nothing. No REST or CLI route is declared or implied. +- 1555ed4: `CLOUD_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) gains a member: + `sys_package_version`. `isPlatformProvidedObjectName('sys_package_version')` now + returns `true`, so a reference to that name resolves instead of being flagged as + a platform-prefixed name nothing registers (#16745). + + This widens an accept set. The name was previously refused, the list is a closed + set, and nothing in the published header enumerated this member — so the ladder + now accepts a value it used to warn on, and the widening reaches every surface + that consults the predicate: a dataset `object`, an action parameter + `reference`, a dashboard `optionsFrom.object` and a navigation `requiresObject` + naming `sys_package_version` all stop being diagnosed. + + Why this name and not another: the list already carried `sys_package` and + `sys_package_installation` — the head and tail of the three-table package family + that `cloud/package.zod.ts` declares — but not the release-snapshot table + between them, whose row schema this repository ships as + `cloud/package-version.zod.ts`. Platform metadata that ships with the product + references it: `sys_metadata.package_version_id` in `@objectstack/metadata-core` + is a `Field.lookup('sys_package_version', …)`. + + One entry is added; no other member moves and nothing is removed or narrowed. + The cloud-side half of the contract — that `@objectstack/service-tenant` + registers the table — is owned by the cloud repository per the list's header and + is not asserted from here. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- 9bd4344: feat(auth)!: adopt better-auth's account-issuer rollback — drop `sys_account.issuer`, retire the backfill, lift the `@better-auth/*` family to an exact `1.7.3` (#17440) + + + + **BREAKING** — a platform object drops a declared field and `@objectstack/plugin-auth` + drops six published symbols. Shipped as `minor` under the launch-window convention + (`major` is refused by `check-changeset-no-major`; breaking-ness is carried by this + banner plus the ADR-0087 disposition above). The hand-migration prescription is + registered under protocol major 18 as `sys-account-issuer-retired`. + + better-auth `1.7.3` removed the issuer-scoped account identity outright + (`better-auth/better-auth#10909`): `createLocalAccountIssuer` is deleted, + `accountSchema.issuer` is gone, `AccountKey` is `(providerId, accountId)` again, and the + `account.issuer` column and its unique index are gone from `get-tables`. There is no + drop-in replacement. `#16186` pinned the family at an exact `1.7.2` as a stopgap; this is + the durable half, per the maintainer ruling of 2026-09-10 on `#16629`. + + ## 迁移:FROM → TO + + | FROM | TO | the one-line fix | + |:--|:--|:--| + | `sys_account.issuer` (column + `{ fields: ['issuer','account_id'], unique: true }`) | — | nothing replaces it; identity is `(provider_id, account_id)`, declared UNIQUE on `sys_account` since the object was created | + | reading `account.issuer` off a row or off `client.accounts.list()` | `sys_sso_provider.issuer`, resolved through the account's `provider_id` | `provider_id` is unique per environment, so it names the authority on its own | + | `backfillAccountIssuer(ql, …)` | — | delete the call; there is no successor pass | + | `CREDENTIAL_ISSUER` / `oauthIssuerFor(id)` | — | drop the argument; `internalAdapter.createAccount({ userId, providerId, accountId, password })` takes no `issuer` | + | `ResolvedSocialProvider`, `BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult` | — | delete the import; the compiler names every site | + | `@better-auth/*` at an exact `1.7.2` (eleven members) | an exact `1.7.3` (eleven members) | the family moves as ONE line — `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions | + + ## ⭐ Existing deployments: run the pre-flight BEFORE the column is dropped + + Uniqueness moves from `(issuer, account_id)` to `(provider_id, account_id)` — a + **narrower** key. Two rows sharing `provider_id` + `account_id` and differing only in + `issuer` are legal under the old key and are ONE account under the new one. + + ``` + os migrate account-issuer # read-only; exits non-zero when the drop must not proceed + # … take a backup (the operator's act, and the apply step's precondition) … + os migrate apply --allow-destructive + os migrate account-issuer # post-check: reads zero + ``` + + The pre-flight reads **rows**, never the index declaration. `syncDeclaredIndexes` logs a + plain UNIQUE whose CREATE failed on existing duplicates onto the durability channel and + lets the boot continue (`#14902` / `#15479`), so a database can carry the declaration + without the constraint — and on such a database the drop does not fail loudly, it + degrades silently: the rows become indistinguishable and a sign-in can resolve onto the + wrong user's account. `os migrate apply --allow-destructive` re-runs the same pre-flight + and refuses the drop before writing any DDL. A read that throws, or a scan that + truncates, refuses too — an unread table is not a clean one. + + ⛔ Colliding rows are never merged or dropped for you: which row survives is application + knowledge, and two different people can be behind one colliding key. Keep the row whose + provider account is live, delete the rest so a fresh sign-in re-links, and re-run. + + The boot refusal is unchanged and needs no new machinery: a runtime already refuses to + start against unapplied destructive drift, naming the command to run, and never + auto-migrates. + + ## ⚠️ A `provider_id` re-pointed at a different IdP must have its bindings REBUILT + + This is the one case `issuer` still discriminated. After the drop no column records which + IdP vouched for a row, so if a re-pointed provider's new IdP mints a subject the old one + had already issued to somebody else, the key resolves that sign-in onto the other + person's account. Under the old key that failed loudly (`unable_to_link_account`); under + the new one it is silent. + + ⇒ `sys_sso_provider` now **refuses an `issuer` change while `sys_account` rows are still + bound to that `provider_id`** (`RESOURCE_CONFLICT` / 409). Delete the provider's account + bindings first; each user re-links on their next sign-in. + + ## Why the column was a liability, not an asset + + A credential row whose `issuer` was not the local credential issuer was invisible to + `findAccountByKey`, so sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not + found" warn pointing at the `sys_user` row rather than at the account. **Four checklist + items had that recorded as a knownGap, each rediscovering it.** Its discriminating power + here was near zero anyway: `sys_sso_provider` declares `{ fields: ['provider_id'], unique: + true }`, so `provider_id → issuer` is a function within an environment. + + ## Also in this change + + `pnpm check:vendor-export-contract` (from `#16186`) keeps its exactness requirement and + still resolves every named symbol — its self-test re-anchors from the now-retired + `@better-auth/core/db` specimen onto a live edge, and gains a case asserting the two + deleted names are imported nowhere. `#11627`'s hash-shadow-key machinery is untouched: it + is a generic driver capability serving five UNIQUE members of the >768-char class. +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. +- 9c44eed: fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296) + + + + ## ADR-0087 disposition + + `second`, `minute` and `hour` leave a published closed enum that reaches TWO authored sites: an analytics request body's `timeDimensions[].granularity`, and an analytics cube dimension's `granularities[]`, which is stored metadata (`defineCube()` / `defineStack({ analyticsCubes })`). The stored half is rewritten by the D2 conversion `cube-sub-day-granularities-removed`, which strips the retired members from `analyticsCubes[].dimensions..granularities` and drops the key entirely when nothing coarser remains (an empty list would read as "offers none", the absent key as "offers all"). The semantic entry `time-update-interval-sub-day-retired` carries the half no transform can decide: a dimension that offered ONLY sub-day intervals needs an author to say what it actually serves. `day`, `week`, `month`, `quarter` and `year` are untouched and parse byte-identically. + + **BREAKING** for anyone authoring or sending `granularity: 'second'`, + `'minute'` or `'hour'`, and for anyone importing the `TimeUpdateInterval` + TYPE. Landing in the + launch window as `minor` under the lockstep convention this cluster's siblings + already use. + + ## What was wrong + + `TimeUpdateInterval` declared **eight** intervals. The rest of the contract + never carried three of them, and this is the measurement rather than the + argument: + + | layer | declares | + |:---|:---| + | `TimeUpdateInterval` (`data/analytics.zod.ts`) | **8** — the five below plus `second`, `minute`, `hour` | + | `DateGranularity` (`data/query.zod.ts`) — what a `groupBy` entry and every driver bucket expression are typed by | 5 | + | `@objectstack/core`'s `BUCKET_GRANULARITIES` — the canonical bucket-KEY output contract a drill-down crosses | 5 | + | `driver-mongodb`'s `MONGODB_DATE_GRANULARITIES` | 5 | + + `DriverCapabilitiesSchema.supports.queryDateGranularity` — the one mechanism a + backend has for saying which granularities it buckets natively — is a + `z.record(DateGranularity, boolean)`. Measured: `{ day, week, month, quarter, + year }` parses; the same record plus `hour` raises `unrecognized_keys: ["hour"]`. + **No driver could advertise sub-day bucketing even if it had one.** That is what + makes this a retirement rather than a capability gap: a declared value one + backend cannot serve is a gap and the contract has a place to say so, but a + declared value *no* backend can even claim has no counterpart anywhere in the + contract that carries it. + + Driven against the built packages, two rows fourteen hours apart on one UTC + calendar day, before this change: + + | face | `granularity: 'hour'` | `granularity: 'day'` (control) | + |:---|:---|:---| + | `driver-memory` analytics | `NOT_IMPLEMENTED` / 501 | 1 group, `2026-09-06` | + | `driver-mongodb` bucket builder | `NOT_IMPLEMENTED` / 501 | `$dateToString` `%Y-%m-%d` | + | engine in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity | **200, 2 groups keyed on the RAW instant** | 1 group, `2026-09-06` | + + Two honest refusals and one silently wrong answer. No third behaviour, and no + backend that bucketed it. + + ## What changed + + - `TimeUpdateInterval` is now `z.enum(DateGranularity.options, …)` — the members + come from the single source instead of a second literal list that disagreed + with it by three members for as long as both existed. + - A refusal message splits two populations that are not the same mistake: a + **retired** sub-day name gets the retirement and the `os migrate meta --from + 17` line; anything else gets the vocabulary. `driver-memory`'s own analytics + door carries the same split. + - `driver-memory`'s `NOT_IMPLEMENTED` / 501 answer for these three is **not + silenced** — the declaration it announced is gone, so the class moves to the + 400 the retirement makes correct. The 501 arm stays, and a pin measures that + its population is now empty (`TimeUpdateInterval.options` equals + `BUCKET_GRANULARITIES`), so the day one of the two is widened alone it lights + up again instead of a freshly declared value being called undeclared. + + ## What this does NOT decide + + Sub-day analytics bucketing as a **capability**. Offering it means widening + `DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key + vocabulary and every driver's bucket expression together — new capability, + decided as such, rather than a name that parses in one enum and resolves + nowhere. + +### Patch Changes + +- abc4b83: `search-fields.ts`'s module docblock says `$search` expands to an `$or` of `$icontains`, the operator the engine actually emits + + The docblock's ENGINE bullet claimed `@objectstack/objectql`'s + `expandSearchToFilter` expands a `$search` term into an `$or` of **`$contains`** + clauses. It has compiled to `$icontains` since objectstack#7641: + `packages/objectql/src/search-filter.ts:23` carries the ruling verbatim — *"The + case-insensitive operator is `$icontains`, NOT `$contains`. `$contains` is + contractually case-SENSITIVE (#4706 Q2 = A)"* — and both return paths of + `fieldClausesForTerm` (`:109`, `:111`) emit `$icontains`. + + **Why the distinction is worth a clause rather than a word swap.** `$contains` + is contractually case-SENSITIVE, so a reader who trusted the old sentence built + an ingress gate, a test or a driver **stricter** than the platform is — a false + refusal, not a leak. The corrected bullet now says that in one clause, so the + next reader of this module does not have to reconstruct it from two other + packages. + + ⛔ No behaviour changes. This is a module docblock; the engine has been right + since #7641 and no accept set, authorable key or published behaviour moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and + this TSDoc is emitted into `dist/data/index.d.ts` and `dist/data/index.d.mts` — + measured on the built artifact, with the old spelling absent from all 216 built + files afterwards and the docblock's own neighbouring sentence present at 2 as + the lit control. `src/data/search-fields.ts` is not a `.zod.ts`, so it is not + shipped as source; the emitted declarations are the whole of its published + reach, and they change. + + The sibling INGRESS sentence two lines below — `@objectstack/metadata-protocol` + `findData` refusing a `$searchFields` override the resolved set does not admit + (#4254) — was measured on the same tip and is unchanged: `findData` still calls + `assertSearchFieldsAreSearchable`, which resolves through this module's own + `resolveSearchFieldResolution` rather than re-implementing the rule. +- 245f360: `EventMetadata.cluster` and `ServiceMetadata.cluster` cite the live docs page by SITE URL, not a dead filename + + Both `.describe()` strings pointed at `cluster-semantics.mdx`, a page that is no + longer in the tree — `apps/docs/redirects.mjs` has redirected + `/docs/concepts/cluster-semantics` to `/docs/kernel/cluster` since the page was + folded in. The section numbers still resolved, so nothing was broken for a + reader following a link; what was broken is retrieval by filename, which finds + nothing. + + These two strings are the published half. `gen:docs` copies them into + `content/docs/references/kernel/events-core.mdx` and `service-registry.mdx`, and + they also ship as JSON Schema `description` values under `packages/spec/json-schema/` + and as string literals in `packages/spec/dist/`. So the citation had to become + something a SITE reader can follow: + + ``` + - See cluster-semantics.mdx §4. (a file that does not exist) + + See /docs/kernel/cluster §4. (the address the redirect already resolves to) + ``` + + ⛔ Deliberately NOT the in-repo house style. Source comments elsewhere in the + tree cite `` `content/docs/kernel/cluster.mdx` §N `` — a repo path, correct for a + reader who has the repo checked out. Copying that convention into a `.describe()` + would tell a docs-site reader to open a `content/docs/...` file they do not + have, which is the same class of unfollowable reference pointed the other way. + There is no in-repo precedent to copy either way: these are the only two + `.describe()` strings in `packages/spec/src` that cite a docs page at all. + + The site URL is also redirect-independent — it is the redirect's own target, so + the reference survives the redirect being retired. + + No accept set moves and no authorable key is added or removed: the schemas, + their parse behaviour and their exported types are byte-identical apart from + these two description strings. The two regenerated reference pages carry the + same one-line change on three rows. +- 324968e: The `translation-validation-messages-removed` migration text names the object-scoped bundle key, not just the authored literal + + `validationMessages` was retired in 17.0.0 (#4667). The ADR-0087 conversion that + migrates it told an author to author the message on the rule + (`object.validations[].message`) and stopped there. Since 17.3.0 (#14381, + #14253) that message has a translation route — + `objects.._validations..message`, resolved on the write + path — and the sibling prescription ten metres away in the same package + (`TRANSLATION_KEY_GUIDANCE.validationMessages`, the text the strict door + returns) already names it. + + ⛔ Nothing the old text said was false, and none of it is deleted. The defect is + **silence**: this is the *migration* text, read by exactly the population that + authored the retired key — the authors who wanted their rule messages + translated — and it steered them to a plain authored literal without mentioning + that the bundle key now exists. The literal advice stays; the route is added + after it. + + **Two texts in the file carried the narrow prescription, not one.** The + conversion's `summary` is the one the card named; the docblock above it asserted + that rule messages are *"not translated through a group"*, which would have sat + directly above the corrected summary. Both are completed. The docblock keeps its + 17.0.0 sentence — still true of the retired key — and says what 17.3.0 changed, + including why the object-scoped group is not `validationMessages` returning (the + retired one was keyed by rule name at the top level, could not tell two objects' + rules apart, and had no reader). + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `packages/spec/src/conversions/registry.ts` is not a + `.zod.ts`, so it is not shipped as source — but two published paths move, + measured on the built tree rather than reasoned about: + + - `dist` is in `files[]`, and the new sentence is emitted into six built files + (`dist/index.js` / `.mjs`, `dist/shared/index.js` / `.mjs`, + `dist/browser/index.js` / `.mjs`); a negative control string scored 0 on the + same tree. An author running `os migrate meta --from 16` reads the changed + notice out of that runtime string. + - `spec-changes.json` is itself listed in `files[]`, and it carries the summary + twice. It is generated (`gen:spec-changes`), and `check:generated` caught it + stale — the conversion registry feeds two generated artifacts, not one. + + `docs/protocol-upgrade-guide.md` is the third, regenerated with + `gen:upgrade-guide` and verified by `check:upgrade-guide`; all three are + regenerated, never hand-edited. + + ⛔ No behaviour changes. The conversion id, its `apply`, its accept set and its + fixture are untouched; no authorable key is added or removed. +- 482d34d: fix(devx): the json-schema tree's freshness rule can be answered — a generation stamp acquits a tree whose sources were re-checked-out unchanged (#16175) + + `scripts/check-regen-pending.mjs` exports three freshness predicates over the + same `newestMtime(artifact) < newestMtime(src)` comparison, and all three share + one blind spot: `git merge`, `git checkout` and `git worktree add` re-check-out a + source file with **identical bytes** and bump its mtime, the build that follows + correctly does not run (turbo's cache hashes content), and the rule then refuses + an artifact that is exactly current. + + Two of them were answered already — `distIsStale` by `dist/.build-input-hash-dts` + (#14985/#16176) and `bundlesAreStale` by `dist/.build-input-hash` (#16240). + `schemaTreeIsStale` was the third, and the one with **no evidence of any kind to + read**: nothing recorded which sources `packages/spec/json-schema/` came from. + Measured on a checkout whose `git status` was empty, after a bare + `touch packages/spec/src/data/query.zod.ts`: + + ``` + pnpm --filter @objectstack/spec check:docs exit 1 + packages/spec/json-schema is older than packages/spec/src. + ``` + + The only remedy on offer was a full `gen:schema` — minutes under a shared verify + lock — for a tree that needed nothing. The same command now exits 0 with no + rebuild, and a genuine source edit still refuses. + + **The evidence is new, because neither `dist/` stamp could stand in.** Both are + written at the END of the build, whereas `gen:schema` is its FIRST step and is + also run standalone and again by `check:authorable-surface` — so a `dist/` stamp + is evidence about `dist/`, and in the standalone case there would be none at all. + `build-schemas.ts` now writes `json-schema/.build-input-hash-schema` as the last + thing it does: one write point, after the unconditional whole-tree regeneration + that precedes its `--check` / `--update-base` fork, so all three entry points are + covered, and after every ratchet that can exit 1, so a refused run vouches for + nothing. + + **⛔ The digest may only ACQUIT, never accuse.** A missing, unreadable or + non-64-hex stamp is `unstamped` — no evidence — and leaves the mtime refusal + exactly where it stood (#4690). Nothing that passes today can start failing, and + the rule keeps its only conviction instrument: mtimes still see the hand-edited + tree and the toolchain change a content digest is blind to. + + **Why this ships, and why it is a changeset rather than `skip-changeset`.** + `json-schema` is in `@objectstack/spec`'s published `files[]`, so the new stamp + travels in the tarball — measured with `npm pack --dry-run`: + `json-schema/.build-input-hash-schema` is present alongside the two existing + `dist/` stamps. One 65-byte file is added to the published package. No export, no + schema key, no runtime behaviour and no authorable surface moves. + + **One other published-adjacent change**, for the same soundness reason: the build + digest (`scripts/build-input-hash.mjs`) now also hashes `/scripts/**` for + packages that have it. `packages/spec`'s generators live there and were in none of + the previous input sets, so an edited generator kept a digest that had not moved — + and a stamp written by the OLD generator would then acquit a tree the new one + emits differently. Widening a digest can only ever WITHHOLD an acquittal, never + grant one, so the two `dist/` stamps become strictly more honest as well; the + first build after this lands re-stamps all three. +- d4a1a28: `ObjectNavItem.recordId`'s docblock said it was "Mutually exclusive with `viewName`" — the guard tolerates that exact pair, deliberately + + The docblock read *"Mutually exclusive with `viewName` (viewName is ignored if + both are set)"*. The parenthetical was the tell: *"ignored if both are set"* + describes a **precedence**, not a refusal, so the sentence's own second clause + contradicted its first — and the code agrees with the second clause. + `recordId` + `viewName` parses clean through `NavigationItemSchema`; it is the + one legacy combination `objectNavTargetExclusivity` lets through, and that + guard's own docblock says so in as many words. + + **The harm direction is silent in both directions.** An author (or an agent) + who read "mutually exclusive" would avoid a combination the platform accepts, + or file a bug when it parses. Two docblocks in one file described one rule and + disagreed; the guard's was right. + + ⛔ **No behaviour changes, and the asymmetry is not "unified".** The tolerance + is a recorded decision, and `app-nav-target-exclusivity-export.test.ts` already + pins `recordId` + `viewName` as accepted precisely so that making the target + fields pairwise exclusive goes red. This changeset corrects the **prose** only: + no schema, no guard, no accept set, no authorable key, no export moves. The + `.describe()` strings — the ones that reach `content/docs/references/` — are + untouched. + + The corrected docblock now says the pair is tolerated rather than refused, + names the guard that tolerates it, and points at the test that pins it. The + same test file gains a fifth leg asserting the docblock against the accept set + it describes, so the next copy of this sentence goes red instead of shipping: + prose is the only place the tolerated pair is documented, so nothing else was + watching it. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` carries both + `dist` and `src/**/*.zod.ts`, and `src/ui/app.zod.ts` matches that glob — the + edited file is shipped as source verbatim. Measured on the built artifact as + well: the new sentence is present in **18** built files under `dist/` and the + old spelling in **0**, with two untouched sentences from the same region + (`navigate straight to the detail page`, and the `filters` docblock's own TRUE + exclusivity claim over `recordId` / `viewName`) present in **18** each as the + lit controls, so the zero is a reading and not a mistyped anchor. The + declaration files do not carry it — this is a field-level docblock inside a Zod + shape — which is why the reach is stated as the bundles and the shipped source + rather than as `.d.ts`. +- d34f9b6: The `agent.tools` rejection now says why ADR-0064 binds, so its `Proposed` status does not read as "not yet in force" + + An author who writes the retired `agent.tools` key gets the tombstone's + prescription, which rests the rule on **ADR-0064** (*"an agent's tool set is the + union of its surface-compatible skills' tools"*). Following that citation lands + on a record whose own header reads `**Status**: Proposed (2026-06-22)` and + carries a `🔶 Cloud-owned — superseded in part by cloud ADR-0025` callout. From + the record itself an author cannot tell that the rule still binds them — the + weaker reading is the one the metadata invites. + + ADR-0064 stays the cited authority, because it is the record that states the + invariant the key violated; **ADR-0109** (`Accepted — implemented (Phase 1)`) + names `agent.tools` nowhere and only *builds on* that invariant, so retargeting + the citation would send the author to a record that does not contain the rule + they broke. The message instead gains one clarifying clause: the `Proposed` / + cloud-owned status scopes the **runtime** half (tool resolution, which lives in + cloud `service-ai`), while the **authoring** half is in force in this repo and + ADR-0109 is the in-repo record carrying it. + + Prose only — the rejection, the retirement and the accept set are unchanged. +- aaacf1d: Say what the install-time granted permission set actually does: it is REGISTERED at load and refuses nothing. + + Four shipped sentences claimed the structured `manifest.permissions` / `granted_permissions` set was enforced. Measured on `9bd4344e4`: `SecurePluginContext` — the only reader of `PluginPermissionEnforcer`'s service and hook gates — has zero production construction sites, and `enforceFileRead` / `enforceFileWrite` / `enforceNetworkRequest` are called by nothing at all, `SecurePluginContext` included. So #13457's binding registers a consented set that nothing queries, and the `fs` and `network` classes have no enforcement surface even in principle. + + Corrected, each to the same truthful split ("registered at load · queried by nothing · refuses no operation"): the `registerGrantedPermissions` docblock, the `PluginPermissions` schema docblock, the `manifest.loading` tombstone prescription, and the ADR-0087 D3 entry that ships that prescription into `docs/protocol-upgrade-guide.md`. The hand-written plugin development guide gains the same note beside its permission table. + + `plugin-runtime-tier-truthful-text.test.ts`'s coordination pin — which held the permissions half verbatim so it would go red the day that half was corrected — has been discharged and replaced by pins on the truthful text, in both carriers, each with the negative assertion that keeps the retracted sentence from returning beside it. + + New in `@objectstack/core`: `granted-permissions-not-enforced.pin.test.ts` pins the MEASUREMENT as well as the words, so the claim cannot rot in either direction. It fails the day a production `SecurePluginContext` construction site appears — i.e. the day the ADR-0025 materialize seam lands — and names every text that then becomes false. + + No behaviour changes: no accept/reject, no registration, no gate is added or removed. +- e0e4a56: fix(spec): the `etl-pipeline-layer-retired` D3 entry stops promising that connector-attached sync is EXECUTED + + The entry's `replacement` string is an ADR-0087 D4 projected field: it ships verbatim in + `packages/spec/spec-changes.json` (twice — the flat entry and the composed record), which is + in this package's `files[]` and therefore in the published tarball, and it renders into + `docs/protocol-upgrade-guide.md`. It is the advice an author displaced by the ETL layer's + retirement actually reads, and it said connector-attached synchronisation is + `ConnectorSchema.syncConfig`, "which IS parsed and executed". + + Parsed is true. Executed never was, and this tree measures it: + + - `AutomationEngine.registerConnector` / `registerDegradedConnector` + (`packages/services/service-automation/src/engine.ts`) run `ConnectorSchema.parse(def)` and + store the parsed definition in the engine's connector map. Only `actions` is read back off + it; `syncConfig` is never read. + - `syncConfig` has no reader outside `packages/spec` at all — the only non-spec occurrences in + `packages/` are two comment lines in the D7 expression-conformance ledger. That is the same + measurement that retired `syncConfig.schedule` in 18 under ADR-0049, and it is already + stated at the schema (`integration/connector.zod.ts`). + + The corrected sentence says what the block IS and what actually happens to it — parsed and + validated, then inert — and then names the surface that IS executed, so the reader still has + somewhere to go: a connector's `actions`, dispatched by a flow's `connector_action` node, + which resolves the registered handler and awaits it. + + Nothing about the ETL retirement itself changes: no key moves, no accept set moves, no schema + changes. The registry, `spec-changes.json` and the upgrade guide were regenerated by their + generators, and the corrected claim is pinned in `migrations.test.ts` beside the other + projected-string corrections so it cannot regress. +- 7aae005: `ComponentPropsMap['object-grid'].exportOptions` names all five members the renderer reads, not two + + The entry is `z.unknown()`, so nothing about this key is parsed, refused or + stripped: a member that does not exist draws no error and has no effect, and a + member that does exist cannot be discovered from the schema. That makes the + `.describe()` string the entire account of the key's shape rather than a summary + of an enforced one — and it projects straight into + `content/docs/references/ui/component.mdx`, which is what an author (or a + generating model, ADR-0033) reads. + + It named two members, `formats` and `streaming`. The only renderer reads five. + + Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` + — objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the + `schema.exportOptions` expression and the `exportConfig` local bound to it, with + objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose + comment/string stripping is what stops a prose mention of a key being counted as + a read): `formats` 2 read sites, `streaming` 2, `maxRecords` 1, + `includeHeaders` 1, `fileNamePrefix` 1, and an absent-name control + (`zzzNotAMember`) 0 on the same instrument — which is what makes those five + counts readings rather than a matcher that matches anything. The same instrument + answers the same five, with the same per-member counts, at objectui + `3fbdd4a2dae1`, so the set is not an artefact of the pin's age. + + The three missing members are `maxRecords`, `includeHeaders` and + `fileNamePrefix`. An author reading the old string learned that + `exportOptions` takes `{ formats, streaming }` and had no way to reach the other + three short of reading the renderer's source — the shape objectstack#8010 + closed for this same key one layer out, when `streaming` was read for releases + while no schema declared it. + + ⛔ The key is unchanged: it stays `z.unknown()` and no accept set moves in either + direction. Giving `exportOptions` a real shape is a separate and much larger + change with its own review requirements; this is the docs half only. + + The new list is not restated in prose that can drift on its own. A pin holds the + describe string's member enumeration equal to the members + `ListViewExportOptionsSchema` declares — the spec's own five-key declaration of + this same authoring block, reached through `ListViewSchema.exportOptions`'s + object branch and itself derived from that same read set. Both spellings reach + one renderer, so narrowing or widening the declared block now reds the + `z.unknown()` prose instead of leaving it quietly behind: the declared side has + parse failures to catch drift, this side had nothing. The pin also records that + the key is unvalidated today, so the day it grows an accept set is a deliberate + decision rather than a silent one. + + `content/docs/references/ui/component.mdx` is regenerated from the string + (`gen:schema` then `gen:docs`) and carries the same one-line change. +- ada2869: fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290) + + + + **BREAKING** — `@objectstack/metadata-protocol`'s `insertManyData` no longer hangs + `droppedFields` on each entry of `outcomes`; the response itself carries it, beside + `outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read + the per-row member stops compiling, and the compiler names the site. The set reported + is the same set — what is gone is a per-row attribution that could not be computed + here and was wrong whenever it mattered. Nothing authored or stored changes shape. + + **What it got wrong.** Every create-side strip is the engine's, and its + `onFieldsDropped` event is the UNION over the batch — the listener signature + carries no row index. This seam reconstructed a row set from that union by + asking which rows SUPPLIED each dropped name + (`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that + "the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs + to exactly the rows whose supplied payload carried it". Maintainer ruling C + falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`, + AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned — + recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where + a hook stamps a protected key on some rows and not others: + + - row A supplied `approval_status`, no hook write ⇒ stripped, enters the union; + - row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and + written**; + - and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]` + on a record that still held `approval_status`. + + A row the batch culled before the strip ran (a per-row validation failure) was + named on the same test, having dropped nothing at all. + + ⇒ A wrong attribution costs the reader a wrong investigation, and the import + surface — which prefers this path over `createManyData` — is the consumer most + likely to act on it while reconciling what landed. + + **Why not attribute per row instead.** The honest set is `{rows whose payload + carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second + half is computed per row upstream but does not cross this seam. The outcome's + own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED + over exactly the keys the strip took, and a stripped `autonumber` is refilled by + `applyAutonumbers` — so on both, the key is PRESENT on the row that really did + drop it, and a post-hoc "is the key still there?" check would delete true + attributions while leaving the hook-exempt false one standing. Comparing values + fails on the very case `hookWrittenKeys` exists for: the hook assigning the + value the caller also sent. Restoring row precision means giving the engine's + drop report a per-row channel, not a reconstruction at the call site. + + **Prose corrected with it**, by CLAIM rather than by spelling — the docblock + that authorised the inference is the thing that re-authorises the next author: + `insertManyData`'s own docblock and `createManyData`'s parenthetical + (`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing + sentence, `engine.insertMany`'s docblock claim that "a caller holding the input + rows can attribute each name back to the rows that carried it" + (`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and + `CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical + (`@objectstack/spec`, a string printed AT the customer). + + **Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`, + and they always could: each row is its own `engine.update` / `engine.insert` + call, so that call's events are that row's — earned mechanically, not inferred. + `createManyData`'s aggregated shape is untouched. No strip changes, no row + changes, and the same field names are reported. +- d88a47d: A retirement prescription is the top-level message a `PUT /api/v1/meta/view` 422 carries, instead of sitting buried in `invalid_union` sub-errors + + `ViewMetadataSchema` is the union behind the runtime write door — the one an + MCP/AI author reaches, with no CLI anywhere on the path. A shape-level refusal + raised inside one of its four branches did not become the union's message: the + top level read zod's bare `Invalid input`, and the upgrade prescription sat at + `error.issues[0].errors[k][j].message`. Every retirement this platform wrote for + list and form views was therefore invisible at the one door its intended reader + uses — shipped behaviour since 17.0.0 for `virtualScroll`, `striped` and + `bordered`, not a recent regression. + + The lift is family-wide rather than per case. `retiredKey()` raises one declared + issue shape — `code: 'invalid_type'`, `expected: 'never'`, with the prescription + as its `message` — so the union's existing `.check()` now lifts that message + verbatim from the branch the body claims. The next retirement on this shape is + surfaced without anyone remembering to wire it, which is what a per-case fix + could not promise. + + What does not move: the accept/reject verdict of every body (the lift runs after + the union has reached its verdict and writes one string), the issue codes, the + nested `errors` array and its order, and the message of every refusal that is + not a retirement — a plain shape error still reads `Invalid input`, and a + curated unknown-key refusal still reads exactly as it did. That boundary is + measured, not asserted: `strictObject()` closes a shape with a `z.never()` + catchall, so the union's members reach 67 `never` leaves of which only 8 are + tombstones — zod folds a rejecting `never` catchall into `unrecognized_keys`, so + the other 59 never raise the lifted shape at all. +- 2d34f32: The seven converged rule-array `filter` doors name the ViewFilterRule array form when they refuse the record form + + Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the + objectui#6206 family — `ElementDataSourceSchema.filter` (`ui/page.zod.ts`) and + the `object-grid` / `object-metric` / `object-kanban` / `object-calendar` / + `element:number` / `element:record_picker` rows of `ComponentPropsMap` + (`ui/component.zod.ts`). Each previously accepted the MongoDB-style record + (`{ status: 'active' }`), and each now refuses it — measured on the built + artifact, with exactly one issue apiece: `invalid_type` at `filter`, *"Invalid + input: expected array, received object"*, and nothing else. + + The prescription for that transition was already written down twice, in two + places a parse never reaches: every one of the seven `.describe()` strings, and + in full in the three `18.*-filter-rule-array` semantic migration entries. + Nothing bridges `.describe()` into a zod issue and this package installs no + global error map, so the one population whose metadata the convergence broke — + the authors, human and AI, who wrote the previously-legal form — received the + single sentence that does not say what to write instead. + + Each of the seven now answers that value with the new spelling, through the + zod-v4 `{ error }` param this package already uses for targeted guidance + (`shared/expression.zod.ts`, `ui/view.zod.ts`, `shared/strict-object.ts`): + + > `filter` on this `object-grid` takes the ViewFilterRule ARRAY form + > `[{ field, operator, value }, ...]`, and this value is the MongoDB-style + > record form this door took before the one-filter-orthography convergence. + > Write one rule per record key — they AND — so this filter becomes + > `[{ field: 'status', operator: 'equals', value: 'active' }]`. Legacy operator + > shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. + > Full conversion table: migration + > `element-data-source-and-object-block-filter-rule-array`. + + Following `strictObject`'s model rather than transcribing a sentence seven + times: the rule shape is read from `ViewFilterRuleSchema`'s own shape, the + canonical operator is `normalizeFilterOperator('eq')` — the same fold the door + itself runs — and the worked rewrite is computed from the author's own record, + so the example names their fields. A pin holds each door's `migration` id equal + to a real registry entry and each door's `surface` equal to the one its own + `strictObject` declaration registered. + + ⛔ No accept set moves. The doors refuse exactly the shapes they refused + before, the generated `json-schema/` and `authorable-surface` artifacts are + byte-identical after the change, and the map returns `undefined` for everything + that is not a plain record — so an array author's element-level issues + (`filter.0: Invalid option: expected one of "equals"|…`) and a non-record value + (*"expected array, received string"*) still arrive in zod's own words. + + **Shipped, which is why it carries a changeset rather than `skip-changeset`.** + Measured on the built artifact after both tsup passes finished: the new message + text is present in **18** published files of `npm pack --dry-run`'s 2012, the + test-only text is present in **0** (negative control), and a pre-existing + shipped string reaches **62** as the lit control proving the scan reaches. + `src/ui/page.zod.ts` and `src/ui/component.zod.ts` are also shipped as source + by `files[]`'s `src/**/*.zod.ts`. +- 9e3c485: `date-macros.zod.ts`'s module header states the ADR-0053 D-D upper-bound rule the platform implements, instead of the rule it replaced + + The header's "Out of scope" block told an author that on a `datetime` column + `<= {current_year_end}` **stops at midnight on the 31st**, and prescribed the + half-open `< {next_year_start}` as the fix. That is the pre-ADR-0053 reading. + The platform rule has been the opposite since #3777: a bare `YYYY-MM-DD` used + as an upper bound denotes the WHOLE day, compiled half-open to the next + calendar day. It is stated once, in + `packages/spec/src/data/calendar-day.ts` (ADR-0053 D-D), whose own operator + table reads: + + | Operator | A bare `YYYY-MM-DD` on a `datetime` column means | + |---|---| + | `$gte` / `$gt` / `$lt` | that day's `00:00:00.000` — already correct as written | + | `$lte`, a `$between` max, a `dateRange` end | the WHOLE day → compile `< nextUtcCalendarDay(day)` | + + and which `packages/spec/src/data/temporal-conformance.ts` pins cross-driver: + the case *"datetime: bare-day `$lte` keeps the whole final day"* expects + `d_mid` (09:15 on the boundary day) and `e_late` (21:40 on it) as members. + + **Why this header and not a note.** It is the doc comment on the vocabulary an + AI author reaches for, and it is the one place in the tree that says what a + `*_end` token does on the right-hand side of an operator. Both the old + prescription and the correct spelling parse, run and return rows, so nothing + downstream reports the mismatch — the author simply carries the wrong model + into every later filter. + + **What the correction does.** The load-bearing first clause is kept verbatim: a + `*_end` token IS the period's last calendar DAY. What follows now **cites** + `calendar-day.ts` rather than restating the rule, so the two statements cannot + drift apart again, and the half-open detour is refused by name for the reason + it is now wrong — the widening is already applied. + + ⛔ No behaviour changes. The diff is comment lines only; no schema, accept set, + authorable key or published payload moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` lists + `src/**/*.zod.ts`, so this file ships verbatim as source, and the header is the + first thing in it. + + The generated reference page `content/docs/references/data/date-macros.mdx` + carried the same sentence — it is rendered from this header and is marked + AUTO-GENERATED — and is regenerated here with + `pnpm --filter @objectstack/spec gen:schema && … gen:docs`. +- e1796ad: **Clause-②: no** — no schema key moves, no accept set widens or narrows, no export changes. This is the liveness ledger stating what the renderer actually does with an authored `chartConfig`, at one verdict per key instead of one blanket verdict for fourteen. + + `packages/spec/liveness/dashboard.json`'s `widgets.chartConfig` row is **drilled**: it now carries `children`, one status + evidence per `ChartConfigSchema` key, re-measured against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. The row ships — `packages/spec` publishes `liveness/` whole — so this changeset is a measurement, not a convention: `npm pack --dry-run` puts `liveness/dashboard.json`, `liveness/README.md` and `liveness/state-counts.md` in the tarball (38 files under `liveness/`), and the fourth changed path, the undrilled-containers baseline under `scripts/`, is not in it (0 files under `scripts/`). + + Per-key verdicts, all pinned in the renderer repo: + + - **12 live.** Nine chrome keys are lowered onto the chart schema by `chartConfigPresentation`, one guard each — `title`, `subtitle`, `description`, `colors` (split two ways into the positional palette and the per-category map), `height`, `showLegend`, `showDataLabels`, `annotations`, `interaction`. `xAxis`, `yAxis` and `series` join them by a different route: `mergeAuthoredPresentation` merges their **presentation** onto the bindings the dataset selection derived, dropping exactly the two binding keys `ChartAxis.field` and `ChartSeries.name` so that series membership and the plotted column stay with the dataset. + - **2 dead.** `chartConfig.type` parses and does nothing on a dashboard widget — the widget's own `type` picks the chart family — and `chartConfig.aria` has no reader on either face: the chart implementation declares no `aria` prop and the ARIA injection reads the flat `ariaLabel` / `ariaDescribedBy` / `role`. Both are pinned as **negatives** by name in the renderer's own tests, which is what makes them re-askable rather than merely asserted. + + Neither `dead` verdict is acted on here. Recording a verdict is what feeds the ADR-0049 enforce-or-remove worklist; executing one moves a published accept set and is a separate, ruled piece of work. + + The drill also makes six containers one level further down visible for the first time (`xAxis`, `yAxis`, `series`, `annotations`, `interaction`, `aria` — 39 child keys). They are **recorded** in the shrink-only undrilled-containers baseline rather than drilled: fanning this row's verdicts down over them would manufacture verdicts with no evidence behind them, which is the one thing the drill rule forbids by name. +- c9eb773: The liveness ledger's published README no longer declares a one-level drill — the walk follows a nested `children` map as deep as the ledger declares, and says so + + `check-liveness.mts` read `led.children[ck]` and never recursed into a child's + own `children`. A `children` map written at **depth two** was therefore accepted + by the file format and then ignored in silence: no evidence path resolved, no + key reported unclassified, no container reconcile, and no line of output saying + any of it was missing. Because the enforce-or-remove channel acts on this gate's + `dead` verdicts, a silently skipped subtree could retire a key that was alive. + + The walk now descends as far as the ledger nests, the reverse (orphan) direction + follows it down, and a drilled child that is itself a container owes the same + declared disposition — drilled, deferred or recorded — that its top-level peers + already owed. `MAX_DRILL_DEPTH` is a tripwire rather than the working limit: + every key below it is reported **UNCLASSIFIED**, which fails the gate, because a + depth limit the instrument does not announce would rebuild the same defect one + level lower. + + **No verdict moved.** Before and after: live 850, planned 10, dead 93, + experimental 5, live-elsewhere 1 — the full per-type `byStatus` map is + byte-identical. Nothing flipped to or from `dead`, so no retirement is in + question. What did move is the census the gate publishes about its own + completeness: 54 containers became visible at once, every one of them already + riding on a blanket verdict below a drilled container where a one-level walk + could not see it. Three are genuinely classified elsewhere (`app/navigation`'s + NavigationItem keys) and resolve as deferrals; the other 51 are recorded debt. + + **Why this carries a changeset rather than `skip-changeset`.** The tool, its + tests and its baseline all live under `packages/spec/scripts/`, which is absent + from the package's published `files[]` — measured at 0 entries in the packed + tarball, against `liveness/` ships at 38 as the lit positive control. But + `files[]` ships the `liveness` directory whole, and `liveness/README.md` is the + ledger's authoring contract: its "Granularity — drill one level" section is what + an author reads before writing a `children` map, and that sentence is now wrong. + The published bytes that change are that section, the depth rule that replaces + it, and the re-stated census. No ledger verdict file changed. +- 4342c99: fix(spec): three more lookups refuse an off-vocabulary key instead of handing back an `Object.prototype` member + + `BASE_ALIASES` / `DIALECT_ALIASES` (`canonicalizeSqlType`), + `DEFAULT_VALUE_TOKEN_SUGGESTIONS` (`suggestDefaultValueToken`) and + `CONTEXT_TOKEN_SUGGESTIONS` (`classifyFilterToken`) are plain object literals, so + all three inherit `Object.prototype`, and every lookup into them was a bare + index. Measured by importing the BUILT artifact (`dist/data/index.mjs`) on the + repo's Node 22 baseline (v22.22.2) and driving each function — the same way the + two landed siblings in this family were measured — over a fixed population of + five: `constructor`, `toString`, `valueOf`, `__proto__` and a plain unknown word. + + | call | before | after | + |:--|:--|:--| + | `canonicalizeSqlType('varchar')` | `'text'` | `'text'` — unmoved | + | `canonicalizeSqlType('timestamptz', 'postgres')` | `'datetime'` | `'datetime'` — unmoved | + | `canonicalizeSqlType('constructor')` | the `Object` **function**, out of a signature that admits only `CanonicalSqlType` string literals | `'unknown'` | + | `canonicalizeSqlType('constructor', )` | the `Object` **function** | `'unknown'` | + | `canonicalizeSqlType('__proto__')` | `'array'` | `'array'` — unmoved; the array-notation rule answers ahead of either table | + | `canonicalizeSqlType('toString' / 'valueOf' / 'nope')` | `'unknown'` | `'unknown'` — unmoved | + | `suggestFieldTypeForSqlType('constructor')` | **`TypeError: Cannot read properties of undefined (reading 'suggested')`** | `undefined` | + | `isCompatible('constructor', 'text')` | **`TypeError: … (reading 'exact')`** | `'lossy'` | + | `suggestDefaultValueToken('currentuser')` | `'current_user'` | `'current_user'` — unmoved | + | `suggestDefaultValueToken('constructor')` | the `Object` **function** | `undefined` | + | `suggestDefaultValueToken('__proto__')` | `Object.prototype` — an **object** | `undefined` | + | `classifyFilterToken('{current_user}').suggestion` | `'current_user_id'` | `'current_user_id'` — unmoved | + | `classifyFilterToken('{constructor}').suggestion` | the `Object` **function**, in a field declared `ContextToken` | `undefined` | + | `classifyFilterToken('{__proto__}').suggestion` | `Object.prototype` | `undefined` | + + The two `TypeError` rows are the sharpest consequence and were not previously + recorded: a non-`CanonicalSqlType` reaches `CANONICAL_TO_FIELD[canonical]`, which + is `undefined`, so both published sibling accessors threw on the member read + rather than merely returning something off-contract. `canonicalizeSqlType`'s + `rawType` comes off live database introspection, which is where an + attacker-free, entirely accidental `constructor` actually comes from. + + `classifyFilterToken`'s half is the one a type-checked consumer meets: the + declared `suggestion?: ContextToken` was a compile-time guarantee that was false + at runtime, and nothing in the type system would ever have flagged it. Its + wrapped-token regex captures `[^{}]+` — anything but braces — so the reachable + key set is not the identifier-shaped one; what bounds it is the `toLowerCase()`, + which leaves exactly the lower-case-stable prototype members (`constructor`, + `__proto__`) namable today. `toString` / `valueOf` were quiet by that casing + accident alone, not by a guard. + + All three sites now go through an `Object.prototype.hasOwnProperty.call` check + returning each function's own already-declared refusal value — `'unknown'`, + `undefined`, and an absent `suggestion` respectively. No declared signature + changes. This narrows and widens nothing an author can reach: every legal + spelling is an own key of its table, so nothing accepted before is refused now, + and only answers that were never inside the declared return types move. + + A null-prototype table was the other available shape and is not taken, for the + reason the two landed siblings measured rather than assumed: a `__proto__: null` + object literal does not type-check against the `Record<…>` annotation at all + (TS2353), and the `Object.assign(Object.create(null), …)` spelling that does + compile silently costs that annotation's exhaustiveness check (TS2741 stopped + firing for a table missing a member). A quiet failure is worse than a loud one. +- 132dd13: `KnowledgeSourceSchema`'s docblock stops claiming it is stored as metadata "exactly like a view or a flow", and says where a knowledge source actually lives + + The docblock above `KnowledgeSourceSchema` declared, verbatim: + + > Canonical KnowledgeSource. Stored as metadata, versioned, and + > environment-scoped exactly like a view or a flow. + + None of the three is true, measured on the tree this changeset lands on: + + - `listMetadataTypeSchemaTypes()` returns **26** governed metadata types and + **none is knowledge-shaped**. Controls that fire: `view`, `flow`, `skill`, + `agent` and `tool` are all present; a `zzz_nonsense` dark control is absent. + - `ObjectStackDefinitionSchema` has **44** top-level keys, none knowledge-shaped + (controls present: `skills`, `agents`, `tools`, `views`, `flows`). + - `defineStack({ knowledgeSources: [...] })` is refused with the **generic** + unrecognized-top-level-key message — byte-identical to the message for + `zzz_nonsense`. Lit control: `defineStack({ skills: [] })` is + accepted on the same base, so the probe does find an authoring route for a + type that has one. + + So an author who followed the sentence reached for a mounting that does not + exist and got a rejection that pointed nowhere — the authoring trap, not a + wrong example. + + **The prose was the outlier, not the schema.** No ADR in this repo mentions + `KnowledgeSource` at all, and the rest of the contract is already consistent: + `IKnowledgeService` declares `registerSource` / `unregisterSource` / + `listSources` / `getSource`, `KnowledgeServicePlugin` takes a `sources` option + at kernel wiring and calls `registerSource` for each, and the implementation + holds them in a process-lifetime `Map`. The `agent.knowledge` liveness row says + the same thing from the other side — *"restrict retrieval at the + knowledge-service/source level; describe grounding in `instructions`"*. + + The replacement docblock states what the schema is (the shape of a runtime + registration), names both routes a source actually arrives by, and says the + retrieval restriction is per-source at the service level. + + ⛔ No behaviour, no key and no accept set changes: the diff is one docblock. + Running `gen:schema` and `gen:docs` afterwards produced no artefact change — + `content/docs/references/ai/knowledge-source.mdx` mirrors the file-level header + docblock, not this per-schema one. + + **Why this is not `skip-changeset`.** `@objectstack/spec`'s published `files[]` + ships `dist` *and* `src/**/*.zod.ts`, so this text is published twice over: the + old sentence was measured in the built `dist/knowledge-document.zod-*.d.ts` and + `.d.mts` (1 occurrence each) before the edit, and the source file is shipped + verbatim. Both move. +- dfeba25: `element:record_picker`'s `filter` docblock now says what the `object-*` blocks actually declare + + The docblock on `ElementRecordPickerPropsSchema.filter` (anchor: + `Filter rules narrowing which records the picker offers`) carried a + parenthetical claiming *"the four `object-*` blocks declare `filter` as + `z.unknown()`, no orthography at all"*. Measured on the file itself: there is no + `filter` key anywhere in `packages/spec/src/ui/component.zod.ts` declared + `z.unknown()` — zero occurrences, against 61 occurrences of `z.unknown()` in the + same file on the same instrument, so the zero is a reading and not a broken + matcher. All eight Zod `filter` declarations in the file are + `z.array(ViewFilterRuleSchema).optional()`; the one remaining `filter:` line is a + `KeySetGuidance` prose entry, not a declaration. + + The `object-*` family in `ComponentPropsMap` has **six** entries. **Four** of + them carry a `filter` door — `object-grid`, `object-metric`, `object-kanban`, + `object-calendar` — and all four declare `z.array(ViewFilterRuleSchema)`. The + other two, `object-form` and `object-master-detail-form`, declare no `filter` + key at all. The corrected parenthetical states both numbers and names all six, + and keeps the `#15449` citation, which is accurate as provenance for when those + four doors moved onto the array form. + + **Why this is worth a patch rather than a silent tidy.** The sentence sat in the + one docblock that tells an author what the sibling `filter` doors accept, and it + told them those doors accept anything. The record form it thereby invited — + `{ field: { $eq: ... } }`, the MongoDB-style shape this very docblock says the + picker moved OFF — is refused at parse by all four. Prose only: no declaration + moves and no accept set changes. +- 0a88a80: docs(spec): the structural-condition ruling and the ADR-0087 entry both name the NODE slot (#17493) + + Two places in `packages/spec` still described the world as it was before the + blank structural condition became a defect. Neither changes behaviour: this is + the notification half of a refusal that has already shipped. + + **The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required` + named only the edge key.** Its `surface` and `acceptanceCriteria` told a + consumer replaying the chain to sweep `edges[].condition` and nothing else — + so a deployment carrying a blank `config.condition` on a flow node was never + told to look, even though `AutomationEngine.registerFlow` refuses it since + #17322 and `objectstack validate` since #17495. Both fields now name both + structural slots, the node key's own locator + (the phrase the structural pass builds, e.g. `node 'gate' (start) condition`) is + stated beside the edge's `flows.N.edges.N.condition`, and the sweep carries the + warning that removing a `condition` from a `start` node opens the trigger gate + rather than preserving it. The entry's `id`, `replacement` and `reason` are + untouched, and no new entry is added: this is one decision reaching its second + slot, not a second decision. + + **`structuralConditionRefusal`'s docblock stated a ruling that had become + false.** It admitted a whitespace-only string on the ground that such a + condition "is consistent on both sides and is ruled correct, not a defect" — + the ground #15807 removed at the edge door and #17322 ruled on. The admission + itself is unchanged and still correct, because this function answers the SHAPE + question only and the blank is refused beside it by the imported + evaluated-slot rule; what the docblock now records is which card removed the + ground, which door each refusal lives at, and why the two refusals are kept + distinct. + + It also records, without answering, the question one slot over: the ledger + `predicate` slots (`config.conditions[].expression`, + `screen.fields[].visibleWhen`) still admit a whitespace-only string, pinned as + correct by #15572 on the same ground. Narrowing them re-judges that pin and + moves a published accept-set, so it is a ruling and stays open on #17493. +- 2eb4724: `ApproverType` qualifies `manager` in its `.describe()` instead of offering it as a bare allowed value + + `ApproverType` carried **no** `.describe()` at all, so the generated reference + page rendered `## ApproverType` with nothing but an `### Allowed Values` list: + `manager` — the one rung an author cannot operate on a stock install — read + exactly like the nine members that work. `{ type: 'manager' }` resolves + `sys_user.manager_id`, and that column still has no product write surface + (re-measured on this tree: the identity write guard's managed-update whitelist + for `sys_user` is `{name, image, locale}`; the column carries `readonly: true`; + no `packages/plugins/plugin-auth` source writes it). An author who chose it got + a chain that passed `validate` and `lint` and then stalled on its first + submission. + + The new describe says what is true about `manager` and **points** at the remedy + rather than restating it: `MANAGER_ONLY_REMEDY` / `MANAGER_ONLY_ROUTES` in + `packages/lint/src/validate-approval-approvers.ts` remain the single + authoritative copy of the population routes, and that file's `DEPENDENCY` + docblock now names this new string among the lines that go stale if the column + ever gains a write surface. A pointer cannot drift into disagreement with what + it points at, which is why no third copy of the 667-character remedy was added. + + ⛔ No member is added, removed or renamed, and no behaviour changes: the enum's + accept set is byte-identical and `check:api-surface` is green on the rebuilt + `dist/*.d.ts`. + + **Why this ships, and why `patch`.** `@objectstack/spec`'s published `files[]` + carries `dist`, `json-schema` and `src/**/*.zod.ts`, and the new string is + measured in all three on the built tree — `dist/automation/index.js` and + `.mjs` (2 files, against a lit control of an existing describe from the same + module, also 2), four `json-schema/` documents (`ApproverType.json`, + `ApprovalNodeApprover.json`, `ApprovalNodeConfig.json`, `objectstack.json`) and + the shipped `approval.zod.ts` source. Prose only, no surface widening ⇒ + `patch`. + + The `packages/lint` half is a docblock comment and is deliberately **not** + graded: that package publishes `dist` only, and the new sentence is absent from + it (0 files) while a runtime string from the same source file is present in 4 + and a pre-existing comment from the same docblock is absent in 0 — so comments + are stripped by construction and nothing published moves there. +- 6b97a20: fix(spec): the 17 → 18 chain now NAMES the bare `element:filter` / `element:form` node it leaves behind, instead of ending schema-invalid in silence (#17594) + + `element:filter` and `element:form` were retired whole at element grain, and the + two ADR-0087 D2 conversions that carry the retirement — `element-filter-removed` + and `element-form-removed` — strip every authorable key and **deliberately leave + the bare component node**: deleting an authored page node changes a page's + layout, which a mechanical conversion must not decide. That residue was inert + until both names joined `RETIRED_PAGE_COMPONENT_TYPES` and the parse began + refusing them by name — at which point deleting the node stopped being optional + and became a required step of the upgrade. + + The chain never said so. Measured on a stack carrying both nodes, before this + change: + + ``` + os migrate meta --from 17 --to 18 + + --json schemaValid: false + human path "Migrated stack does not yet pass schema validation — + resolve the manual changes above" + the 115 step-18 todos 0 name `element:filter`, `element:form`, + `ElementFilter` or `ElementForm` + ``` + + ADR-0087 D3 requires a structured TODO "rather than silence" for a migration + step that cannot be expressed declaratively, and this is one: only the author + knows what their region should hold once the node is gone. The new + `element-filter-and-form-node-refused` semantic entry supplies it — surface, the + two replacements (`userFilters` for the filter, the object-bound `object-form` + block for the form) and an `os validate`-clean acceptance criterion — so + `os migrate meta` and the generated upgrade guide both name the thing to delete. + + ⛔ Nothing about either conversion's behaviour changes: they still strip the keys + and still leave the node, and no node is deleted for the author. + + +- 00c332b: Three duration keys now name their unit in the `.describe()` prose that reaches the published output, not only in the key name and the JSDoc above them: `PluginLoadingEvent.durationMs` (`kernel/plugin-loading.zod.ts`), `AppInstallResult.durationMs` (`system/app-install.zod.ts`) and `MigrationPlan.estimatedDurationMs` (`system/deploy-bundle.zod.ts`). + + The first carried no `.describe()` at all, so the generated reference row for `durationMs` rendered an empty description cell; the other two said `Installation duration` and `Estimated execution time`, naming a duration with no unit. All three JSDoc blocks already said milliseconds, and all three key names already carry `Ms`. Only the channel an author — very often a model (ADR-0033) — actually reads was missing it. + + ⛔ Not a rename, and no key moves: the unit is already in the key name, which is what the #14478 rule asks for. This is the describe-only remediation of Ruling A on #15939, and it is the one of the seven remediations that needs no ADR-0087 conversion, no tombstone and no published-key rename. + + **The published surface was measured rather than assumed**, because a changeset is owed only if the changed text actually ships. Measured after `pnpm --filter @objectstack/spec build`, over the paths this package's `files[]` actually publishes: + + - **The changed text ships.** `Duration in milliseconds` reads 24 occurrences across 12 `dist/` bundle files and 6 across `json-schema/`; the other two read 8 in `dist/` and 2 in `json-schema/` each. The generated reference pages under `content/docs/references/**` render all three rows and are regenerated in this change. + - **Positive control that ships**: the neighbouring describe `Objects created/updated` — `dist` 4, `json-schema` 2. + - **Negative control that does not ship**: `no exemption by blindness`, a sentence that exists only in `packages/spec/scripts/`, a path outside `files[]` — 0 across every published path, 1 in its own unpublished file. + - **Dark control**: a fabricated needle reads 0 everywhere, so a zero above is a reading rather than a broken instrument. + + One measured refinement worth recording for the next author, since it cuts against the obvious reading of "published output": **`dist/` alone does not discriminate the two prose channels.** JSDoc text and even a `//` line comment ride into the emitted bundles verbatim (`Objects created or updated`, JSDoc-only, reads 4 in `dist/`). What separates the channels is `json-schema/`, which carries describe prose and 0 comment prose. So `dist` presence is necessary and not sufficient evidence that a string reached the governed channel; the `json-schema/` reading is the one that decides it. +- a83dbb6: A package whose `manifest.permissions` carries the ADR-0025 capability grant is now NAMED when the audience-binding reconciler skips it, instead of vanishing; and both halves of the `permissions` key now point at each other in the spec (#18031). + + `permissions` has two incompatible readings and the package registry stores both in the same slot. At the AUTHORING stage `ManifestSchema.permissions` is the capability grant a plugin requests — the legacy flat `string[]`, or the structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). At the ASSEMBLED stage the collection wins and the same key is the ADR-0090 `PermissionSet[]` collection (`AssembledPackageBodySchema`, ADR-0130 D4). `SchemaRegistry.installPackage` records whichever stage its caller handed it. + + - **`collectDeclaredSuggestions` reports the reading it cannot use.** It wants the assembled one. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, so `Array.isArray(manifest.permissions)` was false and the value never entered the loop; every member of the legacy arm is a bare string, so `consider`'s `typeof ps !== 'object'` line dropped all of them. A package declaring the other reading produced no `sys_audience_binding_suggestion` row, no prompt and no log. It now warns once per engine per package and arm, naming which arm it found, what is lost if the author meant permission sets (no admin is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong — the package's own `defineStack({ permissions: [ … ] })`, which is what the assembled body carries. + - **`warn`, not `error`, and deliberately.** Nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reasoning, one step weaker, as the write-refusal report beside it, and the same sink (`SuggestionDeps['logger']`, which declares no `error`). + - **Reported once per engine per package+arm.** The pass runs at boot, after every package-door `permission` publish and on every list call, while a manifest's shape is fixed for as long as that package is installed; an undeduplicated line would repeat on every console page load and be skimmed past. + - **The spec half is declaration text only — no key, export, arm or accept-set moved.** `ManifestSchema.permissions` now says it describes the AUTHORING stage and names the assembled-stage counterpart; the stack collection `permissions` names the manifest-stage grant; and `InstalledPackageSchema.manifest` says it is the authoring STAGE rather than "whatever was stored", pointing at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` for the stage a `defineStack()` host installs. + - ⛔ **The union at the key was NOT widened, and must not be.** Widening a manifest key into a union of both stages is road C of #14242, rejected by name by the maintainer on 2026-09-02 in favour of road B — declare the assembled stage rather than widen the authoring one — because a union at the key makes neither stage checkable (Prime Directive #12). That ruling is why the fix here is a report and a cross-reference rather than a schema change. +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- 8a5240a: docs(spec): the `dashboard.widgets[].chartConfig` liveness row is re-anchored to the current objectui pin — 12 of 14 keys reach the renderer, not 9 (#17385) + + `packages/spec/liveness/dashboard.json` ships inside this package, so its rows are part of what an author reads. The `widgets.children.chartConfig` row was measured on 2026-08-09 against `objectui @230ffd875` and both halves of that reading are now superseded — re-measured by hand against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. + + **The citation moved repos-internally.** `chartConfigPresentation` was lifted out of `plugin-dashboard` into `@object-ui/core`'s `chart-presentation` module, so the old pointer at `packages/plugin-dashboard/src/DatasetWidget.tsx:380-429` — still byte-exact at the commit it names — lands on the re-export block at that range in the pinned tree, while the nine `if`s it describes are in another package. A foreign path is counted and never resolved by `check:liveness`, deliberately, so nothing mechanical could have caught this: only a hand re-measurement does. + + **The count changed.** `xAxis` / `yAxis` / `series` were recorded as unforwarded on the grounds that they are derived from the dataset selection. They are forwarded today: the dataset keeps series MEMBERSHIP and the column each binding reads (`ChartSeries.name` and `ChartAxis.field`, dropped on the way through) while every other key on those objects merges onto the derived binding with the explicit binding winning. `type` and `aria` remain the two keys that do not reach this face. + + Evidence text only — no verdict moves, no schema key changes, and the row still carries no per-key `children`. The per-key drill, the `type` / `aria` dispositions and the authored-versus-derived precedence the protocol does not yet state stay open on #17385. +- c7af6bd: docs(spec): `options.stageOrder` no longer documents a chart type that cannot be built, and says plainly that only `funnel` reads it (#17344) + + `DashboardWidgetOptionsSchema.stageOrder` is an ungated member of the open widget `options` bag, so its one sentence of prose is the whole author-time surface: nothing warns, nothing refuses, and a widget carrying the key renders with the authored order simply absent. That sentence said *"Explicit category order for ordered-sequence charts — `funnel` / `pyramid` stages above all"*, and it was wrong twice over. + + - **`pyramid` is not a widget type.** It was removed from `ChartTypeSchema` as a variant that only ever rendered as `funnel`, and `chart.test.ts` pins that refusal alongside its fallback-only siblings — so the headline example in the option's own documentation could not be authored at all. + - **The plural framing promised more than the renderer delivers.** "ordered-sequence charts" and "stages above all" read as a statement about ordered marks generally. It is not one: `funnel` is the only type whose branch consults the forwarded order, measured against this repo's pinned objectui renderer. + + The corrected JSDoc and `.describe()` name `funnel` only, state outright that no other widget type reads the key, and send the other types to `sortBy` / `sortOrder`, which lower into the dataset query itself. The generated reference page (`content/docs/references/ui/dashboard.mdx`) is regenerated from the new `.describe()`. + + No schema shape changes: `stageOrder` still parses exactly as before, on every widget type. Whether the key should be *gated* to the type that honours it is ADR-0049 enforce-or-remove on an accepted key — a published-surface narrowing, and deliberately not this change; it stays open on #17344 together with the locale-dependent order/colour drop, which lives in the objectui renderer rather than here. +- 80aef80: fix(spec): the date-range preset prescriptions now name a one-day window for the one-day presets (#17014) + + `DATE_RANGE_PRESET_MACRO_WINDOWS` maps each dashboard date-range preset to the `{date-macro}` window a refusal PRESCRIBES to an author who wrote the preset name as a bare filter comparand (`bareDateRangePresetComparandMessage`). Two of its thirteen entries prescribed a window wider than the preset they name — a filter that parses, runs and returns rows over the wrong range, with no second error to correct against. + + - **`yesterday`** was `['{yesterday}', '{today}']` — an end naming the day AFTER the window. The pair is written for `$between`, which is `$gte min` and `$lte max`, and a bare-day upper bound means "through that whole day", compiled half-open to `< nextUtcCalendarDay(max)` (ADR-0053 D-D). So the prescription resolved to `>= yesterday 00:00 AND < tomorrow 00:00`: yesterday **and** today. It is now `['{yesterday}', '{yesterday}']`. + - **`today`** was `['{today}', null]`, the open `$gte`-only arm, so the prescribed filter had no upper bound at all and also selected every day after today on a column carrying future dates. It is now `['{today}', '{today}']`. + + Both entries now name their own last day, matching the convention the other eight closed entries already used and matching both executable mappings — objectui's `PRESET_RANGES` and `@objectstack/core`'s analytics date-range resolver, which independently spell `today` and `yesterday` as one-day windows. + + The convention that decides an end token was nowhere written down, which is what let one table carry two readings. It is now stated as a rule on the table: **`start` names the window's first calendar day and `end` names its last, inclusive — never the day the window stops before**, and `end: null` is the open arm reserved for exactly the three rolling `last_N_days` windows. Tests pin the resolved extent of every window against a frozen reference day and require a stated extent for every declared preset, so a preset added later cannot silently pick the other reading. + + No schema, type or export changes: the refused shapes and the vocabulary are exactly as before, and only the window text a refusal quotes back moves. +- 65ad77d: fix(spec): the driver-config registry refuses an off-vocabulary id instead of answering with a truthy non-schema + + `DRIVER_CONFIG_JSON_SCHEMAS`, `DRIVER_ID_ALIASES` and `DATABASE_DRIVER_ALIASES` + are plain object literals, so all three inherit `Object.prototype`, and every + lookup into them was a bare index. Measured against the built artifact + (`dist/data/index.mjs`) on the repo's Node 22 baseline (v22.22.2), an id that + names an inherited member resolved that member and was handed onward as if it + were a driver: + + | call | before | after | + |:--|:--|:--| + | `getDriverConfigJsonSchemaById('memory')` | the JSON Schema | the JSON Schema — unmoved | + | `getDriverConfigJsonSchemaById('constructor')` | `{}` — an EMPTY JSON Schema that accepts every config | `TypeError` naming the id and the legal vocabulary | + | `getDriverConfigJsonSchemaById('toString')` | `'[object Object]'` — a **string**, where the signature promises an object | `TypeError` | + | `getDriverConfigJsonSchemaById('valueOf')` | the registry object itself | `TypeError` | + | `getDriverConfigJsonSchemaById('__proto__')` | `TypeError: … is not a function` | `TypeError`, now naming the id | + | `getDriverConfigJsonSchemaById('nope')` | `TypeError: … is not a function` | `TypeError`, now naming the id | + | `resolveDriverId('constructor')` | the `Object` **function** — truthy, not a driver id | `undefined` | + | `resolveDriverId('__proto__')` | `Object.prototype` — a truthy object | `undefined` | + | `resolveDatabaseDriverId('constructor')` | the `Object` **function** | `undefined` | + | `driverHasLocalDefault('constructor')` | `undefined`, out of a function declared `boolean` | `true`, as its doc promises for an unknown id | + | `resolveDriverId('pg')` / `resolveDriverId(' PostgreSQL ')` | `'postgres'` | `'postgres'` — unmoved | + + `getDriverConfigJsonSchemaById` handing back `{}` is the worst of these: an + empty JSON Schema validates anything, so a Studio connection form or a + `DriverDefinitionSchema.configSchema` consumer that asked "what shape must this + config have" was told "any shape at all" and reported success. + + The resolvers' half is reachable without a plain-JS consumer. The CLI refuses an + unclaimed operator selection with `if (driverType && !kind)` after calling + `resolveDatabaseDriverId`, so `OS_DATABASE_DRIVER=constructor` produced a truthy + `kind` that is not a driver id and walked past the refusal. + + All three lookups now go through an `Object.prototype.hasOwnProperty.call` check. + This narrows and widens nothing: every legal spelling is an own key of its table, + so no value accepted before is refused now, and only answers that were never + inside the declared return types move. The declared signatures are unchanged — + `getDriverConfigJsonSchemaById` stays `(id: BuiltinDriverId) => Record` + and both resolvers stay `(driver: unknown) => BuiltinDriverId | undefined`. + + A null-prototype table was the other available shape and was measured rather than + assumed: a `__proto__: null` object literal does not type-check against the + `Readonly>` annotation at all (TS2353), and the + `Object.assign(Object.create(null), …)` spelling that does compile silently costs + that annotation — a table missing a driver stopped failing to compile (TS2741). +- a54ecaa: feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) + + + + **BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). + + ## What was wrong + + Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: + + | filter | before | after | + |:--|:--|:--| + | `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | + | `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | + + What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. + + ## What it does now + + - **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. + - **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. + - **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. + - **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. + - **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. + + ## Deliberately unjudged + + - **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. + - **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. + - **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. + - **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. + + ## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` + + `@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. + + It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: + + | stored surface | how it reaches the door | + |:--|:--| + | `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | + | `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | + | `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | + | dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | + + **Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. + + No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | + | `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | + | `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | + | `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | + | `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | +- 44c917a: The error-code ledger's TSDoc stops naming a retired verdict as a live mechanism, and states the published-face rule it is actually held to. + + `packages/spec` ships `src/**/*.zod.ts`, so `api/error-code-ledger.zod.ts`'s header is published prose — a consumer reads these sentences out of the tarball. Two of them stopped being true when `check-dispatcher-error-vocabulary`'s face refusal widened from `packages/spec/src/**` to every published package's `src/` and the dispatcher vocabulary's `boot-refusal` verdict retired with it (#16649). + + The first said the `boot-refusal` verdict **records** reachability for codes not yet registered, and pointed at the module the verdict was being deleted from. That is a claim about where a live mechanism lives, not about a case that can no longer arise, so a reader following the pointer would have found nothing. It now records the retirement and names what replaced it: a `door: 'none'` code has no resting place short of a row in the ledger. + + The second opened `packages/spec/src/** is held to this mechanically`. True before the widening and an understatement after it — a reader would conclude only the spec tree is guarded, which is the "guarded a part" / "guarded it" confusion this whole class of gate exists to remove. It now states the published face, the stricter spec sub-face where `pending-registration` has no allowance, and the named, dated allowance outside it owed to #8846, with both finding kinds named. + + No schema, accept set, default or refusal moves. `ERROR_CODE_LEDGER` holds the same members before and after, and the generated reference page is regenerated from this prose rather than hand-edited. +- 613d35a: The reference-docs renderer now refuses an `@example CAPTION` with no code block beneath it, + instead of publishing an orphaned caption. + + `@example CAPTION` is declared to be *the caption of the fence beneath it*, and the renderer + acts on that reading: it promotes the tag into a bold lead-in on the assumption that a fence + follows. Nothing asserted that one did. When a module header captioned a listing and wrote its + rows as bare prose, the promotion still fired and the rows below collapsed into a single run-on + paragraph — consecutive non-blank lines are one markdown paragraph, and the docs site loads no + `remark-breaks`. Two customer-facing reference pages shipped that way. + + The assumption is now a precondition the generator checks before it emits anything. A module + description whose caption has no block under it fails the docs build with a message naming the + caption and the source-side fix, the way the renderer already refuses a heading it cannot + renumber. Deliberately a refusal in the generator rather than a separate gate: it makes the + wrong page impossible instead of detecting it afterwards, and it is scoped to the population + the renderer actually renders — module doc blocks — rather than to every `@example` line in the + package. + + ⛔ The check never asks whether a run of prose is "really" a table. Shape-sniffing is exactly + what this renderer refuses to do, and what an author writes instead of a fence is not knowable + from the text. It asks only the question the contract already states: is there a block beneath + the caption? An author who wants those words as ordinary prose writes them without the tag. + + Both code kinds satisfy it. An indented block reaches the page as a fence — the render loop + re-emits it as one — so a caption above one captions a fence by the time a reader sees it. All + twelve captions in the corpus are fenced today and are unaffected; no schema behavior changes. +- 0ee32ed: fix(spec): `FieldSchema` no longer prescribes `required` for `notNull` / `not_null` — the flattened column-constraint spellings now name `storage: { notNull: true }` (#16867) + + Writing `notNull: true` (or `not_null: true`) on a field was refused — correctly — and then told to write `required` instead, via a rename row in `FieldSchema`'s alias table. `required` is the one key ADR-0113 exists to say is **not** the column constraint. `required`'s own description in the same file states the opposite of what the rename prescribed: *"NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`)"*. + + The failure mode was not the refusal — that fired, loudly, and did its job. It was the **remedy**: an author reaching for a NOT NULL column complied, wrote `required: true`, and received a nullable column plus a write-time gate, with nothing downstream to refuse it. The refusal read as though it had been satisfied. + + All three flattened spellings — `notNull`, `not_null`, and `storageNotNull`, which already carried the correct sentence — now get one prescription naming the real key: + + > physical column constraints live under `storage` — write `storage: { notNull: true }` (ADR-0113). There is no flat spelling of it: post-17 a column is NOT NULL because its author wrote that nested key, and for no other reason. It is NOT `required`, which is the WRITE contract (an insert must provide a value; an update may not null it out) and deliberately does NOT imply the column constraint — `required: true` alone leaves the column nullable. Write whichever of the two you meant, or both. + + Both halves are named on purpose: the defect being repaired is that the author cannot tell which of the two axes they are getting, so a prescription naming only the column half would have fixed the measured direction and opened the mirror-image one. + + **No accepted key moves.** `notNull` and `not_null` were refused before this change and are refused after it — a `guidance` / `guidanceSets` table decorates a rejection and never admits a key. Only the sentence attached to the refusal changed. `storage: { notNull: true }` parsed before and parses now; `isRequired` and `mandatory` are genuine spellings of the write contract, ADR-0113 moved neither, and both still rename onto `required`. + + One mechanical note for anyone repairing a table like this: the entry moved from `aliases` to `guidanceSets`, not to exact `guidance`. `aliases` is indexed by `aliasProbe` (case- and separator-folded, so one row covered `not_null` too) while exact `guidance` is matched case-sensitively on the authored spelling — a lone `guidance.notNull` row would have quietly dropped `not_null` onto the edit-distance fallback. The two spellings are pinned separately for exactly that reason. +- 58b36fa: fix(spec): project a union branch-by-branch, so five filter operators reach a published reference page + + `z.toJSONSchema()` refuses a whole schema the moment ONE node in it has no JSON + form, and `build-schemas.ts` applied that refusal per SCHEMA. `orderingComparandSchema` + is `z.union([z.number(), z.date(), z.string(), FieldReferenceSchema])`, so four + `data/filter.zod.ts` exports emitted nothing at all — and `$gt`, `$gte`, `$lt`, + `$lte` and `$between` reached no reference row. Not a blank Description cell: no + section. The ~2000 characters of `.describe()` on those slots — the #5685 comparand + contract, the #6571 endpoint contract, and the `{ "$gte": "2026-01-01" }` shape the + platform's own date-macro resolver produces — reached no reader. + + The generator now makes a third attempt when both strict directions refuse: it + projects with Zod's `unrepresentable: 'any'`, marks every node that came back with + no structural keyword, and DROPS the marked ones that are direct members of an + `anyOf` / `oneOf`. That is not a narrowing. These artifacts describe JSON + documents, a JSON document cannot carry a `Date` INSTANCE, so the set of JSON + documents that union accepts is unchanged by the drop. + + ⛔ A marked node anywhere else — an object property, a record value, an array item + — refuses the projection and the export is skipped with the message Zod threw, so + this cannot change WHY anything is skipped. Five exports leave + `unemitted-schemas.baseline.json` (23 → 18): the four filter exports, plus + `data/Hook`, whose only unprojectable member was the deprecated inline-function + handler branch — that puts 22 `data/Hook:` authorable keys under the key ratchet + for the first time. + + Published artifacts gain `json-schema/data/{ComparisonOperator,FieldOperators, + NormalizedFilter,RangeOperator,Hook}.json`, each carrying an + `x-unprojectable-branches` record naming exactly which branch the projection + dropped and where. +- d127f9b: `i18n.zod.ts` stops asserting a stale size for the inline-locale-map population. + + Two docblocks in this file each stated that the repo authors 31 inline locale maps — the + `INLINE_LOCALE_KEY` rationale ("Every inline map authored in this repo (31 of them, across + three platform pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no real + authoring surface") and the `I18nLabelSchema` form-2 note ("Three published platform pages + author 31 of these"). The measured population is 45: 33 in `sys-user.page.ts`, 6 in + `sys-organization.page.ts`, 6 in `sys-position.page.ts`. + + The number is **dropped** at both sites rather than corrected to 45. Neither sentence's + argument needs a magnitude. The first turns on the universal — *every* authored map uses those + four tags — so the accept set is what makes the constraint free, not the size of the set. The + second turns on the map being authored on published platform pages *and* resolved by + `pickLocalized`; one authored-and-resolved map already refutes "a convention the runtime + ignores", so the count was never load-bearing there either. Writing 45 would buy one release of + accuracy in prose that is cited as evidence for a schema constraint, and the figure has already + drifted once with nothing noticing; deriving it would mean a permanent gate whose only job is + keeping a number in a comment true. + + The measured half survives untouched at both sites: three platform pages author these maps, and + that is still exactly three. No schema arm, bound, default, `.describe()` string or export + changes; nothing an author can write is affected. +- c17b494: `id_field` now gets a named answer instead of a bare refusal: `FIELD_KEY_GUIDANCE` declares it a retirement with **no successor**, which is the spec-side fact objectui's ingestion choke point needs before it can canonicalise the key (objectui#7650 ruling A — retired spellings are folded once, at ingestion, never at the consumer). + + The direction was a factual finding, not a preference, and it went the way the cheaper branch happens to point — so here is the evidence rather than the verdict alone. A lookup stores the referenced record's id, and which field holds that value is not an authored per-field choice: the picker resolves record identity itself. Nothing on `FieldSchema` names it, nothing in `objectql` / `runtime` / `metadata-protocol` reads a per-field id key, and the two places the platform does let a reference be stored by something other than an id are declared elsewhere — `APPROVER_VALUE_BINDINGS.valueField` (per approver type, e.g. `position` routing by `sys_position.name`) and a seed dataset's `externalId`, the channel lookup references already resolve through. So there is no member to fold onto, and the prescription says what to reach for instead: `displayField` for the candidate's label, a dataset `externalId` for a portable natural key. + + **The entry is keyed `id_field`, in snake_case, and that is deliberate.** The two channels this table feeds disagree about the key face. A `to` becomes a `strictObject` alias, matched through `aliasProbe` — case folded, separators stripped — so one camelCase row covers every spelling. A `why` becomes strict guidance, matched exactly and case-sensitively on the authored spelling. A camelCase row would therefore never be reached by the key authors write, and every existing test in the file would still pass, because none of them asks whether an entry is ever consulted. + + That gap is closed too. Three assertions read the channel that actually answers an authored field key — `FieldSchema.safeParse`, since the schema is strict and the authoring-key walker stays silent on a strict surface by its own posture rule — and pin that the refusal carries this table's sentence verbatim, that a retirement suppresses the rename channel, and that the same-named `idField` on the `inlineColumns` GridColumn mirror is a different schema that stays live. +- d414e2b: Scope the text-operator declared-type door's `formula` prose to the judgement it + actually states. The module declared that a `formula` with a readable + `returnType` is judged as the field type its return type names, but at the + door's only consumer — the engine's field-aware seam — a filter over a formula + field never arrives: the earlier materializability door refuses every one of + them with `INVALID_FIELD` 400, whatever the `returnType`. The verdict function, + its sets, the class table and every case are unchanged; only the prose now says + the formula rows are a contract answer no consumer currently reaches, and why + they are kept rather than retired. +- af98a04: `ManifestSchema.version`'s TSDoc no longer documents an `@example` its own regex refuses + + The key documented two examples and accepted only one: + + ``` + @example "1.0.0" -> /^\d+\.\d+\.\d+$/ accepts + @example "2.1.0-beta.1" -> /^\d+\.\d+\.\d+$/ REFUSES + ``` + + An author who copied the second example verbatim got a `ZodError` out of + `ManifestSchema.parse`. The prerelease example is corrected to `"2.1.0"`, a + value the regex accepts. + + **Nothing published moves except the comment.** The regex, the + `.describe('Package version (semantic versioning)')` string and the prose + `(major.minor.patch)` are byte-identical; no accept set, authorable key or + runtime behaviour changes. `@objectstack/spec` ships `src/**/*.zod.ts` in its + `files[]`, so this TSDoc line is itself published — which is why it carries a + changeset rather than `skip-changeset`. + + **The refusal was already the settled reading, which is why this is a comment + fix and not a schema change.** Three artifacts agreed before this change and + still agree: the regex, the prose `(major.minor.patch)`, and + `manifest.test.ts`, which pins `'1.0.0-beta'` in `invalidVersions` on purpose. + Only the `@example` line dissented, so it was the artifact in error. Widening + the accept set to admit prerelease or build metadata would contradict that pin + and is deliberately NOT done here. + + `PluginSchema.version` accepts a different grammar today; the two keys are + deliberately different and are not reconciled by this change. +- 43cbe14: Correct the `search-fields.ts` module docblock's ENGINE bullet: the `$search` expansion is not closed over the resolved set, and its clauses are not all `$icontains`. + + The bullet claimed `expandSearchToFilter` expands a `$search` term into a `$or` of `$icontains` clauses "over exactly this set". Since the pinyin-recall companion column landed, an object whose deployment provisioned the hidden `__search` companion gets one additional clause per latin term on that companion — a field `resolveSearchFields` never returns and no `$searchFields` override can name, so it sits outside the set the sentence called exact. That one clause is `$contains`, deliberately: the companion is already lowercase on both sides, so a case-sensitive operator over two folded values is exact rather than a case bug, and the engine carries an explicit instruction at the site not to align the two operators. The docblock now states both facts and cites that instruction, so a reader does not "repair" the deliberate split. + + Documentation only — no behaviour, schema or exported surface changes. +- c4d1759: docs(spec): record which axis the list-view calendar guard gates — and which it does not (#16577) + + `checkListViewCalendarVisualization` gates ONE way of asking for a calendar: `appearance.allowedVisualizations` includes `'calendar'`. A view can also ask for one by BEING one — `type: 'calendar'` — and that axis parses CLEAN at all three doors (`ListViewSchema`, `ObjectListViewSchema`, `VIEW_METADATA_MEMBERS.listOverlay`). The disposition was correct but undocumented, so it read as an oversight rather than a decision. + + **No behaviour changes.** Every parse verdict at every door is byte-identical before and after; the diff is a TSDoc block on the exported check (which ships in `dist/*.d.ts` and in `src/**/*.zod.ts`) plus pins in `view.test.ts`. + + What the docblock now records, all of it measured rather than inferred: + + - The `type:` axis is **not unwatched**. It is carried by `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` (`kernel/functional-completeness.ts`) at **warning** severity, under the same ADR-0078 §1 rubric this file's `page` note already cites — refuse what renders NOTHING, warn what degrades. The two doors have complementary coverage: the completeness check reads `type` only and is blind to `allowedVisualizations`; this check reads `allowedVisualizations` only and is blind to `type`. + - `viewType` is **not** a second spelling of `type`. The two authoring doors refuse it as an unknown key; the `.strip()`ed overlay write door (`PUT /api/v1/meta/view`) DROPS it, so the view parses as the defaulted `type: 'grid'` — an author who spells it reaches a grid, never a calendar. + + ⛔ Escalating the `type:` axis to a parse refusal is deliberately NOT done here: it would refuse a shape 17.3.0 accepts, which is a published-surface narrowing and belongs to a ruling — the same disposition the `timeline` scope pin has stated since #13817. +- f7a9740: The lookup-picker "who reads this" claims in `packages/spec` are re-measured against objectui and dated to the commit they were measured on. No schema, accept set, default or refusal moves — this is evidence prose, and every verdict it sits under is unchanged. + + Three claims had gone false, all in the same direction: they credited objectui's picker with reading a `snake_case` alias that objectui no longer reads. A stale *tolerance* claim fails in the dangerous direction — it tells an author a spelling is accepted downstream when it is not, so a value that will silently arrive as nothing looks supported by the spec's own prose. + + - **`liveness/field.json`, both `displayField` notes.** `/props/displayField` claimed the record picker "reads displayField || display_field"; `/props/inlineColumns/children/displayField` named the `snake_case` spelling flatly as *the* key the grid's lookup cells pass. objectui deleted that twin from `LookupFieldMetadata` with no deprecation window and no dual read. Both notes now name the read chain they actually have — `LookupField.tsx`'s `fieldMeta?.displayField || fieldMeta?.reference_field || 'name'`, and `GridField.tsx` handing the column's camelCase `displayField` straight through at all three lookup-cell call sites. Both entries stay `status: "live"`: `displayField` is live, and more exclusively so than the notes claimed. + - **`src/data/field.zod.ts`, the LOOKUP PICKER (forward) docblock.** It told authors that objectui's `LookupField` / `RecordPickerDialog` / `deriveLookupColumns` read "both these camelCase keys and their snake_case aliases" — a blanket claim over all seven keys declared beneath it. Measured, it holds for three: `lookupColumns`, `lookupPageSize` and `allowCreate` are each read as ` ?? `. The other four — `displayField`, `descriptionField`, `lookupFilters` and `dependsOn` — are read camelCase-only. The docblock now states that per key, keeps saying the truth for the three aliases that survive, and records that those three are objectui's own back-compat rather than a spelling this schema declares. + - **`liveness/field.json`, the `valueDomain` `evidence` string.** It described the shared membership predicate as one "the write path **will** call" while its own first clause already quotes the landed call site that calls it. Tense corrected; the pointer is unchanged. + + Each rewritten claim now names the objectui commit it is dated to, so a later reader can tell how old the evidence is instead of assuming it is current. That dating is prose by design: a gate over a pinned foreign tree would go stale at every pin bump and need its own anti-vacuity self-test, which is a worse trade than a dated sentence. +- 5f9f846: fix(spec): the one-app-per-package refusal cites the record it means, `ADR-0019 (app-as-consumer-unit) D3` + + `ADR-0019` names **two** records in this repository — `0019-app-as-consumer-unit` (D3 = a `type: 'app'` package defines at most one app) and `0019-approval-as-flow-node` (D3 = deprecating `ApprovalProcessSchema`). Both have a D3, and `stack.zod.ts` cited the bare number for both, so an author following the refusal's own citation was as likely to reach the wrong decision record as the right one. + + The three citations of the app-cap rule now name the record: + + - the `STACK_SINGLE_APP_VIOLATION` message — the only one an app author ever sees; + - the `validateSingleApp` docblock; + - the `StackSingleAppViolationError` docblock. + + Only the message tail changed: `An 'app' package must define at most one app, but found N (…)` is untouched, so any consumer matching on that prefix is unaffected. The rule, the refusal's condition and `defineStack`'s behaviour are unchanged. + + The approvals-side citations are deliberately left bare — repo-wide ADR-number disambiguation is tracked separately. +- 5bf2330: Correct the `permissions` alias table's justification for `hosts`, and pin the two aliases nothing measured. + + `PluginPermissionsSchema` (`kernel/manifest.zod.ts`) curates three aliases — `filesystem` and `paths` point at `fs`, `hosts` points at `network`. The block's only comment said edit distance cannot reach any of them, and it sat directly above all three. That is true of the two `fs` entries and false of `hosts`. + + The fallback budget is `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), so a 5-character key gets 2, and `hosts` differs from the declared `hooks` by exactly 2. Measured against the real `findClosestMatches` with the alias table out of the picture: `filesystem` and `paths` return nothing, `hosts` returns `hooks`. So without the alias an author writing `hosts` is answered ``Did you mean `hosts` → `hooks`?`` — pointed at lifecycle hooks on the one block that also grants network access. + + The alias is therefore better justified than the comment claimed: it overrules a confident wrong suggestion rather than filling a silent gap. Only the justification moves — the alias stays, the declared keys, the strictness and the union are untouched, and no message an author reads changes. + + `hosts` is also the only one of the three whose absence would be invisible, since it is the only one that changes a live suggestion, so `manifest-unknown-keys.test.ts` now pins both it and `paths` alongside the `filesystem` pin that was already there, asserting the offending key and the rename — and, for `hosts`, that `hooks` is not what comes back. +- d9e1587: `PluginSchema.version` now describes the grammar it actually enforces instead of calling itself `"Semantic Version"`. + + The key's regex accepts **every** SemVer 2.0.0-valid string and, additionally, eight strings SemVer 2.0.0 forbids: + + | SemVer 2.0.0 rule | Strings this key accepts anyway | + |---|---| + | §2 — numeric identifiers MUST NOT include leading zeroes | `01.1.1`, `1.01.1`, `1.1.01` | + | §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes | `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` | + | §10 — build-metadata identifiers MUST NOT be empty | `1.0.0+.` | + + **No accepted value moved, in either direction.** The regex is byte-for-byte what it was; the `describe()` string is what changed. The leading-zero half is older than the recent widening — the original `/^\d+\.\d+\.\d+$/` admitted `01.1.1` too, because `\d+` always has — so tightening the key to the official SemVer regex would refuse plugin objects that load today, which the ruling on this key forbids. With the accept set frozen, the only side of the declared/enforced pair still free to move is the claim, and the bare `"Semantic Version"` was the false half: it named a standard this key does not implement. + + The replacement states the shape an author can predict a verdict from — `major.minor.patch` with an optional `-prerelease` and an optional `+build` suffix — and disclaims the standard it exceeds rather than merely dropping the word. This follows `ManifestSchema.version`, which already spells `(major.minor.patch)` explicitly rather than leaning on "SemVer". + + **What consumers see.** The `description` on `version` in the shipped `json-schema/` tree and on the generated `kernel/plugin` reference page. No `pattern`, no `type`, no accepted or rejected value changes, so a tool that validates against this schema behaves identically. + + All eight forms are now pinned as **accepted** — in `packages/spec` (`plugin.test.ts`) and in `packages/core` (`plugin-loader.test.ts`, `plugin-contract-enforcement.test.ts`) — so the honesty is enforced rather than narrated, and a future edit that "corrects" the grammar to be standards-compliant fails those pins on purpose. + + `@objectstack/core` is deliberately **not** listed above. Its `PluginLoader` predicate was renamed `isValidSemanticVersion` to `isSemverShapedVersion` in the same change, for the same reason, but the symbol is `private` and package-internal: measured against the built `dist/index.d.ts`, `import { isValidSemanticVersion } from '@objectstack/core'` is TS2305 (no exported member) and `loader.isValidSemanticVersion` is TS2341 (private), while a public member on the same class compiles. Nothing published moves. +- 143c715: fix(spec): the `protection` block's unknown-key refusal now names the surface, lists the declared keys and suggests the rename (#16845) + + `ProtectionSchema` (`shared/protection.zod.ts`) was a bare `z.object({ … }).strict()` with **no error map**, so an unknown key inside a `protection:` block was refused with zod's own default text and nothing else: + + ``` + AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } }) + ✗ protection: Unrecognized key: "lockk" + ``` + + `lockk` is one keystroke from the declared `lock`, and the author — human or AI, whose whole correction loop is the error text — was told the key was wrong and given no surface name, no declared-key list and no rename. The block is mounted on very nearly every authorable metadata type in the platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools, skills), so that was the message everywhere a protection key was misspelled. + + It is now built with the `strictObject` helper — the same conversion #16328 made for the manifest `permissions` block — and answers: + + ``` + ✗ protection: Unrecognized key(s) on the `protection` block of this metadata item: `lockk`. + Did you mean `lockk` → `lock`? … The declared keys are `lock`, `reason` and `docsUrl`. + ``` + + Curated alongside it: prose-slot aliases (`description` / `message` / `explanation` / `lockReason` → `reason`), documentation-link aliases (`docs` / `link` / `url` / `href` / `helpUrl` / `documentationUrl` → `docsUrl`), a wrong-layer prescription for the field-level `readonly` / `readOnly` booleans (which map to a `lock` *policy*, not a boolean), and one prescription for the whole private `_lock*` envelope family. Two of those aliases correct a measurably **wrong** answer: the edit-distance fallback used to point `docs` and `link` — each two edits from `lock` — at the lock policy rather than at `docsUrl`. + + **Not a breaking change: the accept set does not move.** `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a zod error map is consulted only for an issue already being raised, so it can neither admit a value that was rejected nor reject one that was accepted. Measured rather than argued — the same parse probe across the declared key set, every accepted input, and every rejection's issue `code` reads byte-identical before and after. +- 396eae3: docs(spec): state the retired `allowRestore` / `allowPurge` parse-time accept set exactly (#17425) + + Documentation only — no schema, no key, no exported symbol and no accepted value moves. What changes is what the tombstone's own prose claims about itself, in the three places a consumer reads it: the `permission.zod.ts` docblocks (published in the tarball, both as `dist/*.d.ts` and as the `src/**/*.zod.ts` sources this package ships), and the two hand-written permission docs pages. + + The prose said the retired bits are refused, and separately that "every other value" lands on the tombstone. Read together those two sentences describe a truthy/falsy split, and that is not what the schema does. Measured on this tree, `ObjectPermissionSchema` tolerates exactly ONE value: the boolean literal `false` the published 17.x toolchain materialized into every permission entry of every artifact it built, accepted as inert residue and silently stripped under the retired-defaulted-key class rule. Every other value of any type — including the string `"false"`, the number `0` and `null` — is refused exactly like `true`, with `code: 'invalid_type'`, `expected: 'never'` and the same guidance string, at the key's own path. + + The consequence consumers were missing is now stated with it: a successfully parsed permission entry can carry neither key on any input that came from JSON, so a post-parse guard against either bit is dead code — presence, truthiness and `=== true` alike can never be true on validated data. A `false`-versus-other distinction is observable only to pre-parse tooling reading raw sources, where the retired default is inert legacy residue and any other value is a hard ADR-0049 violation. + + One measured exception is documented and pinned, because it is the only post-parse observation that survives: an in-memory TypeScript input carrying an explicit `undefined` for either key parses and keeps the key as an own property whose value is `undefined`, so a presence check can be true there. JSON cannot spell it, and a serialize round-trip drops it again. + + +- de1a611: `AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. + + The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. + + - **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. + - **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. + - **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. + + `SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. + + The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. + + ⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. + + ⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. +- db76982: `ai/solution-blueprint.zod.ts` publishes its own sentence again, instead of a list of the symbols it happens to export. + + The file always carried a real module header — ADR-0033 §4 plan-first authoring, and how the `apply_blueprint` tool expands each entry into a proper metadata body. But only a blank line separated that header from `const SNAKE_CASE`, and TSDoc's own attachment rule says a block belongs to the declaration it immediately precedes. The header-zone selector reads that rule back, so the header counted as the regex constant's documentation and was disqualified as the module's. Both generators then fell through to their export-list fallback, and the row published into the `objectstack-ai` skill index read: + + ``` + - `…/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, … + ``` + + A true statement about the file that says nothing about its subject — on the one row whose job is to send an agent to this source for exact field shapes. + + `SNAKE_CASE` now carries the one-line doc it always deserved. A comment is not a declaration, so the preamble ends there and the header becomes the module's own block. The published row and the public reference page both open on it: + + ``` + - `…/ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) + ``` + + The selector is untouched. Under its own rule it was deciding correctly, and a census of every source under `packages/spec/src` found this file to be the only one of its kind: 19 shipped `*.zod.ts` sources have a header-zone block sitting against a declaration, and in the other 18 that block genuinely documents the symbol it sits against (`Transport Protocol Enum` against `TransportProtocol`, `Shared history for this file` against `AGENT_HISTORY`). Only here did a module header sit against a constant it says nothing about. + + Neither generator can see this class — each compares its artifact against itself, and each reproduced the selector faithfully, so a generator-only check passes on the defect. A pin now asserts the content of the published row directly. +- ab450f4: docs(spec): `functional-completeness`'s three `objectql/engine.ts` citations name symbols instead of line numbers (#16960) + + The module doc block of `kernel/functional-completeness.ts` cited the runtime that + justifies each rule by line number. All three had rotted: re-measured on `origin/main` + `7ddf13dca` (`engine.ts` is 15,309 lines), the quoted texts live at 8630, 8978 and 921 + against cited 3001, 3191 and 346 — drifts of 5,629, 5,787 and 575. Each quoted text + occurs exactly once in `engine.ts`, so those are readings rather than artefacts. + + The citations are the only limb tying a rule's justification to the runtime that + implements it, and that limb is walked by a human reading it — nothing in the module can + notice the runtime moved. `:3191` was the dangerous one: the line it names today is + ordinary-looking `dispatch:` code, so a reader following it lands somewhere plausible and + never learns they were sent to the wrong place. + + Each now names the enclosing symbol in the repo-root `path#symbol` form + `packages/spec/liveness/field.json` already uses — + `packages/objectql/src/engine.ts#buildSummaryIndex`, `#planFormulaProjection`, + `#expandRelatedRecords` — beside the verbatim snippet. A corrected line number would rot + again on the next refactor; a symbol plus a unique snippet is greppable and survives + movement. The anchor form also moves these three from + `check-spec-docblock-symbol-anchors`' not-judged bucket into resolution (that gate now + reports `3 symbol (3 declaration)` where it reported `0`), so a rename reddens CI. + + Doc text only — no schema, export, type or runtime behaviour changes. It ships because + this block is emitted into the published `dist/kernel/index.d.ts`. +- 025588a: Correct `FieldReferenceSchema`'s first TSDoc `@example`: a `{ $field }` comparand names a column of the SAME row, never a relation path. + + The example spelled its comparand as `{ "$eq": { "$field": "order.owner_id" } }` and captioned it as a join ON clause, while the same docblock's "Execution support" prose states that a dotted path is refused by SQL push-down with `INVALID_FILTER` (HTTP 400). Copied as written it does not fail at the schema door — both spellings parse — so it fails later and quietly: the in-memory evaluator answers `false` for a flat row, and SQL push-down refuses. The ON clause it advertised no longer exists either; `query.joins` was removed and related records are read through `expand`. The example is now the same-table cross-field comparison both execution paths compile, and the docblock header no longer advertises a join surface. `@objectstack/spec` publishes `src/**/*.zod.ts`, so this docblock ships to authors and to IDE hover. +- f3e3d59: Correct `aliases`' documented contract: it is not "only for what edit distance cannot reach". + + `strictObject`'s `aliases` option was documented as a universal in the three places an adopter reads — the module docblock in `shared/strict-object.ts`, the `StrictObjectOptions.aliases` JSDoc an editor shows on hover, and the same JSDoc on the published `strictUnknownKeyError`'s `StrictUnknownKeyErrorOptions.aliases` — all saying aliases are "semantic near-misses edit distance cannot reach". The word *cannot* denies the option's second job. + + The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]`: an alias is consulted **before** the distance fallback and wins outright. So an entry is equally right when distance *does* reach the key and answers with the wrong one — `hosts` is 2 edits from the declared `hooks` against a budget of `Math.max(2, Math.floor(5 / 3))` = 2, so on the plugin `permissions` block the entry is what keeps an author off lifecycle hooks. + + Neither role is rare, and the correction carries its own count rather than the hedge it replaces. Measured over every surface the `strictObject` registry records, 2026-09-11: **1910** alias entries, **1658** unreachable by distance, **252** reachable — 211 where the fallback would have answered identically, and **41** where it answers a different key the entry overrules. + + The failure mode the old sentence produced is precise and has a live carrier: an adopter with a reachable-but-wrong near-miss read "edit distance cannot reach", concluded `aliases` was not the tool for their case, and left the confident wrong suggestion in place. + + Prose only. No alias is added or removed, no schema, key list, strictness or error message changes, and `visibleWhen → visible` (verified still unreachable) stays as the proving case for the gap half. +- bbca441: `translateFlow` overlays screen nodes inside ADR-0031 regions, at any depth + + `translateFlow` (`system/i18n-resolver.ts`) read the flat `flow.nodes` array and + nothing else. But `FlowNode.config` carries ADR-0031 regions — + `loop.config.body`, `parallel.config.branches[].nodes`, + `try_catch.config.try`/`.catch` — each holding a full `nodes` array that nests + arbitrarily, and a `type: 'screen'` node inside one is a real screen: the + executor pauses on it and the client receives its `ScreenSpec.nodeId`. + + So `flows..screens..{title,fields.*}` was authored for such a + node, parsed (the bundle schema is keyed by node id and knows nothing about + depth) and was then silently never applied. The wizard step rendered its + source-locale heading and field labels while its siblings one level up were + translated. + + The descent now runs through `mapFlowNodeList`, a per-flow region-aware + copy-on-write walk shared with the ADR-0087 conversions' `mapFlowNodes`, which + reads `FLOW_REGION_SLOTS_BY_TYPE` — the single declaration of where a region + lives (`automation/region-slots.ts`). This resolver is therefore not a fifth + hand-rolled reader of that table; the fourth pass written against the flat + one-liner is the last one that had to be. + + Reference identity is unchanged and is pinned: a node that resolves nothing + comes back as the same reference, every container `config` and region `nodes` + array on the way down is copied only when a descendant actually changed, and a + flow the bundle does not carry is returned as the same object. + + ⛔ No wiring changed. `translateFlow` is still deliberately absent from + `translateMetadataDocument`'s dispatch table and no liveness row moved — that + decision belongs to the downstream runner card, as its docblock records. +- 7cd5874: docs(spec): the `field.valueDomain` liveness note stops claiming the settings door is "unchanged until then" + + The `valueDomain` row of the published `liveness/field.json` ledger ended on a sentence written + while the re-point was still in the future: + + > The settings door (`service-settings/value-domains.ts`) re-points onto the shared predicate in + > its own follow-up card and is unchanged until then. + + Both halves of the 2026-09-02 ruling have since landed — the settings half (#15434) and the engine + half (#15316) — and the engine half rewrote this note wholesale while carrying that sentence + forward verbatim. "Unchanged until then" therefore described a state that no longer existed: the + door it names had already re-pointed, one commit earlier. + + The sentence now says what is true of that door, read off its source rather than off a PR title: + its second copy of all three definitions is deleted, `firstRejectedDomainMember` asks + `isValueDomainMember` — the same call `record-validator.ts` makes — and what remains on that side + is the door's own business (which declarations it agrees to enforce, how a multi-value carrier is + walked, the fragments the env-override log line needs). A re-added local table reddens + `value-domains.shared-predicate.pin.test.ts`. + + Ledger-note text only. The row's `status` is untouched — it tracks the engine write path, and + `liveness/state-counts.md` is derived by `gen:liveness-counts` from the row states, none of which + move here (`check:liveness` reports the counts file current). +- 7887077: fix(spec): stop advertising `app` as an expression-scope root the shipping renderer mounts (#17203) + + Six prose faces of the UI schemas told an author that a CEL predicate could name `app` — that the shipping renderer mounts it alongside `features` and `os.user`. It does not, and it never contractually did. `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and ADR-0068 has never ruled it; decision batch #67 (2026-09-07) ruled option B — the engine's `SCOPE_ROOTS` is the contract and ObjectUI aligns to it — and ObjectUI shipped that, so `buildExpressionScope` no longer binds `app`. The producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was closed `not_planned` in the same ruling. + + The `app` token is deleted from all six. `features`, `os.user`, `data`, `current_user`, `record` and `user` all stay, in place and in their existing order, and the "renderer behaviour, NOT contract-guaranteed" framing is unchanged: + + - `ui/page.zod.ts` — the "Ambient roots" docblock, and the **published `.describe()`** on `PageComponentSchema.visibleWhen`, which republishes verbatim into `content/docs/references/ui/page.mdx` (regenerated here). + - `ui/action.zod.ts` — the param-level `visible` docblock, and the **action-level `visible`** docblock, which stated the same claim unbackticked (`record/user/app/features`) and was invisible to a probe shaped for the backticked token. + - `ui/component.zod.ts` — the `page:tabs` ambient-root name-resolution example, and its "also mounts the ambient …" sentence. + + Why this was worth correcting rather than leaving to rot: this `.describe()` is the surface an authoring tool and a metadata-generating agent read (ADR-0033 lists AI as a primary consumer), and it was the last place anywhere that could still teach either to write `app.tier == 'pro'`. The resulting predicate does not fail uniformly and is silent both ways — a field `visibleWhen` and a nav / area `visible` fail OPEN (the gate stops hiding), a conditional-formatting `condition` and a row-action `visible` / `disabled` fail CLOSED (the rule silently stops matching). + + No accept set moves: `SCOPE_ROOTS` is untouched, every schema parses exactly what it parsed before, and a predicate naming `app` is accepted and rejected precisely where it was. This narrows what the protocol advertises, and nothing else. A pin test now holds all six faces, published and TSDoc alike. +- 29dd1a6: Correct what `retiredFromLoadPath` declares about its own reach. + + The flag's docs said a retired conversion is "never at load" and that "the load + seam never sets this — only `objectstack migrate meta` (and the fixture CI) + replays it". Neither half held. Three data-at-rest call sites pass + `includeRetired: true` on purpose — `applyConversionsToStoredItem` (which pins + it rather than offering it), flow rehydration in the automation engine, and the + artifact-ingestion door `applyArtifactForwardConversions` — and `migrate meta` + does not reach the option at all: `applyMetaMigrations` looks each step's + conversion up by id and calls `apply` directly. + + What the flag actually governs is the **authoring** surface: it keeps the entry + off `normalizeStackInput`, the single funnel for `defineStack`, `validate`, + `lint`, `compile`, `info` and `doctor`, so a live author meets the tombstone + instead of a silent rewrite. That split is what ADR-0087's + `## Addendum (2026-07-31)` and the artifact-door ruling both bought. + + Documentation only — no behaviour, no schema key and no export moves. The + corrected text ships in `dist/*.d.ts`, and the split it describes is now pinned + by a test that drives `normalizeStackInput` and `applyConversionsToStoredItem` + over the same bytes, so the sentence and the behaviour cannot drift apart again. + + Authors setting this flag on a **default flip** (old and new shapes both legal, + meaning different things) should read the corrected doc: the flag does not + confine such a rewrite to history — the data-at-rest seams still apply it. + ## 17.4.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 180e392f77..ef94bbfff9 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.4.0", + "version": "17.5.0", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index 585a095c55..e926e6d36f 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/trigger-api +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index d1c2d8ccd1..b27d885483 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-api", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Inbound HTTP/webhook flow trigger for ObjectStack — per-flow HMAC-verified endpoints with queue-backed ingestion (ADR-0041)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-record-change/CHANGELOG.md b/packages/triggers/trigger-record-change/CHANGELOG.md index 683fb3781c..3775d55052 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/plugin-trigger-record-change +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index ccfed35ef2..041bc19ad7 100644 --- a/packages/triggers/trigger-record-change/package.json +++ b/packages/triggers/trigger-record-change/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-record-change", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Record-change flow trigger for ObjectStack — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks (ADR-0018)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-schedule/CHANGELOG.md b/packages/triggers/trigger-schedule/CHANGELOG.md index 3efcff004c..d38a97ca8c 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,252 @@ # @objectstack/plugin-trigger-schedule +## 17.5.0 + +### Minor Changes + +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 920006c94e..e14f3dbb86 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-schedule", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Schedule flow trigger for ObjectStack — 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 f17319ea77..fef6ae6cb7 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,298 @@ # @objectstack/types +## 17.5.0 + +### Minor Changes + +- 758ac40: refactor(types): one `isNativeErrorName` reader, so three doors cannot disagree about what a crash is (#17681) + + The predicate that decides whether a sandboxed body's `throw` is a business + REFUSAL (4xx, the author's words relayed) or a CRASH (5xx, the words withheld) + had **three byte-identical copies** — measured, one distinct 74-character regex + literal across three packages: + + | copy | package | its stated reason for being a copy | + |:--|:--|:--| + | `isScriptFaultMessage` | `@objectstack/rest` (`error-response.ts`, #7543) | the original | + | `isScriptCrash` | `@objectstack/objectql` (`hook-withheld-readonly-fault.ts`) | this package must not depend on `@objectstack/rest` for a regex | + | `sandboxRefusalMessage` | `@objectstack/runtime` (`sandbox/quickjs-runner.ts`, #17265) | rest declares one export subpath and re-exports nothing from `error-response` | + + ⭐ **Every reason is a statement about reaching `@objectstack/rest`, and none of + them survives moving the rule.** `@objectstack/types` now owns + `isNativeErrorName` — the name list, the `^` anchor, and the deliberate absence + of a bare `Error:`. All three packages already depend on it and it depends on + none of them, so this fold **adds zero dependency edges** and cannot cycle. + + ⚠️ The hazard was never style. One copy learning a new native error name and the + others not means the same throw is a refusal at one door and a crash at the + next — a crash message **leaked** at one boundary and **withheld** at another. + #16013's argument for extracting exactly this class applies verbatim: the + classification is the part nobody may get wrong, so one *tested* helper is worth + more than N correct copies that must each stay correct forever. + + ⛔ **No behaviour changes at any door, per case.** This is a pure refactor and + the three WRAPPERS are deliberately NOT folded, because they are not the same + shape and merging them would move a door's answer: + + - rest asks a trimmed message and answers a boolean; + - objectql asks **two** slots — `err.name` **or** `err.innerMessage.trim()` — + because a code hook and a sandboxed body carry the native name in different + places; + - runtime asks the trimmed inner message and answers the **message**, not a + boolean. + + What the three share is the predicate, so the predicate is what moved. Each call + site keeps its own slot choice and its own trimming, and `isNativeErrorName` + deliberately does **not** trim for its callers — a contract pinned in its test. + + **Shipped rather than `skip-changeset`**, measured on a real build: all four + packages publish `files[]: ["dist", …]`, and the built `dist` of each carries + the new call — `@objectstack/types` 4 files, `@objectstack/objectql` 4, + `@objectstack/rest` 3, `@objectstack/runtime` 2 — with `looksLikeInternalErrorLeak` + scoring 4 in `types/dist` as the lit control and a nonexistent symbol scoring 0. + The retired copies are gone from the artifacts too: the regex literal scores + **0** in `rest/dist`, `objectql/dist` and `runtime/dist`, and **2** in + `types/dist` (the ESM and CJS bundles). + + `@objectstack/types` takes **minor**: a new export is a purely additive widening + of a published surface, which is at least minor whatever the commit type says. + The three consumers take `patch` — their artifacts change, their behaviour does + not. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- 6e3462d: Host importer: a `link:` / `file:` install is now verified by the LOCATION the app declared, so a correctly linked package loads instead of being refused. + + The ESM fallback finder (`createHostImporter`) verifies the one directory it consults — `/node_modules/` — against what the host's own `package.json` declares. Until now it could only do that by NAME, and a `link:` / `file:` value promises no name, so the KEY stood in for one: a package linked exactly as the app asked, whose own manifest happens to be named something else, was refused with `declared-unresolvable` / `MODULE_NOT_FOUND`. Nothing was broken, and the only way out was to stop using a supported linking mode. + + Such a declaration does name something checkable — a directory — so the finder now checks that too: `realpath(node_modules/)` against `realpath(resolve(hostRoot, ))`, both sides canonicalised, compared exactly (no basename matching, no case folding). If they are the same directory, the host declared it and it loads. + + This is a second verification axis, not a looser first one. A directory the app declared neither by name nor by path is refused exactly as before, and the finder stays strictly tighter than the CommonJS resolution it backs up, which asks neither question. Unchanged: a plain version range licenses no path; an `npm:` alias is still checked by name; `github:` / tarball URLs and the bare `owner/repo` shorthand name no on-disk location, so they gain nothing; a package that publishes a `require` condition never reaches this fallback at all, so no load that succeeds today changes. + + Measured on pnpm 10.33: `link:` symlinks the key at the declared directory and verifies; a `file:` directory install routes through pnpm's virtual store (a copy), so it does not, and keeps today's refusal. The refusal's text now states what the location check compared instead of asserting a limit the finder no longer has. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth was + `seed-tenancy-backfill`'s organization probe, which kept a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes recorded `'unknown error'` there rather than `''`; + that fallback was load-bearing — the site read an empty value as "the probe did not fail" — + and #17167 removed it in this same release, so all fourteen sites now record the channel as + is and that site carries its failure fact structurally. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. + +### Patch Changes + +- 7f62536: A **declared capability absence** — a 5xx answered because the deployment did not install an optional service — is now reported **once per route per process at `warn`**, naming the missing service, instead of one `error` line per request. Every other 5xx keeps the per-request `error` line #14310 shipped. + + Measured before the change, on a stock showcase boot: `GET /api/v1/ai/*` (the cloud-only AI service's declared `501 NOT_IMPLEMENTED`) printed one `error`-level line per request, and Studio opens it unprompted. A deployment that is working exactly as configured was training the channel built to mean "an operator must look" into noise — which is the failure mode `--log-level`-watching operators learn as "skim the errors". + + - **What counts as an absence** is the envelope the door composed: a producer-declared 5xx (`declaresServerFault` — the repo's existing declared-5xx predicate) whose ADR-0112 `code` is `NOT_IMPLEMENTED` or `SERVICE_UNAVAILABLE`. Nothing is invented to recognise one; the code the producer already declared *is* the declaration. + - **The predicate is applied inside the shared funnel** (`logServerFault`, `@objectstack/types`), not at each door, so `sendError`'s nested-envelope exit and the runtime dispatcher read one answer by construction. A door cannot opt in, opt out, or drift. + - **The dedupe key is (route, process).** A restart reports again, and a second, different route reports on its own — deliberately not a global "first N", which is the shape that hides the second route. A door that supplies no route coordinates is demoted to `warn` but never suppressed: an un-keyed bucket is that same hiding shape. + - **A thrown 5xx keeps its `error` line even when it declared `501`.** The thrown exit hands the funnel the throw and no envelope `code`, so it is not recognised as an absence — fail-loud for the half that carries a stack. + + ⛔ **No wire byte moves.** Status, `code`, `message` and body shape are unchanged at both doors; this changes a log level and a count. The response bytes are pinned in `packages/runtime/src/declared-capability-absence-warn-once.test.ts`, and that block runs green on the pre-change tree too, which is what makes it a before/after measurement rather than a claim. + + Operators who were alerting on `[5xx]` at `error` level for an uninstalled optional service will now see one `warn` line per route per process instead. The line says so in its own text: `(declared capability absence — reported once per route per process)`. +- 288fe9c: `createHostImporter` stops prescribing an install repair for a `link:` / `file:` install that is already correct. The refusal is unchanged; only its wording is. + + A host app declaring `{"foo": "link:../bar"}` links `node_modules/foo` to a directory whose manifest may be named anything. `link:`, `file:` and git or tarball URLs name a LOCATION or a remote artefact, never a package, so the specifier carries no name for the ESM-only fallback finder to expect and the KEY stays the expectation — kept deliberately, because widening it would accept any directory sitting at the key and trade a wrong REMEDY for a wrong LOAD. When the linked manifest names something else the finder therefore refuses, and it was reporting that refusal with the `declared-unresolvable` INSTALL wording: run `pnpm install`, check a production prune did not drop it, check the dist was built. Driven on a real symlinked install, all three are measurably false — the finder had just read the manifest at `node_modules/foo`, so the package is on disk, was not pruned, and its `import` target exists. The operator reinstalls, nothing changes, and they go looking for a build that is not broken. + + That sub-case now states what was actually measured: the directory it consulted, the name the manifest there carries, the name it expected, and why a location specifier leaves it with only the key. It says outright that this is neither an install nor a declaration problem, and closes with the remedy that does work — make the two names agree, by declaring the linked package under its own name or by renaming the linked manifest to the key. Both ends are pinned as loading. + + Unchanged: the refusal itself, its `declared-unresolvable` kind, its `MODULE_NOT_FOUND` code and every consumer branch that reads them; the finder's accept set, which is byte-for-byte what it was — a `link:` install whose manifest matches the key still loads silently, and a plain range or an `npm:` alias whose directory holds a different package still gets the INSTALL wording, because there the install really is the fault. The second verification axis that would make these installs LOAD (comparing `realpath(node_modules/)` against the declared location) is deliberately not built here. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [6059b29] +- Updated dependencies [88a072e] +- Updated dependencies [d4a1a28] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [aaacf1d] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [48203ff] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 66e360d19e..f73c29333b 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Shared interfaces describing the ObjectStack Runtime environment", "main": "dist/index.js", diff --git a/packages/verify/CHANGELOG.md b/packages/verify/CHANGELOG.md index 96655ce869..754e49382c 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,340 @@ # @objectstack/verify +## 17.5.0 + +### Minor Changes + +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 611795e: verify: let `bootStack` be told which package `multiTenant: true` resolves, so the + `declared-unresolvable` control can name a subject the workspace can never supply + + `BootOptions` gains an optional `organizationsPackage`. It defaults to + `@objectstack/organizations` and production callers never pass it — the + operator-facing error still names that package literally, because in every + production boot it is the subject. Only the specifier moves. + + Why it exists: a fixture whose whole content is "this host root DECLARED the + package and does not have it" cannot state the second half with a name the + workspace owns. Since ADR-0132 the multi-org runtime is a tracked workspace + package, pnpm's hoisted store carries it, and a `pnpm exec`-launched runner + exports a `NODE_PATH` that reaches that store — so such a fixture resolved the + package out of the ambient workspace the moment it had been built, and its + verdict became a function of an unrelated package's build state rather than of + its own directory. The harness's own host-resolution control now hands in a + `@fixture/*` name and proves the absence instead of assuming it, the repair + already landed for `packages/qa/dogfood` and `packages/types`. +- 6058cb2: **Clause-②: yes** — new exported symbols on a published package (`bootStackOnce`, `isVerifyRefusal`, and ten new members on the `VerifyStack` every `bootStack` caller already holds), so the accept set a consumer writes against widens. Contract-review tier. + + Every `VerifyStack` now carries an **in-process handle** on the stack `bootStack` boots — a way to run a hook, a validation rule, a flow, an action, a seed or a read against the REAL engine and assert on what the engine did, instead of writing through HTTP and inferring from persisted rows, or rebuilding the engine's semantics in a test stand-in. + + New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged): + + - `hooks.run(object, 'insert' | 'update' | 'delete', input, { as })` — one write through the engine's own door as the caller `as` (a bearer token from `signIn` / `signUp`). The bound hook chain, field defaults, declared validations and the SecurityPlugin middleware run inside it, in the engine's order, because this is the very call the REST data ingress makes. Returns what the engine returned; a refusal rejects with the engine's own error (`code`, `statusCode`). + - `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written. + - `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` — the runtime's `/automation` trigger and resume routes driven in-process (no Hono, no socket): the caller's resolved identity is forwarded exactly as the route forwards it, and the engine's `AutomationResult` comes back (plus `flowName`, so the value hands straight to `resume`). A never-dispatched refusal or a failed run rejects with the route's ADR-0112 envelope. + - `actions.run(object, action, { as, recordId?, params? })` — the `/actions/:object/:action` route driven in-process, the one door carrying the whole action contract (ADR-0066 D4 gate, ADR-0104 param contract, subject-record load, trusted body context). Returns the handler's value. + - `seed(object, rows)` / `rows(object, where?, { as? })` — real ObjectQL writes (the platform's own seed-replay context) and reads (system-scoped, or as a caller under that caller's grants and RLS). + - `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary. + - `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`). + - `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller. + + Also new: `bootStackOnce(config, opts?)`, a per-process memo of `bootStack` keyed on the `config` and `opts` object identities — the worker-scoped shared boot `packages/qa/dogfood` kept privately, promoted for suites that run many files under `isolate: false`. + + Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`. + + **Zero re-implemented semantics.** Every method is a thin facade over a door the kernel wired at boot; the handle assembles no `ExecutionContext`, orders no hooks, evaluates no permission. The package's own tests pin each method against the real service behind it (the PR's ablation record breaks each service in turn and shows only that method's pin going red), pin `hooks.run` against the REST write on the same row **and** the same refusal, and port one hotcrm exemplar (`opportunity_lifecycle`) onto `hooks.run` as the proof of ergonomics. + + No boot option was added: the tenancy posture a stack runs under is still chosen by `multiTenant` (the `--multi-tenant` option `os verify` already has) and read back through `tenancy()`. `os verify`, `runCrudVerification` and `runRlsProofs` are unchanged. + +### Patch Changes + +- bdb247d: `@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + + The execution context a seed write must use — `isSystem`, `skipTriggers`, + `seedReplay` — had **no exported form**, so every seeder held a private copy of + it and nothing held the copies equal. There were three on `main`: + `SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), + `SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock + already recorded that it "mirrors" the first) and `SEED_CONTEXT` + (`@objectstack/verify`'s fixture writer, which spelled it a third time + specifically because the runtime kept its copy module-private). + + **Why a shared constant rather than three accurate copies.** `skipTriggers` is + what suppresses "on create" automation for seed rows, and `isSystem` alone does + **not** suppress dispatch. A seed path that lost that flag once seeded with + automation live while the main path had it suppressed — a self-trigger loop that + wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging + defect is a kernel semantic, not a local detail. + + **What is exported, and what deliberately is not.** The **inner** + `ExecutionContext` value, and nothing wrapped around it: + + ```ts + import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + + await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); + ``` + + The `{ context: … }` options bag stays at the call site. It is what all three + sites ultimately hand to `insert`, but it is an options envelope rather than the + posture: its type differs per engine method, so freezing one bag onto the + protocol surface would serve `insert` and no other operation, and it is + precisely the convenience bundle this export is not. + + ⛔ **No behaviour change.** The value is byte-identical to all three previous + copies, the three flags keep their existing meanings, and no seed path changes + what it writes or how. The three former copies now read this export, so the two + option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` + context is the export itself. + + **Additive, so `minor` on `@objectstack/spec`**: one new name on the existing + `./kernel` entry point, no existing export removed, renamed or narrowed. The + three consumers take `patch` — their published `dist` changes (an import edge, + and the constant now resolves through `@objectstack/spec/kernel`) while their + own public surfaces do not move. +- 5741ff1: Comment-only correction: the reason `bootStack`'s cross-tenant proofs stand in for `@objectstack/organizations` is now stated as the true one. + + Those doc comments said the enterprise multi-organization runtime was **cloud-private / not installable in this workspace**. ADR-0132 falsified that: the runtime is open core, Apache-2.0, and published on npm. The effect they describe has not changed, so the text now gives the reason that is actually load-bearing — **ADR-0132's entitlement boundary forbids any framework package DECLARING `@objectstack/organizations`** (`packages/plugins/organizations/src/no-framework-dependents.pin.test.ts`, its mechanical half: "Apps declare it; packages do not"), because the commercial repository ships a licence-gated subclass under the same package name. So `packages/verify` cannot depend on the runtime and cannot resolve it, the `'posture-only'` stand-in stays exactly what it was, and the proof that the real plugin walls tenants still lives in cloud's `security-enterprise` multi-organization integration test. + + ⛔ **No behaviour, no dependency and no public surface moves.** `BootOptions.multiTenant` accepts and does the same things it did; the only shipped bytes that change are the doc comments carried into `dist/index.d.ts`. Apps that mount the runtime keep declaring it in their own `package.json`, which is and remains the supported wiring. +- Updated dependencies [7f62536] +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [3a5eaea] +- Updated dependencies [c8a006f] +- Updated dependencies [fe71032] +- Updated dependencies [482d34d] +- Updated dependencies [e526556] +- Updated dependencies [305e7fc] +- Updated dependencies [216b066] +- Updated dependencies [ee6fbd7] +- Updated dependencies [4f1a56b] +- Updated dependencies [6059b29] +- Updated dependencies [89a652b] +- Updated dependencies [88a072e] +- Updated dependencies [9c577c1] +- Updated dependencies [d4a1a28] +- Updated dependencies [baf9745] +- Updated dependencies [d34f9b6] +- Updated dependencies [1e20f81] +- Updated dependencies [38472ce] +- Updated dependencies [4af758d] +- Updated dependencies [86c5052] +- Updated dependencies [aaacf1d] +- Updated dependencies [6548118] +- Updated dependencies [146c291] +- Updated dependencies [e0e4a56] +- Updated dependencies [7aae005] +- Updated dependencies [bdb247d] +- Updated dependencies [c9246fa] +- Updated dependencies [48203ff] +- Updated dependencies [cea85fd] +- Updated dependencies [ada2869] +- Updated dependencies [d88a47d] +- Updated dependencies [2f1a6f6] +- Updated dependencies [23fc5d6] +- Updated dependencies [2d34f32] +- Updated dependencies [9e3c485] +- Updated dependencies [e1796ad] +- Updated dependencies [1a25f4a] +- Updated dependencies [c9eb773] +- Updated dependencies [4342c99] +- Updated dependencies [132dd13] +- Updated dependencies [dfeba25] +- Updated dependencies [0a88a80] +- Updated dependencies [0252320] +- Updated dependencies [bea41f6] +- Updated dependencies [2eb4724] +- Updated dependencies [e04a0af] +- Updated dependencies [6b97a20] +- Updated dependencies [e7ff9c2] +- Updated dependencies [310760d] +- Updated dependencies [2b6a207] +- Updated dependencies [2b08a72] +- Updated dependencies [758ac40] +- Updated dependencies [a2c2852] +- Updated dependencies [c744c0a] +- Updated dependencies [98bd798] +- Updated dependencies [cbcae14] +- Updated dependencies [8261ff7] +- Updated dependencies [24489f1] +- Updated dependencies [fc28c1d] +- Updated dependencies [6d64785] +- Updated dependencies [00c332b] +- Updated dependencies [134b410] +- Updated dependencies [84e6b05] +- Updated dependencies [a83dbb6] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [40098a4] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [374d9d3] +- Updated dependencies [ea4d164] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [2c87a48] +- Updated dependencies [a36b526] +- Updated dependencies [dd2fd20] +- Updated dependencies [92865f6] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [e66da5c] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a61ae59] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [2266438] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [54e8234] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [d414e2b] +- Updated dependencies [af98a04] +- Updated dependencies [43cbe14] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [45c2cf9] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [9540590] +- Updated dependencies [ae6dcf6] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [e6965dd] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [775e5ec] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [0ced0aa] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [396eae3] +- Updated dependencies [470746a] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [f3e3d59] +- Updated dependencies [9bd4344] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [9c44eed] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [a2509d7] +- Updated dependencies [7887077] +- Updated dependencies [29dd1a6] + - @objectstack/types@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/spec@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/service-automation@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/service-settings@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index b15e9885f6..719abcb5e4 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Boot any ObjectStack app in-process and verify it through the real HTTP stack — auto-derived CRUD round-trip fidelity plus the cross-owner RLS invariant. Catches runtime regressions that static checks miss.", "type": "module",