diff --git a/.changeset/action-confirmation-contract.md b/.changeset/action-confirmation-contract.md deleted file mode 100644 index faabfd5d3c..0000000000 --- a/.changeset/action-confirmation-contract.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`action.ai.requiresConfirmation` gets a real contract — a DECLARATION, not yet a live gate: the contract states that an AI-facing call on an action declaring the flag must carry an explicit confirmation and is refused without it, with a refusal that tells the caller how to retry. No door performs that refusal yet; setting the flag still stops nothing until the runtime half lands. - -The flag has always read as a safety gate and has only ever filled one field of the MCP `list_actions` summary. Two of the spec's own passages went further and told authors it "actually stops execution" through an HITL approval queue — a queue the open framework path does not have (the server-side queue is an ObjectOS layer over these same actions). This change defines the gate the flag was always claimed to be. It is additive and defines the contract only; the doors adopt it separately. - -- **The request member.** `AIActionConfirmation` (`contracts/ai-service.ts`) declares the confirmation as a closed boolean member, and `AI_ACTION_CONFIRMATION_MEMBER` fixes its one spelling so every AI-facing action door and every retrying client read the same constant. It rides at the top level of the action request — deliberately not inside `params`, which is strict by default (`enforceActionParams`, ADR-0104 D2) and would REJECT an undeclared `confirm` outright on any action that declares params, and deliberately not a transport header, which the action door — a plain function handed a request object — cannot carry and the tool schema an agent reads cannot show. -- **Which predicate gates the refusal.** A door refuses when, and only when, the action's author DECLARED `ai.requiresConfirmation: true` and the request does not carry the member as `true`. This is narrower than the predicate behind the `requiresConfirmation` field of a listing, which falls back to a destructiveness heuristic (`mode: 'delete'` / `variant: 'danger'`) when the author declared nothing: that field advises a client to ask, and an author who declared nothing has asked for nothing. Gating the refusal on the heuristic would start refusing calls that work today, on a guess the author never made. The member is accepted on every call and required only on the declared-gated ones, so a client that confirms whenever a listing says `requiresConfirmation: true` is always correct. -- **The refusal.** `ACTION_CONFIRMATION_REQUIRED`, registered in `ERROR_CODE_LEDGER` under `@objectstack/runtime`, answered 428 — the request is valid and merely incomplete, and the identical call with the member set succeeds. `error.details` is `ActionConfirmationRequiredDetails`: the action name, its object, and the exact member to set, so an agent builds the retry mechanically instead of re-parsing prose. It is not a re-spelling of the standard catalog's `PRECONDITION_REQUIRED`, which leaves a caller unable to tell a confirmation gate from a missing conditional header. -- **Two passages corrected.** The `tool.requiresConfirmation` retirement guidance and the ADR-0049 semantic migration entry no longer describe an approval queue. They state what the contract DECLARES — a gate, nothing parked, nothing held for an operator to find later — and say plainly that the door which performs the refusal has not landed, so the flag does not stop an unconfirmed call today. They also no longer tell an author to prove the gate by invoking the operation without the confirmation member: until that door ships, such a call is not refused, it RUNS. - -`list_actions` is unchanged and keeps reporting the flag exactly as it does today. diff --git a/.changeset/action-declarative-update-ledger-live.md b/.changeset/action-declarative-update-ledger-live.md deleted file mode 100644 index 66af161731..0000000000 --- a/.changeset/action-declarative-update-ledger-live.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Liveness ledger: `ActionSchema.operation` and `ActionSchema.patch` re-graded `planned` → `live`, and their author warning dropped. - -Both keys were seeded `planned` with an `authorWarn` whose hint said, in as many words, that "nothing performs the write yet". That premise is gone: the runtime half of the declarative row-level field write is merged, so the ledger now says what the tree does. - -- **`operation` → `live`.** It is the executor's own discriminator (`isDeclarativeUpdateAction`, a bare read of the declared key) and it is consulted *before* `type` at every reader: the REST `/actions` door, the MCP `run_action` door, the headless-invokability predicate, the type-error prescription, and the MCP listing summary. -- **`patch` → `live`.** `declarativeUpdateWrite` reads it as the base of the write bag `{ ...patch, ...params }` — the static values sit *under* the ones the dialog collected — and `executeDeclarativeUpdateAction` hands that bag to a single data-plane update of the routed row, under the caller's own execution context. - -Judged separately and both measured, not inferred: deleting the `operation` read fails 24 of the executor's 27 pins, deleting the `patch` read fails 16 of them, and the unmutated tree passes all 27. - -**What moves for consumers.** `@objectstack/spec` ships `liveness/` in its published files, and `@objectstack/lint` resolves that directory off the installed package to build its author-warning map. Dropping `authorWarn` on these two rows therefore removes a real `os lint` finding: authoring `operation: 'update'` + `patch` no longer draws `liveness-planned-property`. Nothing else moves — no schema, no `.describe()`, no export, no accept-set change. diff --git a/.changeset/action-door-record-load-verdict-consumed.md b/.changeset/action-door-record-load-verdict-consumed.md deleted file mode 100644 index 5f03f1e899..0000000000 --- a/.changeset/action-door-record-load-verdict-consumed.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -An action whose caller-scope record load was DENIED is now refused at every action door, not at one of the three. - -`loadActionSubjectRecord` computes one verdict — `recordLoadDenied` — for every door, and exactly one door consumed it as a refusal: the declarative update. The flow door and the script/body door spread the same verdict into the context as a field and proceeded. So MCP `run_action` on a `type: 'flow'` action answered `ok: true` and started a persisted run for a `recordId` the caller cannot read — and, identically, for an id that names nothing at all — while `get_record` answered "not found" and `update_record` answered "no access" for that same id in the same session. Nothing in the response told the calling agent the row had not been delivered. - -Both remaining doors now consume the verdict, on both surfaces (the REST `/actions` route and the MCP `run_action` bridge), through one shared refusal: - -- **What is refused.** A row-scoped invocation whose caller-scope load was attempted and did not deliver the row. The refusal lands before the automation run is created and before a trusted, RLS/FLS-bypassing action body is entered — not after, which would answer an error with the run already persisted. -- **The envelope is the shared not-found one** — `RECORD_NOT_FOUND`, 404, the same `recordNotFoundError` the read path and the declarative door already answer. Not a 403 and not a new "denied" code: the read path collapses "filtered out by row-level security" and "this id names nothing" on purpose, so answering the two differently would make this door disclose existence where every other door declines to. -- **Record-less and new-record actions are unchanged.** The verdict can only be `true` when a load was actually attempted — a `recordId` was supplied and the action key is not object-less — so an object-less ("global") action and an invocation with no `recordId` never reach the refusal, and both still receive the `recordId` stamp on `ctx.record` exactly as before. The predicate is the load's own verdict, deliberately not the `locations`-derived `requiresRecord` of an action listing, which an author may omit entirely. - -`AutomationContext.recordLoadDenied` and the handler-side `ctx.recordLoadDenied` are untouched and still populated by the same producer; an author guard written against either keeps working. What changed is that the platform no longer depends on that guard being written. diff --git a/.changeset/adr-0113-withdraw-required-notnull-conversion.md b/.changeset/adr-0113-withdraw-required-notnull-conversion.md deleted file mode 100644 index 91886fdc06..0000000000 --- a/.changeset/adr-0113-withdraw-required-notnull-conversion.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/driver-sql": patch ---- - -fix(spec): withdraw the `field-required-notnull-explicit` ADR-0087 conversion — `required: true` no longer stamps `storage.notNull: true` on anybody's fields (#16693) - -ADR-0113 split the pre-17 `required` tri-binding on purpose: `required` is the **write-time contract** and is NOT a column constraint, and `storage.notNull` alone binds the physical column (`sql-driver.ts#createColumn` has keyed off it alone since that ADR's P0). The `field-required-notnull-explicit` conversion asserted exactly the implication the ADR abolished — it added `storage: { notNull: true }` to every field it found `required: true` on — so it is removed from the conversion registry and from protocol 17's ADR-0087 ledger entry. - -**Who this was reaching, and why it was not confined to old artifacts.** The entry carried `retiredFromLoadPath: true` and a docblock stating that "only `os migrate meta --from <16 or lower>` may apply it". That was not true of this tree. The artifact-ingestion door replays the whole chain with `includeRetired: true` (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) and keys the replay off the artifact's declared `engines.protocol` **floor**, not its age — so any artifact declaring `^17.0.0`, which is the range `create-objectstack` stamps, was converted at boot. Measured on this tree at that seam: an artifact declaring `^17.0.0` on a 17.3.0 runtime came back from the door with `storage.notNull: true` written onto a field its author wrote as nullable-and-write-gated, and the boot logged `converted N site(s) forward` with a remedy sentence telling the author to write the same tightening into the source. On a populated database that instruction is a `tighten_not_null` / `severity: error` / `category: destructive` migration — prescribed as the remedy for a deprecation notice. - -**What moves for consumers.** - -- `applyConversions(stack, { includeRetired: true })` — the artifact-ingestion door and `os migrate meta` — no longer emits or applies this rewrite. The default load posture (`includeRetired: false`) is unchanged: the conversion was already skipped there. -- `os migrate meta --from 16` no longer lists it, and a `required: true` field crosses 16 → 17 carrying its write contract and nothing else. -- Boot no longer warns about it, so an artifact whose only conversion was this one now boots with that warning gone. -- Nothing is authored differently and nothing is refused that was accepted before. `required` and `storage.notNull` both remain authorable and both keep their ADR-0113 meanings. A column is NOT NULL because its author wrote `storage: { notNull: true }`, and for no other reason. - -**No migration is owed to anyone** (maintainer ruling, 2026-09-08, decision batch #85, option A). Genuinely pre-ADR-0113 artifacts are not measured to exist, existing columns are left exactly as they are, and an app that wants NOT NULL columns declares `storage.notNull` deliberately — which is what the app that reported this had already done. - -The protocol-17 ledger entry and the generated upgrade guide now say this in the other direction too, and the falsified sentence in `sql-driver.ts` — "sources authored before protocol 17 carry `storage.notNull` explicitly via the `field-required-notnull-explicit` conversion, so their columns come out exactly as they always did" — is corrected where it stood. - -Two sentences in `@objectstack/driver-sql` that this withdrawal falsifies are corrected with it, and no drift behaviour changes. The `relax_not_null` finding — raised when a column is NOT NULL and the metadata declares no `storage` constraint — used to prescribe "(pre-protocol-17 sources: `os migrate meta` stamps it for every previously-required field)"; it now says the constraint has to be declared by its author, because nothing supplies it any more. The comment beside it, which closed with "`os migrate meta` ratifies it whenever the source is next migrated", says so too. The deliberate SILENCE for a `required: true` field whose column is already NOT NULL is unchanged — this corrects the sentences, never the finding. diff --git a/.changeset/advisory-boot-path-aggregation.md b/.changeset/advisory-boot-path-aggregation.md deleted file mode 100644 index 682e37fdeb..0000000000 --- a/.changeset/advisory-boot-path-aggregation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/objectql": minor -"@objectstack/metadata-protocol": minor ---- - -Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. - -A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule. - -Two changes, and neither moves what a rule evaluates to: - -- **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`. -- **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects. - -`error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list. diff --git a/.changeset/aggregate-boolean-members.md b/.changeset/aggregate-boolean-members.md deleted file mode 100644 index 6d1f705324..0000000000 --- a/.changeset/aggregate-boolean-members.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `AGGREGATE_FIELD_TYPE_COMPATIBILITY` accepts `boolean` / `toggle` for `sum` / `avg` / `min` / `max` — ruling #11152 (booleans aggregate as numbers on every backend) stands over batch #59's blanket default (#16685) - -The aggregate × field-type table declared by `@objectstack/spec/data` gains the boolean class (`boolean`, `toggle`) on its four arithmetic / order rows. Two maintainer rulings collided on that class: decision batch #59 (2026-09-06) said "every other pair: refused" without ever naming booleans, while ruling #11152 (2026-08-28) pins that booleans aggregate as NUMBERS on every backend with no per-aggregate exception — `sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, enrolled on six backends by the spec's own `AGGREGATION_CASES`, and implemented by `driver-sql`'s Postgres cast (#11635). The director ruling of decision batch #80 (2026-09-08, #16685, maintainer verbatim 「其他同意」, option A) holds that the specific ruling stands over the blanket default: the four rows carry both boolean members, and nothing else moves — `AGGREGATION_CASES` and the driver cast are untouched. - -| Aggregate | Accepted field types | -|---|---| -| `count`, `count_distinct` | every `FieldType` | -| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary`, **`boolean`, `toggle`** | -| `avg` | the numeric class (`percent` included), **`boolean`, `toggle`** | -| `min`, `max` | the numeric class, `date`, `datetime`, `time`, **`boolean`, `toggle`** | -| every other pair | refused | - -Why it matters: `avg(flag)` is the win-rate / SLA-violation-rate shape (#11065) — the reason the conformance table exists. A compatibility table refusing it would refuse a pair every backend is REQUIRED to answer, and the two consumer legs that execute this table (the compile-time refusal in the dataset compiler, #16099, and the authoring-time lint rule) would have turned a supported measure into an authoring error. - -**Additive, `minor`.** No export is added, removed or renamed — the exported declarations are unchanged (the table's declared type is the same; only its value gains members), the new `BOOLEAN_AGGREGATE_FIELD_TYPES` constant is module-private — absent from `dist/*.d.ts` and from the bundles' export lists — and `api-surface/` and `export-origins/` are untouched — and the accept set only WIDENS: every pair accepted before is still accepted. It rides the same release as the table's own changeset, so the version outcome is the same either way; `minor` is declared because widening a published accept set is the same class of change the table's introduction was (decision batch #35 puts additive widening at `minor`), and because the two consumer legs are the change's real audience: a measure over a boolean field compiles and lints clean. diff --git a/.changeset/aggregate-field-type-compatibility.md b/.changeset/aggregate-field-type-compatibility.md deleted file mode 100644 index f542b78b8b..0000000000 --- a/.changeset/aggregate-field-type-compatibility.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare the aggregate × field-type compatibility matrix a dataset measure is judged against — `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` (#16353, spec half of #16099) - -A dataset measure pairs an `aggregate` with a `field`, and nothing between author and driver correlated the two: `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend, where one SQL family averages the column's storage form and another rejects the call — one metadata document, two answers. Which pairs are accepted is a contract, so it is now declared once in `@objectstack/spec/data`: - -| Aggregate | Accepted field types | -|---|---| -| `count`, `count_distinct` | every `FieldType` | -| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary` — the numeric class EXCEPT `percent` (a rate does not add; `isIncoherentAggregate` already says so) | -| `avg` | the numeric class, `percent` included | -| `min`, `max` | the numeric class plus `date`, `datetime`, `time` — both return a value of the field's own type | -| every other pair | refused | - -The ruling (director, decision batch #59, 2026-09-06) named its buckets by category; the table resolves them against the real `FieldType` membership through the `field-value.zod` semantic classes: "numeric" is `NUMERIC_VALUE_TYPES` (`integer` is a driver-internal column alias, not a `FieldType` — the integer-valued authorable members are `rating` / `slider` / `progress`); "temporal" is the three temporal classes, `time` included because its stored form is a dialect question exactly like `date` / `datetime` (native TIME on Postgres and MySQL, canonical `HH:MM:SS[.fff]` TEXT on SQLite), the canonical form orders chronologically on every dialect, and `AnalyticsResult.fields[].type` already describes `min` / `max` over it as temporal (#15768). `formula` is refused for arithmetic aggregates whatever its declared `returnType`: it is virtual in SQL storage, no column exists to aggregate. - -**Booleans** (`boolean`, `toggle`) are NOT in the "every other pair" default: maintainer ruling #11152 has every backend answer them as numbers (`sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, pinned in the spec's `AGGREGATION_CASES`; `driver-sql` casts the aggregand on Postgres to make it hold), and the director ruling of decision batch #80 (2026-09-08, #16685) holds that specific ruling over batch #59's blanket default — the four arithmetic / order rows carry both boolean members (see that changeset). One refused row overrides an existing opinion and is recorded as such, not presented as agreement: **the string classes** are refused for `min` / `max` here, while `service-analytics` (#15768) already types `min` / `max` over them as a supported `'string'` result; the refusal is defensible (string order is collation-dependent) but it overrides that opinion. - -**The narrowing, stated plainly.** Every pair outside the table — `avg` × `datetime`, `sum` × `text`, `min` × `json`, `sum` × `percent`, and so on — is an authoring shape `DatasetMeasureSchema` accepts today and will be REFUSED once the two consumer legs land: the compile-time refusal in the dataset compiler (#16099) and the authoring-time lint rule (its devx sub-card). A measure whose pair is refused is fixed by changing the aggregate to one the field's type supports (`min` / `max` for a temporal field; `avg` for a `percent`; `count` for anything), never by widening the table. - -**Not breaking in this release, `minor` on purpose.** This changeset ships a table and a predicate that nothing yet enforces: `DatasetMeasureSchema` accepts byte-for-byte what it accepted before, no export is removed or narrowed, and no runtime path reads the table yet. It is an additive widening of the published surface — two new exports in `dist/*.d.ts` — which the maintainer ruling of 2026-09-04 (decision batch #35) puts at `minor`. The refusal itself arrives with the consumer legs, whose changesets carry the breaking declaration, its migration prescription and the ADR-0087 disposition; this one names the narrowing so an upgrading author can read the contract before it is executed. - -`isIncoherentAggregate` is unchanged and stays the semantic opinion beside this table. The two diverge on exactly one pair: `count_distinct` × `percent` is flagged there and accepted here (the ruling reads `count_distinct` as "any type"). That divergence is pinned in the table's test and reported on #16353 rather than resolved silently. diff --git a/.changeset/ai-studio-five-registered-tool-names.md b/.changeset/ai-studio-five-registered-tool-names.md deleted file mode 100644 index 69bd5a7b49..0000000000 --- a/.changeset/ai-studio-five-registered-tool-names.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`PLATFORM_TOOLS_BY_PACKAGE['service-ai-studio']` lists the five tools the cloud AI runtime registers that it had been omitting: `get_authoring_rules`, `load_tools`, `open_record`, `test_flow` and `toggle_flow`. Added in the list's existing alphabetical order; nothing else in the registry moves. - -The omission was not cosmetic. `PLATFORM_PROVIDED_TOOL_NAMES` is the load-bearing half of `skill.tools[]` reference integrity under ADR-0109 — the default third-party authoring path declares no tool records at all, so a `skill.tools[]` entry resolves against this registry or against the materialised `action_` family and against nothing else. While these five were absent, a skill naming any of them was reported by `validate` / `lint` as a **fictional** tool reference (`ai-skill-tool-unresolved`), which is precisely the failure the registry was created to end. Five previously-refused references are now accepted; a name registered by nobody is still refused. - -The module's own maintenance contract already said why an omission is worse than no registry at all — "an out-of-date registry is worse than no registry, because consumers now trust it" — and a second consumer had already paid for it: `@objectstack/mcp` gives a listed name `openWorldHint: false`, and the Studio's tool-step labels read the same set. - -This is the data half only. Making the owning package's conformance test **derive** the union from what `plugin.ts` actually registers, instead of restating it, is tracked separately in the cloud repository; re-copying the list correctly resets the clock rather than stopping it. diff --git a/.changeset/analytics-daterange-closed-vocabulary.md b/.changeset/analytics-daterange-closed-vocabulary.md deleted file mode 100644 index fb541344ec..0000000000 --- a/.changeset/analytics-daterange-closed-vocabulary.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": minor ---- - -feat(spec)!: `timeDimensions[].dateRange`'s string arm closes to the date-range preset vocabulary; any other string is refused with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16041) - - - -**BREAKING** — an accept-set narrowing on a published analytics contract. -`AnalyticsQuerySchema.timeDimensions[].dateRange` (and with it the -`POST /analytics/query` / `/analytics/sql` bodies, `AnalyticsQueryRequestSchema`, -and the `AnalyticsQuery` type every driver and `AnalyticsService.query` caller is -typed against) used to accept ANY string. It now accepts exactly the thirteen -dashboard date-range preset names, derived from `data/date-range-presets.ts` -(`z.enum(DATE_RANGE_PRESETS)` — the vocabulary's single source of truth since -#4614, so the two cannot drift), or the unchanged `[start, end]` array arm. -Shipped as `minor` under the repo's launch-window convention for breaking -changes; the hand-migration prescription is registered under protocol major 18. -Maintainer ruling on #16041 (2026-09-06, decision batch #57, option A — -contract first, 「同意」): 「本项目以协议为基准。所以开发应该对其协议,协议有问题应该立卡修改协议」. - -## What was wrong - -The arm was a bare `z.string()` whose only documented example — `"Last 7 days"`, -in the schema's own comment — was a value no driver could parse. `driver-memory` -recognised exactly `today` and a case-sensitive `last N ` and fell every -other string through to a `[range, range]` pseudo-window that (measured through -mingo, 2026-09-05) matched **every `Date`-typed row**, 2099 included, because a -`Date` compares above a `String` under BSON cross-type ordering. The SQL -strategies read the same bare string as a single ISO day. A dashboard asking for -one week silently got all of history on one backend and one day on the other, -at HTTP 200 on both. - -## What it does now - -- The string arm is `AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS)` - (`today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, - `this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_7_days`, - `last_30_days`, `last_90_days`); the schema example is corrected to - `'last_7_days'`. -- Any other value raises ONE prescriptive issue at `timeDimensions.N.dateRange` - (`analyticsDateRangeRefusalMessage`: the value, the vocabulary, the array - spelling for an explicit window). `@objectstack/spec/data` exports the - structural predicate `isAnalyticsDateRangeRefusalIssue` for doors. -- `POST /analytics/query` and `/analytics/sql` answer the ADR-0112 envelope - **`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`** — a new `ERROR_CODE_LEDGER` member - registered under `@objectstack/runtime` — and the analytics service is never - reached. A body wrong in more places than the `dateRange` stays the generic - `400 VALIDATION_FAILED` + `details.fields[]`. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | -| `dateRange: 'Last 30 days'` / `'last 30 days'` | `dateRange: 'last_30_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: 'This week'` | `dateRange: 'this_week'` | -| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | - -Measured in this repository at the ruling: three authored `'Last 7 days'`, all -in `packages/spec` tests (re-spelled here), and no published dashboard authors -the string arm at all — the shipped console lowers presets to the array arm -before querying. The drivers' own refusal of a non-conforming value that reaches -them in-process (past the schema) is the sibling card #16322, blocked by this -one; the fenced `service-analytics` fixture that authors the retired bare-ISO -spelling is that card's to re-triage. diff --git a/.changeset/analytics-icontains-per-dialect-fold.md b/.changeset/analytics-icontains-per-dialect-fold.md deleted file mode 100644 index 0d72acfa06..0000000000 --- a/.changeset/analytics-icontains-per-dialect-fold.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -Analytics `$icontains` no longer compiles a `translate()` call on the `sqlite` and `mysql` dialects. On **SQLite** that function does not exist and the statement failed to parse — measured on the engine, not inferred. On **MySQL** the same construct was emitted and its arm is repaired the same way, but nothing was ever executed there: the MySQL arm is asserted as emitted TEXT only, on this face and on `driver-sql`'s alike, so no MySQL parse failure is claimed as measured. - -`$icontains` folds ASCII case on both sides of the comparison (#4706 Q1 = A). All three of this package's SQL compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo of that statement — spelled that fold as `translate(col, 'ABC…', 'abc…')` on all four dialect values a compiler can see: `sqlite`, `mysql`, `postgres` and `unknown`, onto which `normalizeSqlDialect` maps everything else, an unset hook and `'oracle'` included. `translate()` is PostgreSQL/Oracle; SQLite has none. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate` — so this was not a filter that returned the wrong rows, it was a statement the engine refused. On a SQLite datasource, an analytics `where` carrying `$icontains` and an **RLS read scope** carrying it were both unusable. - -The fold is now chosen per dialect, on the same construct table the case-exact text family already used, reached through one `fold` flag: - -- **SQLite** — `lower(col) GLOB lower(?)`. SQLite's `lower()` is ASCII-only (measured: `lower('CAFÉ')` is `cafÉ`), so this is the ruled fold rather than an approximation of it, and it runs. -- **PostgreSQL** and the `unknown` residue — `translate()`, byte-for-byte what those two arms emitted before. Measured set for that word: this package's own suite pins six cells verbatim — `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {dialect unset, 'postgres'}` for `{name: {$icontains: 'acme'}}`, full emitted SQL and the exact bound params — and the round-1 contract review widened it to **2,721 cells** (2,720 = `{undefined, 'postgres', 'unknown', 'oracle'} × 5 compiler paths × 8 filter shapes × 17 comparands`, plus the bare `{dialect: undefined}` cell), emitted at the merge-base blobs (all five hash-verified) and again at this head: **0 changed cells, 0 error cells**. Outside that set nothing is claimed — no PostgreSQL server was contacted, and on `sqlite` and `mysql` the bytes deliberately changed (340 of 680 cells each, all inside the four `$icontains` shapes). -- **MySQL** — the nested-`REPLACE` fold over `CAST(… AS BINARY)`, matching what `driver-sql` emits for the same operator; the review measured the two faces byte-equal on 60 of 60 MySQL cells. Asserted as text only — no MySQL server is provisionable in the container that wrote this, so that cell is a declared skip, not a claimed pass. - -⚠️ Carve-out, stated because it is the surviving half of the defect and not an aside: an `unknown` dialect that is really SQLite is **not** fixed by this change. The residue is reached by four constructions the round-1 contract review drove rather than reasoned — a `SqlDriver` given a **class** client or an unrecognised spelling (`'libsql'`), a host hook answering knex's own `'sqlite3'`, a directly-constructed public `AnalyticsService` with the optional `sqlDialect` omitted, and a `data` service without `getDriverForObject`. For each of them `translate()` still reaches the engine and still fails to parse, on the `where` path, the read scope and the echo alike. No in-repo SQLite driver lands there — `SqliteWasmDriver` and `TursoDriver` both answer `"sqlite"`, measured — so this is an embedder-composition population, not a shipped-driver one. Tracked as #16028. - -`$icontains` and the case-sensitive `$contains` family remain two separate constructs on every dialect the compilers accept — collapsing them would give `$contains` back the case fold #4706 Q2 = A took away from it. Measured set for that word: 510 cells (six dialect names — the four values above plus `'oracle'` and an unset hook, which both normalize to `unknown` — × 5 compiler paths × 17 comparands), 0 of them identical between the two families and no `$contains` cell carrying a fold. - -⚠️ One deliberate divergence from `driver-sql`, recorded here rather than only in this package's source: `driver-sql`'s own `unknown` arm folds with `LOWER()`, this one keeps `translate()`. Each face keeps the residue it already had, and adopting `LOWER()` here would silently restore on PostgreSQL the Unicode fold #4706 Q1 = A rules out. The pointer exists on this side only; `driver-sql` carries no cross-reference back. diff --git a/.changeset/analytics-measure-result-type-string-family.md b/.changeset/analytics-measure-result-type-string-family.md deleted file mode 100644 index 95c9311bd3..0000000000 --- a/.changeset/analytics-measure-result-type-string-family.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -A `min`/`max` over a string-valued field is described as `string`, not `number` (#16098) - -The sibling population of the temporal fix. `min` and `max` return a value **of the aggregated field's own type**, so a `min` over a `text` / `select` / `lookup` / `autonumber` column carries a string — and `POST /api/v1/analytics/dataset/query` described every one of those columns as `type: "number"`, exactly as it did for the temporal family before the temporal half landed. - -What changed: - -- **`measureResultType` now answers `string` for the string-valued field types too**, in the same one table it already answered `time` from. No second mechanism and no new call site: the rule still answers `undefined` for "no correction", and `queryDataset`'s ADR-0021 result-column enrichment still applies it once, downstream of all four producers of the shape. -- **The corrected spelling is `string`**, the `DimensionType` word a `lookup` or `string` DIMENSION column in the same response already carries (`dataset-compiler.dimensionType`). A textual measure spelled `text` would have been a sixth word in a five-word wire vocabulary, leaving every existing consumer branch unreached — the same argument that chose `time` over `datetime`. -- **Membership is composed from `@objectstack/spec`'s own value classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, `REFERENCE_VALUE_TYPES`) rather than re-listed, so what the platform says a field type STORES and what this rule says a `min` over it RETURNS cannot drift. - -Corrected: `text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`, `select`, `radio`, `lookup`, `master_detail`, `tree`, `user`, `autonumber` — twenty-one members, each verdict read off the two shipped statements of what the type stores (the spec value contract and `driver-sql`'s DDL column switch). - -Deliberately NOT corrected, with the measurement recorded rather than a guess shipped as a declaration: - -- **`boolean` / `toggle`** — Postgres has no `min(boolean)` at all, SQLite answers `0`/`1` as numbers, and the driver seam has been recorded answering `false`/`true`. Three readings that disagree about whether a value exists and what kind it is. `DimensionType` does carry a `boolean` word, so the correction is spellable; it is not made. -- **The JSON-column classes** (`multiselect` / `checkboxes` / `tags`, `composite` / `repeater` / `record` / `location` / `address` / `vector`, `json`) — no `min` over `jsonb` on Postgres, serialized TEXT on SQLite. -- **The file types** (`image` / `file` / `avatar` / `video` / `audio`) — their stored form is mid-migration under ADR-0104 D3: the value contract already says an opaque `sys_file` id while the DDL still gives them a JSON column. -- **`formula`** — its result type IS declared, on `FieldSchema.returnType`, but that key is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and is itself optional. -- **`summary`** — measured NUMERIC on both shipped statements (the spec's `NUMERIC_VALUE_TYPES`, and `driver-sql`'s `table.float` column), so the `number` it already carried is correct rather than merely unexamined. - -Every member of `FieldType` now carries an explicit verdict, pinned by a test that walks the enum: a field type added to the spec fails that pin instead of silently inheriting the flat `number`. diff --git a/.changeset/analytics-measure-result-type-temporal.md b/.changeset/analytics-measure-result-type-temporal.md deleted file mode 100644 index 8f3511e6f9..0000000000 --- a/.changeset/analytics-measure-result-type-temporal.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/spec": patch ---- - -A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768) - -`POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource): - -```json -{"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}], - "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]} -``` - -`min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch. - -What changed: - -- **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies. -- **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached. -- **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path. -- **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it. - -`AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string. diff --git a/.changeset/analytics-preview-min-max-operand-type.md b/.changeset/analytics-preview-min-max-operand-type.md deleted file mode 100644 index bcf28144a4..0000000000 --- a/.changeset/analytics-preview-min-max-operand-type.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -A draft-preview `min`/`max` answers the operand's own type instead of `0`, and a preview dimension column is described by its own type - -`POST /api/v1/analytics/dataset/query` has two producers of one response: the engine, and — when the request renders the as-if-published world over a pending seed draft (ADR-0037 P3) — `evaluateAnalyticsQueryOverRows`. The second one coerced every aggregate operand with `Number()` and dropped the non-finite ones, so a `min` / `max` over a non-numeric field answered `0`. Measured on one dataset and one row set, with two services differing only in whether a pending seed draft exists: - -``` -live {"category":"travel","latest_spend":"2026-05-12"} -preview {"category":"travel","latest_spend":0} -``` - -That is not a mislabelled column: it is a different, wrong answer to the same query, with no refusal and no warning, on the path an author is looking at *while* authoring the dataset. - -What changed, per member of the closed `AggregationFunction` vocabulary: - -- **`min` / `max` return the winning operand in its own type.** Ordering goes through this file's shared `compare` — so an ISO date orders as a date, a BSON `Date` orders as its instant against wire text, and text orders the way `MIN(text_col)` does on a SQL face — with a numeric arm so a numeric column written as text (`'800'`) still orders numerically. `cross-object-rebucket.ts` settled the identical question for the recombination path: the value these two pick is a value OF the column, so it must come back in the shape the row carried. -- **A group whose operand is null throughout answers `null`, not `0`** — `emptyGroupValueFor` (`@objectstack/spec/data`) rules `min` / `max` over nothing unanswerable, and `0` reads as a measurement nobody made. -- **`count_distinct` answers a cardinality again.** Its arm was spelled `countDistinct`, a word no producer mints (`dataset-compiler` copies the spec's `count_distinct` through), so it was unreachable and the measure fell to the numeric default — answering a row count under the author's `count_distinct` name (measured: `3` where the live path says `2`). -- **`count` stays a row count and `sum` / `avg` stay arithmetic.** Counting dates is still counting. -- **`sum` / `avg` over a TEMPORAL operand is deliberately unchanged.** There is no defined answer — the SQL faces do not agree on one either — and refusing an incoherent aggregate/field-type pair is an open decision, not this fix's to invent. -- **A dimension column is typed from the cube dimension**, the same expression both live producers use (`d?.type || 'string'`), so a `date` dataset dimension is `time` on the preview path as it already was on the live one. A MEASURE column keeps the `number` every producer mints; correcting that is the ADR-0021 descriptor pass's one rule, not a second copy here. - -Derived measures are untouched: `computeDerived` still coerces with `Number()` and answers `null` for a non-finite operand — but a derived ratio over a temporal `min` / `max` now sees a date instead of the spurious `0`, so it answers `null` on the preview path exactly as it already did on the live one. diff --git a/.changeset/analytics-text-family-case-exact-per-dialect.md b/.changeset/analytics-text-family-case-exact-per-dialect.md deleted file mode 100644 index 54df4f9997..0000000000 --- a/.changeset/analytics-text-family-case-exact-per-dialect.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/driver-sql": minor ---- - -The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684) - -`$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`. - -On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518. - -What changed: - -- **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess. -- **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only. -- **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL. - -`$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted. diff --git a/.changeset/analytics-unknown-dialect-icontains-portable-fold.md b/.changeset/analytics-unknown-dialect-icontains-portable-fold.md deleted file mode 100644 index 3429078ddc..0000000000 --- a/.changeset/analytics-unknown-dialect-icontains-portable-fold.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -Analytics `$icontains` no longer compiles a `translate()` call on the `unknown` dialect arm, so a datasource whose dialect nothing answered — which includes SQLite — gets a statement its engine can parse. **Graded `patch`:** no exported type, signature or option changes; the package's own contract for the operator (#4706 Q1 = A, an ASCII-only fold on both sides) is unchanged, and this repairs an arm that could not run rather than adding or retiring behaviour. What moves is emitted SQL text on one arm, measured and enumerated below. - -`normalizeSqlDialect` maps **everything it cannot name** onto `unknown`: an unset `sqlDialect` hook, `'oracle'`, `'libsql'`, a `SqlDriver` handed a knex Client **class** rather than a spelling. #15780 left that arm folding with `translate()` and recorded it as "never broken", which was true of the dialects the arm was *pictured* as — mssql and oracle, which have `translate()` — and false of the ones actually routed there. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate`, so on all three of this package's compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo — the statement failed to **parse**. It reached the client as a 500, not an ADR-0112 refusal. One of the four constructions that land there is a directly-constructed public `AnalyticsService` with its **optional** `sqlDialect` omitted: leaving out an optional field turned a documented operator into a 500. - -The `unknown` arm now folds with one nested `REPLACE` per ASCII letter — the chain the MySQL arm already used, minus its `CAST(… AS BINARY)`, so there is one builder and the two arms cannot fold different alphabets. `REPLACE` is the one string function every SQL dialect has, and the domain is the same 26-letter constant, so the fold is ASCII-only **by construction**: - -- **PostgreSQL / Oracle-like** — same result set as `translate()`. The chain equals the simultaneous `A`-`Z` map because no step can feed a later one: every replacement writes a lower-case letter and every later step matches an upper-case one. Measured on the engine over **every ASCII code point** plus accented, Greek, Cyrillic and dotted-I probes, required equal to the ASCII-only map exactly. -- **SQLite-like** — it runs. Executed over the shared `FILTER_TEXT_CASES` `$icontains` rows through all three compilers on sql.js: the same row sets the `sqlite` arm is required to answer, including the `CAFÉ`/`café` pair that separates an ASCII fold from a Unicode one. -- ⛔ **Not `LOWER()`**, which is what `driver-sql`'s own `unknown` arm folds with. `LOWER()` follows the collation, so adopting it would trade this parse failure for **silently wrong rows** on PostgreSQL — the Unicode fold #4706 Q1 = A rules out. ⚠️ Measuring `LOWER()` in this container proves nothing about that: SQLite's `lower()` is ASCII-only and passes the same fixture, which is exactly the trap of letting a green SQLite reading stand in for a PostgreSQL one. No PostgreSQL server was contacted. - -**Which cells moved.** The emitted SQL and bound params of `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {undefined, 'unknown', 'oracle', 'libsql', 'postgres', 'sqlite', 'mysql'} × 5 text operators × 17 comparands` = **1,785 cells**, generated at this head and again with the emitter reverted to its merge-base blob (both legs hash-verified on disk and rebuilt, the marker's presence and absence checked in `dist/`): **204 moved, 1,581 byte-identical, 0 error cells either side.** Every moved cell is `$icontains` on one of the four dialect inputs that normalize to `unknown` (51 each = 17 comparands × 3 compilers). **0 of the 204 changed their bound params** — only the fold's spelling moved, never the escaping or the `ESCAPE` binding. Nothing moved on `postgres`, `sqlite` or `mysql`, and no case-exact operator moved on any dialect input. - -⚠️ **The cost, stated rather than left to be found:** the predicate grows from 168 to 1,014 characters on the read scope (233 → 1,079 on the other two). Both constructs are non-sargable scalar expressions over the column, so the plan class is unchanged — what grows is statement text and per-row work, on the arm where the alternative was a statement that did not run. - -⚠️ **The residue that remains**, because this arm is a residue and not a dialect: the fold is exact everywhere, but the comparison is `LIKE`, which on a case- or accent-insensitive collation (MySQL/MariaDB arriving here through the `'mariadb'` spelling #11756 deliberately leaves unrecognised; SQL Server) over-matches beyond ASCII. That is the **same** residue this arm's case-exact neighbour already carries and names — not a new one — and on those engines `translate()` did not run at all, so nothing that answered correctly before stops answering. - -`SqliteWasmDriver.dialectName` gains a direct pin. It answers `"sqlite"` only through an `isSqlite` override (the base class string-matches `config.client`, and this transport passes a class), that override had **0 direct test hits**, and it is the sole reason no in-repo SQLite driver reaches the arm above. The new pin includes the control: the base class answers `'unknown'` for that very config. diff --git a/.changeset/analytics-where-names-the-route-hop.md b/.changeset/analytics-where-names-the-route-hop.md deleted file mode 100644 index e20f8ebe8e..0000000000 --- a/.changeset/analytics-where-names-the-route-hop.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Documentation: the analytics `where` contract and the `element:number` D3 entry now name the hop an array filter is lowered at. - -Text only — no schema, accept-set, runtime or test behaviour changes. `AnalyticsQuerySchema.where` is still `FilterConditionSchema` and still refuses an array, which is the protocol working as `FilterArray`'s docblock (#5158 ruling C) declares it: a `FilterArray` is input-only authoring sugar, lowered to a `FilterCondition` at the single sink `parseFilterAST` (`@objectstack/spec/data`) the moment it arrives, and only the lowered `FilterCondition` travels any further. - -- `AnalyticsQuerySchema.where`'s `.describe()` gains one sentence pointing array authors at that lowering: an authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire, and this field admits only the lowered `FilterCondition`. It lands in the generated `content/docs/references/{api,data}/analytics.mdx` prop tables, which is where an author reads it. -- The `element-number-filter-rule-array` semantic migration entry recorded its runtime prerequisite one hop too late: "authored array → adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`". `lowerAnalyticsWhere` (`service-analytics`) is the in-process door (#5334) for callers reaching `analyticsService.query` directly. The wire's door is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` before any service code runs, so an un-lowered array is refused there. The entry's reason clause now names that route hop and the `parseFilterAST` lowering the adapter owes before the wire (#15828; the adapter-side fix is objectui#7752). - -The sibling entry `element-record-picker-filter-rule-array` was read for the same claim and does not make it — its measured path is `find()` / `convertQueryParams`, not the analytics wire — so it is unchanged. diff --git a/.changeset/api-duration-keys-unit-in-key-name.md b/.changeset/api-duration-keys-unit-in-key-name.md deleted file mode 100644 index cbea5238b1..0000000000 --- a/.changeset/api-duration-keys-unit-in-key-name.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": patch ---- - -feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478) - - - -**BREAKING** — twelve published `api/` duration keys are renamed and tombstoned. -Shipped as `minor` under the repo's launch-window convention for breaking -changes; the hand-migration prescriptions are registered under protocol major -18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit -in the key NAME, never only in its `.describe()` prose, and grandfathers no -existing offender. Stack card 1/6 (#15676) landed the rule's two structural -exemptions; this card clears the `api/` directory against it. Measured with the -gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree -count falls **48 → 36**. - -## FROM → TO - -| key | replacement | unit | -|:--|:--|:--| -| `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | -| `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds | -| `DeviceRequestResponse.interval` | `intervalSeconds` | seconds | -| `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds | -| `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds | -| `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | -| `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds | -| `RouteDefinition.timeout` | `timeoutMs` | milliseconds | -| `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds | -| `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds | -| `WebSocketConfig.timeout` | `timeoutMs` | milliseconds | -| `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds | - -**Every value is unchanged** — only key names move. Every old spelling is a -`retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type -`never`) and fails the parse with the rename prescription rather than a bare -unrecognized-key error. - -## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch - -Ruling B put this key explicitly in scope with its own BREAKING note: the -runtime-emitted measurements are read by humans and agents even though nobody -authors them. A consumer meets two retry-after values on one 429 — this -ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After` -header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date. -Spelled identically they read as one value in two places. - -**The HTTP `Retry-After` response header is a separate, unchanged surface.** Its -name is fixed outside this repo and nothing here touches it. Do not "fix" the -header to match the envelope, and do not read a surviving `retry-after` in -transport code as leftover work. - -## Dispositions — one D2 conversion, five semantic entries - -Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only -one of the twelve that gets an ADR-0087 D2 conversion** -(`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack -collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered -metadata kind stored as a row, so the conversion chain has a seam that sees it. -`os migrate meta --from 17` lists the mechanical edits. - -The other eleven are wire payloads and construction arguments — a device-flow -response body, an error envelope, REST-plugin route registration, a batch-loader -config, a router registration, WebSocket client/server configuration. None is -ever a stack collection member or a `sys_metadata` row, so no conversion seam -runs on them and each carries a **semantic** entry instead: this is the -disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these -very shapes, and what ruling B prescribes for a runtime-emitted key. - -## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror - -Attributed to RFC 8628 by the campaign card; the attribution fails against the -schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as -a set — `code` is not `device_code`, `verificationUrl` is not -`verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a -different type, an ISO-8601 instant where the RFC carries a relative lifetime). -A schema that already renames every RFC field it carries into house style cannot -claim the standard fixes the one name it left bare. Renamed rather than marked -deliberately: a wrongly marked key is exempted permanently and silently, while a -wrongly renamed one is visible. - -## Readers moved in the same PR, at the same magnitude - -`@objectstack/runtime`'s policy chain (`computeCacheControl` now reads -`endpoint.cacheTtlSeconds`), the publish gate's issue path -(`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase -example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead` -tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is -retargeted onto the live key — an alias must point at a key the schema really -accepts, and `cacheTtl` now accepts nothing. diff --git a/.changeset/api-surface-dual-kind-rows.md b/.changeset/api-surface-dual-kind-rows.md deleted file mode 100644 index 22836997d4..0000000000 --- a/.changeset/api-surface-dual-kind-rows.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`api-surface/` now records the value half of a name declared as both a const and a type, so deleting it is a breaking change the gate reports. - -TypeScript merges an `export const X` and an `export type X` into ONE symbol whose flags carry both. `build-api-surface.ts` mapped that symbol through a first-match-wins lookup that tested `TypeAlias` before `Variable`, so the shard recorded `X (type)` alone and the value half was never enumerated. Ablated: deleting `export const RestApiRouteRegistration` while keeping its type alias left all 17 shards byte-identical, the export total unmoved, and `check:api-surface` printing "public API surface + factory signatures unchanged" at exit 0 — on a removed public value export, which is the exact removal the ADR-0059 breadth gate exists to make loud. On the fixed generator the same deletion reports `- RestApiRouteRegistration (const)` as 1 breaking change and exits 1. - -The generator now emits one row per DECLARED kind. The shipped `api-surface/` shards gain **134 rows across 10 of 17 entry points** — every one of them the previously-missing `(const)` half of a name that also declares a type — as a pure insertion: zero rows removed, zero modified, no reordering. - -**Why `patch` and not `minor`, measured against what a consumer can observe.** No export was added, removed or renamed: `dist/` is byte-identical across this change, and the row grammar `Name (kind)` is untouched, so anything that parsed the artifact before parses it now. The 134 new rows describe exports that already existed — the record got more complete, no capability arrived. What changes is the accuracy of a shipped record and the strictness of this repo's own gate, which is a fix. - -One consequence for the release seat, stated because it is not visible from the diff: `build-spec-changes.ts --previous-surface` is a release-time join, so a release crossing this change will list those 134 rows as `added` surface entries. They are not new API — they are the same exports, newly recorded. diff --git a/.changeset/app-plugin-flat-bundle-seed-double-collect.md b/.changeset/app-plugin-flat-bundle-seed-double-collect.md deleted file mode 100644 index f64edf62b9..0000000000 --- a/.changeset/app-plugin-flat-bundle-seed-double-collect.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): a flat-manifest bundle no longer collects every seed dataset twice - -`AppPlugin.start()` collects seed data from two locations — the top-level -`data` field, then the legacy `manifest.data` for backward compatibility. The -legacy read resolves its base as `this.bundle.manifest || this.bundle`, so on a -FLAT bundle — manifest fields written directly on the bundle rather than nested -under `manifest:`, a shape `AppPlugin` supports by design and this repo's own -tests construct — it re-read the very array the top-level read had just -contributed. Every dataset landed in the collection twice. - -`mergeSeedDatasets` is a plain `push` with no de-duplication, so both copies -reached the shared `seed-datasets` registry, the inline boot seed, and every -later per-org replay. For an `upsert` dataset with an `externalId` the second -pass is idempotent and the cost is doubled work; for a `mode: 'insert'` dataset -it is the dataset APPLIED TWICE per boot — measured here as two `insert` calls -for one record. - -The legacy read now carries the same reference guard its sibling collector has -always carried: `loadTranslations()` performs the identical two-location read -and skips the legacy half when `manifest.translations` IS the array the top -level already contributed. That asymmetry between the two collectors was the -whole defect, so the repair is the sibling's guard rather than a third spelling -of the same idea. - -⛔ Not a removal of the legacy read: a bundle whose `manifest.data` is a -genuinely different array from its top-level `data` still contributes both, and -a bundle that nests its manifest is unaffected either way. Nothing is added to -or removed from any published surface. diff --git a/.changeset/approval-recall-docstring-override-scope.md b/.changeset/approval-recall-docstring-override-scope.md deleted file mode 100644 index 82dfc4d475..0000000000 --- a/.changeset/approval-recall-docstring-override-scope.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`IApprovalService.recall`'s contract prose names every actor who may recall, and scopes each one by status (#14670) - -**Documentation only — no key, no accepted value, no runtime behaviour moves.** The implementation has been correct since #12775; only the contract's description of it was stale. - -The docstring said *"Only the submitter (or a system context) may recall"*, then widened to `returned` requests in a second paragraph. Both halves were wrong, in opposite directions: - -- **The list was not exhaustive.** A #3424 override actor — a platform or tenant admin holding no approver slot — may recall a `pending` request. That is the in-product recovery path for an approval routed to an unstaffed position, and this same file already documented it 387 lines above the sentence denying it: the docblock on `ApprovalRequestRow.viewer.can_override` spells the override's levers as `(approve / reject / reassign / recall it)`. One file, two contradicting sentences about the same verb. -- **The ADR-0044 widening read as though it applied to that whole list.** It does not. The override and system arms are ANDed with `status === 'pending'` where they are computed, so neither reaches a `returned` request; an override actor is refused there exactly as any other non-submitter (#12775, maintainer ruling 2026-09-02). Abandoning a revision window is the submitter's alone. - -The rewrite makes **status** the axis instead of appending a caveat, so the second defect cannot come back on a re-read: each status carries its own admitted set, and the `returned` bullet says outright that the submitter is alone in it. - -`ApprovalRecallInput.actorId` carried the same stale sentence (*"Must be the request's submitter (or a system context)"*) and is corrected with it. Fixing only the method docstring would have left the contradiction alive on the very input type the corrected method takes. - -The two sibling docstrings sharing that phrasing are **correct and unchanged**: `ApprovalSendBackInput.actorId` and `ApprovalResubmitInput.actorId`. `isOverrideActor` is called from exactly five places in `plugin-approvals` — `decideNode`, `reassign`, `recall`, `attachViewers` and `visibleRequestIds` — and neither `sendBack` nor `resubmit` is among them, so no override actor reaches either. - -The published prose already described the corrected rule (`content/docs/automation/approvals.mdx`: an admin "may act on any `pending` request — approve, reject, reassign it to a real approver, or recall it"). This docstring was the one surface that had not kept up. diff --git a/.changeset/approvals-bu-member-org-screen.md b/.changeset/approvals-bu-member-org-screen.md deleted file mode 100644 index 61d6e389c1..0000000000 --- a/.changeset/approvals-bu-member-org-screen.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -Fix: a `department` approver on a seeded business unit no longer routes the approval to another organization's members. - -`ApprovalService.expandBusinessUnitUsers` screened the `sys_business_unit` rows with the null-inclusive tenant predicate (#3807 — a seeded unit carries no organization and is admitted on purpose) but read `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either. A seeded unit id exists identically in every tenant, so a `department:` approver on tenant A's request resolved the shared unit and then collected every tenant's membership rows hanging off it — approval authority over A's record, routed to B's users. The member read now carries a strict `organization_id` equality against the directory organization the approver resolves in: the same screen `plugin-sharing` applies to these rows, and the same posture this package already takes for `sys_team_member` and `sys_user_position`. - -The screen is strict rather than null-inclusive on purpose. `sys_business_unit_member.organization_id` is filled by REST/session writes but left NULL by seed replay and by elevated system-context writes (tracked in #14570), so a NULL on a membership row means unknown tenancy, not "platform-global", and routing fails closed on it. Declared cost: on a deployment whose membership rows (not merely its units) were seeded or system-written, a `department` approver on a request that carries an organization now expands to nobody — the slot falls to the `department:` literal, the existing `expanded to nobody` warning (#3807) names it, and `onEmptyApprovers` governs the request as for any unstaffed target. The repair is to stamp those membership rows. A request that carries no organization is unchanged, and so is every unit-level screen. diff --git a/.changeset/approvals-continue-restored-suspension.md b/.changeset/approvals-continue-restored-suspension.md deleted file mode 100644 index 424f4a63a8..0000000000 --- a/.changeset/approvals-continue-restored-suspension.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/plugin-approvals": minor ---- - -A restored approval suspension can now be decided again, not only cancelled. - -`AutomationEngine.restoreConsumedSuspension` re-arms the pause of a run that stranded mid-resume and tells the operator to *re-issue the continuation*. For an `approval` suspension nobody could: every approvals door that stamps the resume marker — `decide`, `recall`, `sendBack`, `resubmit` — guards on a `pending` request, and the row is terminal, written by the very call that stranded the run; and the generic engine door refuses an `approval` pause outright, because that node declares `resumeAuthority: 'service'`. The only remaining verb was `cancelRun`, which discards the branch's downstream work — so the advertised repair produced a run that looked resumable and was not decidable. - -Measured against the real engine and the real decision door: the restored suspension lacks nothing. A `resumeAuthority`-marked resume walks the restored pause to completion. What was missing was an **issuer** on the approvals side, and that is what this adds. - -- **`ApprovalService.continueRestoredRun(requestId, options?)`** re-issues the continuation the recorded outcome already produced once, against a pause an operator has re-armed. It reports which outcome it replayed, which edge it walked, and whether the signal was replayed exactly or rebuilt (`source: 'journal' | 'reconstructed'`). -- **The failing door now journals the signal it was carrying** on the repairable exit — the engine's own `status: 'stranded'` discriminator, the one exit that journals a repair snapshot — under `__strandedContinuation` in the request's `node_config_json`, beside the `__decisionOutputs` side-channel that was already there. Best-effort: it is awaited but can never replace the `RESUME_FAILED` throw the decision's caller is owed. -- **The continuation is tied to this request's own pause, by three guards.** A boolean "is this run suspended" is not enough: a run outlives any one request, so a terminal row's continuation could be issued against whatever pause the run happened to be sitting on. It now requires that the request is still the newest on its run, that a pause exists (strictly — an unreadable store throws rather than reading as "not suspended"), and that the pause is parked **where this request's recorded outcome was issued from**. That node is signal-aware, not simply the row's own: `approve`, `reject`, `revise` and `recall` are all issued at the request's own approval node, but a `resubmit` is only ever issued from the revise window the request's `revise` edge leads to, so its pause is re-armed there while the row still records the approval node. Comparing against the row's own node refused exactly that case, and told the operator the pause was not this request's when it was. The node check is fail-closed in every direction, including an engine that cannot report where a run is parked and a revise window this service cannot derive from the flow definition. This needs no new automation-engine surface: `listSuspendedRunsDurable` is already public, and the approvals-side resume interface simply declares it. -- **Runs stranded before this shipped are served too**, and where the signal cannot be proved the verb **refuses instead of guessing**. A status is not the same thing as a continuation, and three of the four terminal statuses have more than one writer or issuer: `approved` is unambiguous; `rejected` has two writers, discriminated by the `revise` action row that only ADR-0044's revision-limit auto-rejection leaves behind; `returned` has one writer but **two** issuers, discriminated by the `resubmit` action row whose sole writer is `resubmit` — without it a stranded resubmit was rebuilt as a send-back and walked the wrong edge, proceeding only through the engine's unmatched-label fallback with the wrong output; and `recalled` has two writers across **three** behaviours, two of which issue no continuation at all, so it is **refused on the rebuild path** with a message naming what an operator can do instead. Journal-recoverable is a **measured, named set** rather than a blanket claim: `approve`, `reject`, `resubmit` and `recall` continuations replay end to end through the verb, and `reject` and `resubmit` do so on the rebuild path as well. Two shapes are refused by design and stay refused — a `rejected` row that also carries a `revise` action, and a `recalled` row with no journal. NOT covered by a pin, and so not claimed: the `approve` rebuild path. - -- **A journalled signal is checked against what the row's status can have issued, before it is replayed.** The journal records what the last FAILED resume was carrying, and nothing rewrites it when a later door moves the row on — so a signal can outlive the state that issued it. Measured, with no injected failure beyond the strand: a `resubmit` strands and journals `resubmit`; the submitter then recalls, a real `cancelRun` on an already-stranded run answers `false`, the row is marked `recalled` and the run stays parked; the restore re-arms the pause; and the stale `resubmit` was replayed, opening a fresh `pending` round on a request somebody deliberately withdrew. Every step an ordinary action answering ordinarily. A row is now replayable only for a continuation its own status can have issued — `approved`→`approve`, `rejected`→`reject`, `returned`→`revise` or `resubmit`, `recalled`→`recall`, and nothing at all for a status nobody has enumerated. ⛔ Clearing the journal after a successful replay does not close this and was measured not to: the offending replay is the FIRST replay of that journal, so a clear that fires afterwards can never run before the advance it would prevent. - -⛔ What this deliberately does not do, each pinned: it does not re-open or rewrite the request row — all four `pending` guards are untouched and no status, mirror field or audit row is written, so a decided request still cannot be decided again through the front door; it does not relax `resumeAuthority: 'service'`, since the resume still goes through the one call site that stamps the marker; and it does not change `ApprovalDecisionResult`, whose shape is the subject of an open ruling. It also grants no capability in-process code did not already have — `RESUME_AUTHORITY_SERVICE` is importable by any host — what it adds is the guarded form, and the guards are stated as what they actually check: that this request is still the newest on its run, that a pause exists at all, that it is parked where this outcome was issued from, and that the recorded signal is one the row's present status can have issued. ⛔ None of them checks that the pause was consumed and genuinely re-armed, and an earlier wording of this entry claimed one did: a `returned` row with a resubmit action row and a pause that was never consumed is admitted, with `restoreConsumedSuspension` itself answering *"already resumable — nothing to restore"*. That shape is benign — the recorded action is the submitter's own resubmit, so the step it walks was decided — but it is not what any guard tests. Like the engine verb it completes, it is an in-process operator repair: no REST route, and no entry in the spec `ApprovalService` contract. diff --git a/.changeset/artifact-packages-collection-reads.md b/.changeset/artifact-packages-collection-reads.md deleted file mode 100644 index e306662df5..0000000000 --- a/.changeset/artifact-packages-collection-reads.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): a multi-package artifact's collections are read from `packages[]`, not only from the flattened top level - -A release artifact composed with `manifest: 'preserve'` carries every -definition twice — flattened at its top level, and again under -`packages[]` (ADR-0130 D4). Only two readers had ever learned the second -half: `ObjectQLPlugin`'s manifest service and the metadata artifact door. -Every other reader said `artifact.` and nothing else, so an -artifact that carried a collection under `packages[]` alone reached them -EMPTY — and nothing threw. The app booted clean having lost its -declarative actions, its scheduled jobs, its seed data, its object routing -or its default permission set. - -`resolveArtifactCollections` — new, and PACKAGE-PRIVATE to -`@objectstack/runtime` — is now the one way this package reads a top-level -collection out of an artifact in either shape. It takes the artifact's own -top-level value first and whole, then adds from each package body — in -`resolveArtifactPackageOrder`'s dependency order — the items the top level -did not already claim. A bundle that carries no `packages[]` is returned -unchanged, by identity: every single-package artifact and every -`defineStack()` config reads exactly as before. Nothing is added to any -package's published surface: `@objectstack/core` is untouched by this -change, and the new module is not named by -`packages/runtime/src/index.ts`. - -Where one collection key is spelled two ways inside one artifact — -`functions` is `z.union([z.record(…), z.array(…)])`, so two packages can -each be schema-valid and disagree — the read is REFUSED with an ADR-0112 -envelope (`MIXED_ARTIFACT_COLLECTION_SHAPE`, 422) rather than one spelling -being skipped. `composeStacks` already refuses the same mix at compose -time for the same reason. - -Taught to use it, in `@objectstack/runtime`: - -- `AppPlugin` — declared datasources and their auto-connect, the - `datasourceMapping` object routing, the objects handed to the connection - service and to the hot-reload seeder, scheduled jobs, seed datasets, - translation bundles, and the ADR-0057 security collections - (`positions` / `permissions` / `capabilities` / `sharingRules`). A job - handler's `ctx.bundle` is now the resolved view too, so - `ctx.bundle.objects` answers on a multi-package artifact. -- `collectBundleActions`, `collectBundleHooks` and - `collectBundleFunctionEntries` — including the object-EMBEDDED actions - that ride on `objects[]` and disappeared with it. -- `mergeRuntimeModule` — the declaration half. The sibling ESM module - re-supplies every callable regardless of shape, so `functions` was not - absent: a function declared `effect: 'writes'` simply came back as a bare - callable and defaulted to `'pure'`. It registered, it ran, and its writes - were counted as none. -- `createStandaloneStack`'s surfaced `requires` / `objects` / - `permissions` / `positions`, which drive the CLI's tier resolution, its - engine and storage-driver auto-registration, and the ADR-0056 D7 default - permission set. -- `resolve-project-database`'s project-database tier, which opens the - artifact itself and runs before any stack exists (`os dev`, `os start`, - `os db clean`). Without this a multi-package project silently fell - through to the unified default database instead of the datasource it - declared. - -Nothing about what the platform EMITS changes: `composeStacks` and the -artifact format are untouched, and the flattened top level is still -written. This is the reader half of the option-B program (#14512). diff --git a/.changeset/assembled-package-body-plugins-envelope.md b/.changeset/assembled-package-body-plugins-envelope.md deleted file mode 100644 index 985cb214fb..0000000000 --- a/.changeset/assembled-package-body-plugins-envelope.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `plugins` / `devPlugins` are artifact envelope keys — excluded from the assembled package body and refused inside `packages[]` (#15219) - - - -**BREAKING** accept-set narrowing on `AssembledPackageBodySchema` — the body -under `packages[i].manifest` of a release artifact (ADR-0130 D4): a body that -carries `plugins` or `devPlugins` is now **refused** at the manifest's strict -close (`unrecognized_keys`, naming the key), where it used to parse. Shipped as -`minor` under the repo's launch-window convention for breaking changes; the -hand-migration prescription is registered under protocol major 18. Maintainer -ruling 2026-09-04 on #15219 (director decision batch #32, verbatim 「同意」): -option A for both keys. - -`plugins` and `devPlugins` were members of the assembled-body key set by the -same derivation every other collection uses (`COMPOSE_KEY_DISPOSITIONS` gives -both `concat`). They are the only members whose values are **runtime assembly -instructions** rather than serialisable metadata: `plugins` holds what a host -hands to `kernel.use()` — live plugin instances, manifests or package names — -and `devPlugins` is the `os dev` load list. Inside an artifact a package body -is inert JSON, so a plugin under `packages[i].manifest` could never be -constructed by a loader; every reader reads the top level. The classification -is corrected rather than special-cased: an artifact carries metadata, a host -assembles plugins. - -**What changes** (`packages/spec/src/stack.zod.ts`): - -- `plugins` / `devPlugins` are **envelope keys** — top level only, never inside - `packages[]`. `ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS` (`packages`, `plugins`, - `devPlugins`) is declared once and feeds both the `AssembledPackageBodyKey` - derivation and `assembledPackageBodyShape()`. -- Both keys stay `concat`: a live stack still concatenates its plugins to the - top level under `composeStacks`, and `manifest: 'preserve'` no longer folds - them into any package body. -- The two declarations on the stack schema are unchanged. - -**What does NOT change:** `os serve` / `os migrate` / `os dev` keep reading the -top level (now correct by construction); no CLI, core or runtime code moves. - -## FROM → TO - -```ts -// before — a package body inside an artifact could carry plugins nobody could load -{ packages: [{ manifest: { id: 'com.example.crm', /* … */ plugins: [{ name: 'plugin.x' }] } }] } - -// after — plugins live on the artifact envelope only; the body above is refused: -// packages.0.manifest: unrecognized_keys ['plugins'] -{ plugins: [new CrmPlugin()], packages: [{ manifest: { id: 'com.example.crm', /* … */ } }] } -``` - -**Migration.** Declare `plugins` / `devPlugins` at the stack top level and -delete them from every `packages[i].manifest`. An existing multi-package -artifact that carries `packages[i].manifest.plugins` (if `os build` ever wrote -one — not directly measured) is refused on load after this change and must be -rebuilt from source; a hand-written `packages[]` entry drops the keys. Stacks -that only ever declared the two keys at the top level parse byte-identically. diff --git a/.changeset/assignment-value-cel-envelope-executor.md b/.changeset/assignment-value-cel-envelope-executor.md deleted file mode 100644 index 34b69590b9..0000000000 --- a/.changeset/assignment-value-cel-envelope-executor.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/lint": minor ---- - -feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention; the level also follows the 2026-09-04 bump ruling — -this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an -additive widening is at least `minor`). No ADR-0087 conversion: no authorable key -is renamed or retired, and the shape this refuses was never a shape any surface -offered. - -The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be -a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size` -…) is finally reachable from metadata — until now CEL was only ever asked for a -boolean. The spec half landed the contract (PR #15113); this is the half that -makes it do something. - -```yaml -# before: written into the variable verbatim, and rendered by `notify` as -# {"dialect":"cel","source":"joinNonEmpty(...)"} -# now: evaluated — digest is "Renewal due\nInvoice overdue" -assignments: - digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' } -``` - -- **Evaluated at run time.** The built-in `assignment` executor evaluates a - `value`-role envelope with the expression engine and assigns the result, in the - same CEL scope a flow predicate is evaluated in (one shared scope builder, so a - predicate and a value expression cannot disagree about what `rows` means). A - plain string keeps today's `{token}` interpolation, and every other literal is - still assigned as data. -- **Refused at three doors.** A malformed envelope now stops the flow registering - (`registerFlow` throws, the severity a malformed predicate gets) and surfaces as - a located `error` finding naming the node and the author's own variable — - `config.assignments.digest` — both at `objectstack validate` and at the runtime - publish gate a Studio / REST / MCP flow write goes through - (`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes: - ['flow']`). Malformed is a composition, not a fixed list: whatever - `AssignmentValueSchema` refuses in the envelope's shape — among them a missing, - empty or non-string `source`, a dialect other than `cel`, a non-object `meta` — - and then CEL that does not parse. All three doors derive that set from the same - two published validators, so none refuses a shape the executor would have run, - and a registered flow never faults for a shape those validators judge malformed. - Two shapes sit outside what either validator can judge — an `ast`-only envelope - and a whitespace-only `source` (it passes `min(1)` and reads as "not authored" - to the validator, while the CEL engine parses it untrimmed) — and those fault - loudly at run time rather than assigning a value. Both are pinned and tracked in - #15430. -- **Only the canonical map.** The ledger declares `assignment.assignments.*` and - nothing else, so the two legacy shapes the executor still normalizes — the - `assignments: [{ variable, value }]` array and the bare `{ : }` - config — keep every meaning they had, envelope-shaped values included. - `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the - array form: refusing it would break flows that register today, and that refusal - is a maintainer ruling rather than a lane's call (#15137 ask 3). - -**What changes silently, and how far it reaches.** A flow that today authors an -envelope-shaped object *as data* in the canonical `assignments` map now evaluates -it — no error on either side, a different value. The discriminator is the spec's -own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`, -in the declared map only. Data that names no `dialect`, names a non-string one, -nests the envelope one level down, or sits in either legacy shape is untouched -and byte-identical. The remaining overlap — a well-formed -`{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly -the spelling the ruling reinterprets; every near-miss the two validators can -judge now refuses loudly at registration instead of changing value in silence. diff --git a/.changeset/attest-fresh-datastore-remedy-register.md b/.changeset/attest-fresh-datastore-remedy-register.md deleted file mode 100644 index f76fc11ee5..0000000000 --- a/.changeset/attest-fresh-datastore-remedy-register.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -`attestFreshDatastore` looks its `os migrate` remedy up instead of defaulting it - -When a fresh datastore's own boot has already admitted a value that contradicts a -migration's contract, that id is not attested and the operator is told what closes -the gate on real evidence. The sentence used to be built from a two-way branch: the -file-references id got `files-to-references`, and **every other id** got -`value-shapes` by default. - -`CREATION_ATTESTED_MIGRATION_IDS` has three members. For the third — -`adr-0030-notification-event` — that default is a wrong prescription: `os migrate -value-shapes --apply` neither attests nor clears it, and there is no `os migrate -notification-event` sub-command to send an operator to at all (that cut-over is an -operator call with no self-check). - -The branch is now an explicit id-to-remedy register, total over the ids a -value-shape tally can contradict. The loop asks it rather than falling into an arm, -so an id with no value-shape contract is never-contradictable by that evidence and -is attested on the birth observation as before. A new member therefore inherits no -remedy: adding a third arm that happened to be right today would only have moved the -same defect onto the fourth member. - -No behaviour changes for the two ADR-0104 ids, which is where every reachable path -runs today: the shipped engine keys its admitted-violation tally from a closed -`'media' | 'value-shape'` union, so it cannot name a third id. diff --git a/.changeset/audit-binder-created-at-unconditional-on-create.md b/.changeset/audit-binder-created-at-unconditional-on-create.md deleted file mode 100644 index 88376bef26..0000000000 --- a/.changeset/audit-binder-created-at-unconditional-on-create.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the audit binder stamps `created_at` from the system clock on an ordinary create, so a caller-supplied value no longer survives a plain `POST` (#15964) - -The `beforeInsert` audit stamp was `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. Since the static-`readonly` strip moved INSIDE `engine.insert` (2026-09-03 ruling, option C) it runs AFTER the before-phase hooks, and its guard treats a key a `beforeInsert` hook ASSIGNED as the hook's write rather than a caller forgery. The `??` therefore laundered the caller's bytes past that strip: a normal authenticated `POST /api/v1/data/OBJECT` carrying `created_at: '1999-01-01T00:00:00.000Z'` stored exactly that on an object declaring `created_at` as `readonly: true`, while `id`, `updated_at` and every other author-declared readonly datetime in the same payload were taken. `created_at` is the audit anchor, so a forgeable one makes after-the-fact attribution untrustworthy. - -The stamp now takes the same shape as `updated_at`: - -```ts -record.created_at = preserveAudit ? (record.created_at ?? now) : now; -``` - -**What changes for a caller.** An ordinary create no longer preserves a supplied `created_at` — the value is overwritten with the server instant rather than deleted, so the column is still a real stamp and no `defaultValue` re-derivation is involved. This narrows the accept set to the `readonly` contract the field already documents; no exported symbol, schema or config key moves. - -**The historical-import channel is unchanged and pinned.** `runImport({ treatAsHistorical: true })` sets `preserveAudit: true` on the write context (`@objectstack/rest`), and that branch still reinstates an original `created_at`, exactly as it has reinstated `updated_at`/`updated_by` since #3493. This is why the fix is the `preserveAudit` ternary rather than a bare `= now`. The create-side strip still does not read `preserveAudit` (2026-08-08 ruling, untouched): the preservation is the audit binder's, and it always was. - -**A creator that back-dated rows through the old `??` must now ask for it.** Any insert path that supplied a historical `created_at` without `preserveAudit` now gets the server instant. The remedy is one context key on the write (`preserveAudit: true`), the same one `treatAsHistorical` sets. - -Ruled by the maintainer on 2026-09-06 (decision batch #54, option A). diff --git a/.changeset/audit-binder-created-by-session-on-create.md b/.changeset/audit-binder-created-by-session-on-create.md deleted file mode 100644 index 5dd5b0266a..0000000000 --- a/.changeset/audit-binder-created-by-session-on-create.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the audit binder stamps `created_by` from the session on an ordinary create, so a caller-supplied value no longer survives a plain `POST` (#16311) - -The `beforeInsert` audit stamp was `record.created_by = record.created_by ?? session.userId` — client-preferred on every insert, with no flag and no privilege required — while its sibling one line down was already the `preserveAudit` ternary. Since the static-`readonly` strip moved INSIDE `engine.insert` (2026-09-03 ruling, option C) it runs AFTER the before-phase hooks, and its guard treats a key a `beforeInsert` hook ASSIGNED as the hook's write rather than a caller forgery. The `??` therefore laundered the caller's bytes past that strip: an authenticated `POST /api/v1/data/OBJECT` carrying `created_by: 'forged_user'` stored exactly that, on an object whose `created_by` is the registry-injected `AUDIT_FIELD_DEFS` shape (`readonly: true`), while `updated_by` in the same payload was correctly overwritten with the session user. A row could claim it was created by a user who did not create it — audit integrity, not privilege escalation. - -The stamp now takes the same shape as `updated_by`, one field over, and the same shape #15964 landed for `created_at`: - -```ts -record.created_by = preserveAudit ? (record.created_by ?? session.userId) : session.userId; -``` - -**What changes for a caller.** An ordinary create no longer preserves a supplied `created_by` — the value is overwritten with the session user rather than deleted, so the column is still a real attribution stamp. This narrows the accept set to the `readonly` contract the field already documents; no exported symbol, schema or config key moves. - -**The session-less insert is deliberately unchanged, and that is load-bearing.** Both audit-user assignments stay inside `if (session?.userId)`. With no session the hook assigns nothing and the engine's readonly strip takes the caller's value, so the key is absent — already the correct outcome today, reached by a different path. A shape that assigned `session.userId` unconditionally would write `undefined` into the key, making it one the hook "wrote", and the strip would then spare it: a branch that is correct today would become a new hole. That row is pinned. - -**The historical-import channel is unchanged and pinned.** `runImport({ treatAsHistorical: true })` sets `preserveAudit: true` on the write context (`@objectstack/rest`), and that branch still reinstates an original `created_by`, exactly as it has for `updated_by` since #3493. This is why the fix is the `preserveAudit` ternary rather than a bare `= session.userId`. - -**A creator that supplied a non-session `created_by` under an authenticated session must now ask for it** via `preserveAudit: true`. Creators that write an arbitrary `created_by` through a session-less system context (`{ isSystem: true }` with no `userId`) are untouched: the hook never entered that branch before this change either, and the `isSystem` strip exemption is what carries their value. diff --git a/.changeset/audit-router-keyed-identity-and-listnames-parity.md b/.changeset/audit-router-keyed-identity-and-listnames-parity.md deleted file mode 100644 index 2ccbfb14de..0000000000 --- a/.changeset/audit-router-keyed-identity-and-listnames-parity.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@objectstack/metadata': minor -'@objectstack/objectql': minor ---- - -feat(metadata,objectql): a keyed plural read on `MetadataManager`, `listNames` fault parity, and an action audit that answers from the same identity and sources as the router - -Two plural reads of one metadata plane could disagree with a by-name read of -that same plane, and the ADR-0110 D5 action-governance audit stood on the -disagreement — reporting `registered handler with NO declaration … REFUSED at -dispatch` about a route the router was resolving and dispatching in the same -boot. - -**`MetadataManager.listNames` gains the per-loader `try`/`catch` that -`loadMany` and `list()` have carried since #5108.** One loader fault used to -produce two different facts depending only on which plural read a caller -reached for: `loadMany` swallowed it and answered short, `listNames` threw. It -now degrades the same way, through the same `reportLoaderReadFailure` / -`reportLoaderReadRecovered` helpers — one outage, one line, one vocabulary. -Callers that relied on `listNames` throwing to detect an outage should read -`listDiagnosed()`, which reports `degraded` explicitly. - -**New: `MetadataManager.loadManyKeyed(type, options?)`** — `loadMany` read under -the identity the STORE holds each item by, returning `{ name, data }` pairs. It -delegates to a loader's own `loadManyKeyed` where one is offered (on -`DatabaseLoader` that shares `loadMany`'s single query, so it costs nothing -extra) and otherwise falls back to that loader's `list()` + per-name `load()`. -⛔ **`loadMany`'s published return shape does not change**, and no existing -consumer is touched: the key travels *beside* the body, never inside it, so a -body that deliberately carries no `name` stays byte-identical to what was -stored (#14205). - -**The action-governance audit now mirrors the router on both halves of the D5 -bijection.** The declaration half enumerates the plane keyed -(`loadStandaloneActionsKeyed`), so a row whose body does not name itself — a -`sys_metadata` row keyed by its `name` column, or a `FilesystemLoader` file -whose identity is its path — is a declaration to the audit exactly as it is to -the router; the handler half also probes the plane BY NAME -(`lookupMetadataAction`, `loadDiagnosed`/`load`, injected like the existing -registry rung), so a loader fault a plural read swallows can no longer turn a -dispatchable handler into an accusation. Both probes stay conservative in one -direction only: a source that throws leaves the handler on the list. - -Additive on every published signature. `runActionGovernanceInventory` and -`collectEngineActionDeclarations` gain optional parameters and keep their old -ones working unchanged; declaration rows gain an optional `storeKey` (the new -exported `ActionDeclarationRow`). - -**Population change, reported:** `unboundDeclarations` now sees declarations -whose identity is the store key. Its BEFORE was **0, structurally rather than -by sampling** — a nameless row was dropped before reconciliation ran, so it -could never be reported however many a plane held. Its one deliberate -subtraction: a row with neither an own `name` nor a store key is no longer -reported as `actionName: undefined`, which read as a parse failure in the -warning rather than as a finding. - -Known boundary, stated in the audit's docblock rather than left to be -rediscovered: a boot-time audit runs outside any request scope, so if a -composition ever registered `metadata` as `SCOPED` the audit could not reach -that instance at all — before any read method runs. No shipped composition does -(`packages/metadata/src/plugin.ts` registers a static instance), and reaching a -request-scoped service from a boot-time audit is a separate change. diff --git a/.changeset/auth-catchall-owned-404-not-yielded.md b/.changeset/auth-catchall-owned-404-not-yielded.md deleted file mode 100644 index 4be96afaec..0000000000 --- a/.changeset/auth-catchall-owned-404-not-yielded.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -The auth catch-all yields only a 404 that disclaims ownership — better-auth's own 404 answers can no longer be replaced by another route's - -`registerAuthRoutes` mounts one catch-all over the whole auth namespace (`rawApp.all(`${basePath}/*`)`), and since #4088 that catch-all is deliberately not terminal: when better-auth answers 404 it calls `next()` and lets whatever else matched answer instead. That yield is load-bearing — `plugin-hono-server` mounts `/auth/me/permissions` and `/auth/me/localization` from its own `kernel:ready` hook, and without it those two are reachable only when HonoServerPlugin happens to register first. - -What the yield could not express is **which** 404 may be handed on, because it had only the status to go on. So every 404 was yielded, including the ones that are better-auth's own answer on a path its router serves. Measured with the shipped handler on a real Hono app: add one broad downstream mount — `app.all('/api/v1/*', c => c.json({}))`, the shape a composition adds — and - -``` -POST /api/v1/auth/delete-user -> 200 {} -``` - -where better-auth answered 404 because `user.deleteUser` is deliberately unconfigured. That route is not hypothetical: `auth-route-ledger.ts` carries it under the `disabled` disposition precisely because it is published and refused — and the same holds for every 404 a routed endpoint produces for a bad token, an unknown id, or an admin family the deployment does mount. Those answers were all up for grabs. - -The catch-all now asks better-auth's live instance whether it owns the path before it yields. The seam is `auth.api` — the same one `auth-route-ledger.conformance.test.ts` reads and the same one the `/admin/` dogfood sweep derives from, because there is no route table to enumerate by hand; matching mirrors better-call's own `createRouter` walk, including its `SERVER_ONLY` skip and its `:param` syntax. That skip is load-bearing rather than cosmetic: measured on the stock boot, the nine `/admin/oauth2/*` endpoints are in `auth.api` and every one carries `SERVER_ONLY: true`, so better-call never routes them — their 404 is an unrouted one and stays yieldable, because ownership is "does better-call route this", not "is it in `auth.api`". An ownership table that cannot be built answers "not owned", so an enumeration failure degrades to the previous behaviour rather than taking the #4088 surface down with it. - -**The mount is untouched.** It still claims exactly `${basePath}/*` and still forwards every request under it to better-auth. What narrowed is only which 404 may be handed on. - -**Upgrade note — a composition that mounts a route matching paths under the auth base path may see a 404 where it previously saw its own answer.** Affected: deployments that register a route which also matches `/api/v1/auth/...` — most often a broad wildcard over the API prefix — mounted *after* AuthPlugin. Before this release, any request to a path better-auth serves but answers 404 on (a switched-off capability, not an unknown path) was passed to that route and the caller received *its* response, commonly `200` with an empty object. From this release the caller receives better-auth's 404. Callers that treated such a response as success — `res.ok`, `status === 200`, "no error thrown" — will start seeing the refusal that was always the real answer; that is the point of the change, and the wire shape they now get is the one a deployment without the extra mount has always returned. Nothing to do if you mount no such route: paths better-auth does **not** own are yielded as before, so `/auth/me/permissions`, `/auth/me/localization` and any other sibling route under the auth prefix are unaffected in either registration order. - -**One carve-out to that sentence, measured and bounded.** A **trailing-slash or doubled-slash spelling of a path better-auth DOES own** — `/api/v1/auth/delete-user/`, `/api/v1/auth//sign-in/social` — is now claimed rather than yielded. better-call treats those spellings as unrouted (it refuses on a `//` and on trailing-slash parity before it looks the route up), while this ownership table strips the trailing slash and drops empty segments and so counts them as owned. On a composition with a broad downstream mount, such a spelling therefore answers better-auth's 404 instead of that mount's response. Only those two spellings, only of a path better-auth already owns, and only where such a mount exists: no route in this repo registers a spelling of that shape, and every genuinely unowned path — every `/auth/me/*` route included — is yielded exactly as it was. Aligning the table with better-call's own pre-checks is tracked as a follow-up rather than carried here. diff --git a/.changeset/auth-domain-claim-segment-boundary.md b/.changeset/auth-domain-claim-segment-boundary.md deleted file mode 100644 index 50b96f7344..0000000000 --- a/.changeset/auth-domain-claim-segment-boundary.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The `/auth` dispatcher domain no longer claims sibling namespaces such as `/authx` and `/authentication/foo`. - -`createAuthDomain` registered `{ prefix: '/auth' }` without a `match`, and `DomainRoute.match` defaults to `'prefix'` — a bare `path.startsWith('/auth')` with no segment boundary. Every path whose first segment merely *began* with the five characters `auth` was therefore claimed by the auth domain and forwarded to the auth service, instead of falling through to the dispatcher's `ROUTE_NOT_FOUND`. Measured on a real boot (a real kernel with `AuthPlugin`, served through `createHonoApp({ kernel, prefix: '/api/v1' })`), `GET /api/v1/authx`, `/api/v1/authx/foo` and `/api/v1/authentication/foo` were all claimed; `/api/v1/aut/foo` and `/api/v1/zzz/foo` were not, which is what located the boundary at the `auth` prefix. - -The route now declares `match: 'segment'` — the spelling the registry's other boundary-correct domains (`/keys`, `/mcp`, `/mcp/skill`) already use. It claims `/auth` exactly and everything under `/auth/`, and nothing else. - -**What does not change.** `/auth/me/permissions` and `/auth/me/localization` still reach `dispatch()`. Neither is a better-auth endpoint, so the adapter's `/auth/*` mount disclaims them and they arrive at this domain; `'segment'` keeps claiming them, which the accompanying test pins as an overshoot control alongside the three narrowed rows. - -**If you mounted a namespace under `/authx`, `/authentication`, or any other first segment starting with `auth`,** it was previously shadowed by the auth domain and answered by the auth service. It is now reachable — register a domain handler for it, or expect `ROUTE_NOT_FOUND`. diff --git a/.changeset/authz-store-outage-status-relay.md b/.changeset/authz-store-outage-status-relay.md deleted file mode 100644 index 9685c3a1d8..0000000000 --- a/.changeset/authz-store-outage-status-relay.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/service-storage": patch -"@objectstack/service-settings": patch ---- - -An authorization-store OUTAGE now reaches the caller as the `503 SERVICE_UNAVAILABLE` it declares, on the storage download doors and on all four settings routes. - -`AuthzStoreUnavailableError` exists so an outage is distinguishable from a capability denial on the wire: it declares `status: 503` and `code: SERVICE_UNAVAILABLE`, and every producer in this family already re-raises it rather than laundering it into a verdict. Two consumers then flattened it back, each in its own way, so the declared envelope never arrived. - -**What changes on the wire.** Only on the path where the authorization store could not be READ — never when it legitimately returned no rows, and never for any other fault. - -| door | before | after | -| --- | --- | --- | -| `GET /api/v1/storage/files/:fileId/url` | `403 FILE_DOWNLOAD_DENIED` / `403 ATTACHMENT_DOWNLOAD_DENIED` | `503 SERVICE_UNAVAILABLE` | -| `GET /api/v1/storage/files/:fileId` | same 403, and no redirect | `503 SERVICE_UNAVAILABLE`, still no `Location` | -| `GET /api/settings` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | -| `GET /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | -| `PUT /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | -| `POST /api/settings/:namespace/:actionId` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | - -The storage row is the one worth reading twice: an outage was answered as a **permission denial**, byte-indistinguishable from a genuine refusal, which is the precise confusion the loud-outage discipline exists to prevent. The message now names the object whose read failed and says in words that this is not a permission denial. - -**What does NOT change.** The security posture is identical — these doors were already fail-CLOSED and still are, and the storage gate still mints no capability on an outage. Every other refusal keeps its status and code: `deny` is still `403`, `unauthenticated` still `401`, an unknown namespace still `404`, a forbidden settings context still `403`, and any fault that is not this branded outage still lands on the same untyped `500 INTERNAL_ERROR` tail it did before. The repair is scoped to the brand, not to "anything carrying a status". - -**Why `patch` and not `minor`.** No API is added, removed or renamed; no exported signature moves; no authorable key changes. This is a released package delivering an envelope it already declared — a bug fix, which this repo bumps `patch`. The change *is* observable, which is why the FROM → TO table above is in the changeset body rather than encoded in the bump: a version number carries no mapping, and this text is what an upgrading consumer greps in `CHANGELOG.md`. - -**If you branch on these statuses.** A client that treated the storage `403` as "this user may not have this file" was, during an outage, retrying or re-authenticating against a fault that no credential could fix; it should now treat `503` as retryable and leave the caller's permissions alone. A client that treated the settings `500` as an unrecoverable server error can now distinguish a transient store outage from a genuine internal fault. diff --git a/.changeset/automation-completed-run-history-throw.md b/.changeset/automation-completed-run-history-throw.md deleted file mode 100644 index af2e4fd936..0000000000 --- a/.changeset/automation-completed-run-history-throw.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A run whose nodes all succeeded is no longer reported `stranded`, journalled for repair, or re-armed because its terminal run-history write threw — which made the "repair" re-run every node after the pause. - -`AutomationEngine.resumeInternal` 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. The arm journalled a repair snapshot, stamped `status: 'stranded'`, and answered `success: false`; `restoreConsumedSuspension` then correctly honoured that snapshot and put the pause back, so the next resume drove the downstream nodes a **second** time. The defence that normally prevents a re-armed run from becoming double-runnable reads the durable terminal row first, and on this path the durable terminal row is precisely what failed to land — so it could not fire. - -Two statements inside `recordLog` reach that `catch`, and both are host-supplied surfaces rather than in-repo ones. `store.recordTerminal` escapes when it throws **synchronously**: the `void write.catch(...)` beneath the call only ever sees a returned promise's rejection, and a store returning a non-thenable makes `write.catch` itself a synchronous `TypeError`. Both stores shipped in this package are `async` methods and so cannot reach it, but `SuspendedRunStore` is an exported, optional-method interface a host may implement. The second statement is the run-summary line `logger.info(...)`, which is on by default (`runSummaryLog: 'info'`) and calls a host-injected `Logger`, so it needs no store at all. - -- **The completion-path history write is now guarded at its own site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. `resume` answers the truthful `success: true`, no snapshot is journalled, `restoreConsumedSuspension` refuses with `RUN_COMPLETED`, and the downstream node runs exactly once. -- **The lost history row is still reported**, at `error`, naming in its first line that the run completed, that its terminal row never landed and nothing retries it, that the run must not be repaired or re-run, and that the driver's own failure is in the record's structured slot. -- **`restoreConsumedSuspension` is unchanged.** It judged correctly on the evidence it was handed; the evidence was what was wrong, and a completed run now journals none. A genuine node failure still journals, still reports `stranded`, and is still repairable. diff --git a/.changeset/automation-failed-save-reseats-suspension.md b/.changeset/automation-failed-save-reseats-suspension.md deleted file mode 100644 index 030f570b9d..0000000000 --- a/.changeset/automation-failed-save-reseats-suspension.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A suspended run whose durable save fails is now kept resumable in this process even when a concurrent read landed mid-park — and the error record that reports the failure says how to check that. - -`AutomationEngine.persistSuspendedRun` writes its map entry BEFORE it awaits `store.save()`, and marks the run cache-only only once that save settles. For the whole of that await the entry is live but unqualified, so a concurrent per-id `hasSuspendedRun` / `resume` reads a store that truthfully has no row yet, finds no qualifier, and evicts a run that is being parked right now. That window is bounded and stays as it was: the strict load is store-first, so once the save lands the run is resumable from the store, and only the cache-only listing under-reports. - -Compounded with the save then **failing**, it was not bounded. The catch marked the run cache-only, but the map entry that marking qualifies had already been evicted, so the run had neither a durable row nor an in-memory copy: `hasSuspendedRun` answered `false` and `resume` answered `RUN_NOT_FOUND`. The run was lost **in this process**, not merely un-durable — for example a paused approval that no decision can ever advance. Reaching it needs a store that rejects the write while still answering reads with "no row" rather than throwing: a healthy read replica behind a broken write path, a missing `INSERT` grant, a full disk. - -- **The failure path now re-seats the map entry** alongside the cache-only marking, so the marking qualifies something again and the documented degradation — a failed save costs cross-restart durability, not in-process resumability — holds in this interleaving too. The cache-only marking is not widened, no lock is added, and the save is not reordered, so a run is still never readable out of the map while the store is authoritative for it. -- **The error record for a failed save is corrected.** It kept telling the operator the run was "kept in memory only" and that they had until the next restart to act, which in this interleaving pointed away from the loss: the run was already gone, and the restart would take the blame. It now names the two reads that must still answer for the run (`hasSuspendedRun()` and `listSuspendedRuns()`), so the promise can be checked rather than trusted. It still reports the same cause in the same structured slot, at the same `error` level. diff --git a/.changeset/automation-resume-stranded-details.md b/.changeset/automation-resume-stranded-details.md deleted file mode 100644 index cb2d2563ee..0000000000 --- a/.changeset/automation-resume-stranded-details.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": minor -"@objectstack/client": minor ---- - -The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded. - -`POST /api/v1/automation/:name/runs/:runId/resume` answers a run that consumed its pause and then failed with `400 FLOW_FAILED`, and until now its `error.details` carried the run's two artefacts only (`errorMessage`, `summary`). The engine's own verdict was dropped at the door: `AutomationResult.status: 'stranded'` — a run that is terminally failed *but* repairable by an explicit operator verb, because the pause a durable decision was waiting on is gone with the failure — reached the wire as the same `400` a plain terminal failure does, so an HTTP-only caller could not tell "beyond reach" from "repair waiting". - -- **`@objectstack/spec`** declares `ResumeFailureDetailsSchema` (`@objectstack/spec/api`): `{ runId, status?: 'failed' | 'stranded', repairable }` — the machine-readable shape of a resume failure told to the caller, declared once so every carrier of the family ruling spells the same members. -- **`@objectstack/runtime`**: the resume door's `400 FLOW_FAILED` details now carry that structure beside `errorMessage` / `summary`. `runId` is the run the resume was addressed to; `status` is the engine's own stamp, forwarded verbatim when it set one and never synthesised (the subflow-child-failed exit stamps none today); `repairable` is `status === 'stranded'` and is **always present on this arm** — present-and-false on a plain terminal failure, deliberately, so an absent member reads as an older server rather than as "not repairable". The code stays `FLOW_FAILED` (no `FLOW_STRANDED` sibling is minted), so a client that treats it as terminal keeps working and one that wants to offer a repair branches on `details.repairable`, never on the message text. The trigger door and `/actions` are unchanged: they never resume, so the member is absent there and absent means "not a resume". -- **`@objectstack/client`**: `automation.resume` documents the new members. diff --git a/.changeset/automation-run-failed-stays-in-the-blob.md b/.changeset/automation-run-failed-stays-in-the-blob.md deleted file mode 100644 index 29ec86c970..0000000000 --- a/.changeset/automation-run-failed-stays-in-the-blob.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -docs(automation): `sys_automation_run` says why `failed` has no column of its own, and `summary_json` names it (#15606) - -`FlowRunSummary` carries five run-level totals. Four of them — -`selected_count`, `acted_count`, `skipped_count`, `unmeasured_count` — have a -column on `sys_automation_run`; `failed` rides inside the `summary_json` blob. -That asymmetry was filed as a finding and ruled on (decision batch #76, -2026-09-07) rather than closed by adding a fifth column, and this change is the -ruling: the reasoning now ships in the schema instead of living only on the -card. - -The four are columns because ONE filter expression needs them in ONE row — -`selected_count > 0 AND acted_count = 0`, qualified by `unmeasured_count` — and -a `WHERE` clause cannot reach into a JSON blob for an operand, so every operand -of that expression has to be a column or the expression cannot be written at -all. `failed` is not one of its operands: it would be its own predicate -(`failed_count > 0`), nobody alerts on it today, and a caller that wants it has -already fetched `summary_json`. - -What a consumer sees change: - -- `summary_json`'s `description` now names `failed` as the field to read - lost-row counts from, states that the run-level totals live in the blob - alongside the per-node breakdown, and repeats the `unmeasured`/`failed` - convention that an absent count means "not tracked", never zero. ⚠️ This is - why the change carries a changeset and NOT `skip-changeset`, and it was - MEASURED rather than assumed from "it's only prose": `SysAutomationRun` is - re-exported from `src/index.ts`, `package.json` publishes `files: ["dist"]`, - and after `pnpm --filter @objectstack/service-automation build` the new - description text is present in BOTH published entry points — one hit each in - `dist/index.js` and `dist/index.cjs`. `skip-changeset` is for a diff that - publishes nothing from any released package; this one changes bytes inside a - released package's shipped bundle, so it does not qualify. (`description` is - also what the authorable `help` / `helpText` keys alias onto in - `packages/spec/src/data/object.zod.ts` — documentation a consumer surface can - render, not an internal note.) -- The comment above `selected_count` — the paragraph that explains why the - four are columns, and therefore the paragraph a reader is in when they - notice the fifth is not — now carries the verdict for `failed` and the one - condition that re-opens it: the first real need to ALERT on "which runs lost - rows this week" is the card that adds `failed_count`, mirroring - `unmeasured_count` (null on rows written before the column existed, never - `0`) — one column on an ADR-0103 engine-owned object, a human-floor change. -- `ObjectStoreSuspendedRunStore`'s terminal-row write, where a fifth - `record.summary?.failed ?? null` line would go, points at that verdict so the - question is not re-derived from the write site either. - -No schema shape moves: no field is added, removed or renamed, no type or -`required` flag changes, and the accepted set of every object and payload is -byte-for-byte what it was. `sys-automation-run-failed-count-verdict.test.ts` -pins both halves — that there is still no `failed_count` (or any other -`fail`-named) column, and that `summary_json`'s description still names -`failed` — so the explanation cannot rot into a claim the schema no longer -supports. diff --git a/.changeset/automation-run-lifecycle-operator-door.md b/.changeset/automation-run-lifecycle-operator-door.md deleted file mode 100644 index 43fe91c10e..0000000000 --- a/.changeset/automation-run-lifecycle-operator-door.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -The two operator run-lifecycle verbs get a door: `POST /automation/:name/runs/:runId/cancel` and `POST /automation/:name/runs/:runId/restore-suspension`, both gated on the platform operator. - -`AutomationEngine` has carried `cancelRun` (ADR-0044) and `restoreConsumedSuspension` (#13909) for as long as either has existed, and neither was reachable by an operator: no REST route, no CLI command, and — until `IAutomationService` declared them — no way for a host holding only the service contract to call them either. The gap mattered most for the repair verb, which has no in-process caller by design: it is meant to be asked for by a person, and there was no way to ask. Maintainer ruling, 2026-09-05 (option A): both are platform-operator verbs, and listing and acting go through `sys_automation_run`, never engine memory. - -- **The gate is the ADR-0095 D2/D3 posture rung** (`posture === 'PLATFORM_ADMIN'`), required **unconditionally**, with the usual `isSystem` bypass so `plugin-approvals`' in-process revise-window recall is untouched. It is deliberately not the `positions[]` entry spelling the built-in: `sys_user_position` is `apiEnabled` with unconstrained values, so a tenant can mint that row. It is also deliberately not posture-conditional the way the ADR-0126 §5 activation gate is — that gate falls open under `single` because a `manage_metadata` tier still stands in front of it, and this door has no tier in front of it, so the same conditionality would open an operator verb to any authenticated caller on every single-organization deployment. Which routes is one predicate, read by the gate and by both route arms so they cannot drift. -- **Refusing fail-closed on an absent member is the door's own job.** Both verbs are optional members of the contract, as 13 of its 15 are. A service that does not declare one answers **501 `NOT_IMPLEMENTED`** naming the member — never a 200 carrying a lifecycle verdict for a verb that was never dispatched, and never the `{ handled: false }` fall-through that renders as a 404 with a discovery hint for a route discovery does not list. -- **Refusals are refusals.** The restore door maps the implementation's refusal code onto the statuses this same door already answers those conditions with on `resume` — `RUN_NOT_FOUND` 404, `STORE_UNAVAILABLE` 503, and the run-state conflicts 409. The contract types that code as `refusal?: string`, a covariant widening of the engine's closed eight-member union, so the mapping is a **non-exhaustive string switch by construction**: an unrecognised code — or a refusal carrying none — answers **500**, not one of the 409s, which would claim a diagnosis the door did not make. The vocabulary is neither narrowed nor extended at the call site. The code rides `details.refusal`, never `details.code`, so `error.code` stays inside the ADR-0112 closed catalog. -- **`requestedBy` comes from the authenticated caller, never the wire.** The repair verb's trace records who asked and why; a wire-settable `requestedBy` would let one operator write another's name into the record of who re-armed a terminally-failed run. The body envelope is closed to `{ reason? }` and refuses the key by name, so a caller who tries gets a loud refusal instead of the silent impression that it took. -- **No once-only side effect keys off `cancelRun`'s return, and the wire says why.** The engine has no cancel-side compare-and-set, so two overlapping cancels each answer `true` and each record the terminal log. This door fires no notification, writes no audit entry and announces no kernel event; the `true` answer carries a notice stating the non-exclusivity so a caller does not build that side effect one tier up. The `false` answer carries the other half: the contract's idempotent-success reading **and** the unreadable-store reading, which land on the same `false` and which nothing above the engine can tell apart. - -⛔ No lister ships here. A lister backed by the engine's in-memory journal answers zero in any process that did not itself strand the run, and a confident zero is the failure this class is about; a correct one is a query over `sys_automation_run` terminal rows and is its own card. ⛔ No CLI command either — the ruling declines one for want of pull. diff --git a/.changeset/automation-service-operator-verbs-contract.md b/.changeset/automation-service-operator-verbs-contract.md deleted file mode 100644 index 7e861c999d..0000000000 --- a/.changeset/automation-service-operator-verbs-contract.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare the two operator run-lifecycle verbs on `IAutomationService` — `cancelRun` and `restoreConsumedSuspension` (#16495, the contract half of #13953) - -`IAutomationService` (`contracts/automation-service.ts`) gains two OPTIONAL -members, typed as the engine already implements them rather than as the -ruling's `verb(runId)` shorthand, so a door calling through the contract can -say who asked and why: - -- `cancelRun?(runId: string, reason?: string): Promise` — end a - suspended run (ADR-0044's run-cancel primitive): `true` only when this call - consumed a suspension, `false` when none exists under the id (idempotent - success — and the answer an unreadable store lands on too, which the - implementation reports at `error`). -- `restoreConsumedSuspension?(runId: string, options?: { requestedBy?: string; reason?: string })` - answering `{ restored: boolean; runId: string; refusal?: string; reason: string }` - — the operator exit from a run a resume left terminally unresumable - (`AutomationResult.status: 'stranded'`, #13909 / #13937): puts the consumed - suspension back verbatim, replays no signal, undoes nothing, never resumes, - never throws. - -Both docblocks carry the #13953 ruling's persistent-face statement (maintainer -2026-09-05, decision batch #42): "listing and acting go through -`sys_automation_run` (the persistent face), never engine memory" — and its -permission posture: platform-operator verbs gated on the existing -`platform_admin` position, no new permission type, no per-run ownership. - -Additive. Both members are optional, so every existing implementation — -including the `{ execute, listFlows }` minimum the contract's own test pins — -still conforms, and the one non-test implementor (`AutomationEngine` in -`@objectstack/service-automation`) already satisfies both under `implements`. -The result of `restoreConsumedSuspension` is a deliberately NARROWER -structural shape than the engine's `SuspensionRestoreResult`: the engine's -eight-member refusal vocabulary stays with the engine, so `refusal` is typed -`string` on the contract (route (i); a second consumer that needs the -vocabulary is a spec card). No REST route, CLI command, lister or engine -behaviour moves in this change — #13953's services half owns the doors. A -service that does not declare a verb has no operator door for it, and a door -must probe for presence and refuse fail-closed when it is absent. diff --git a/.changeset/batch-row-unique-violation-metadata-protocol.md b/.changeset/batch-row-unique-violation-metadata-protocol.md deleted file mode 100644 index 918ad7a355..0000000000 --- a/.changeset/batch-row-unique-violation-metadata-protocol.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -fix(metadata-protocol)!: a batch ROW reports a unique-constraint refusal as `UNIQUE_VIOLATION` — the same wire spelling as the whole-request failure on the same route (#14723) - - - -**BREAKING** on the per-row report of `POST /api/v1/data/:object/batch` (and -the multi-object `POST /api/v1/batch`, which rides the same protocol): a row -refused by the engine's `DuplicateRecordError` envelope now reports -`errors[].code: 'UNIQUE_VIOLATION'` where it reported `'DUPLICATE_RECORD'`. -Shipped as `minor` under the repo's launch-window convention for breaking -changes. Maintainer ruling 2026-09-03 on #14723 (verbatim 「同意,然后执行契约 -复审」), adopting option A: one wire spelling for a unique-constraint refusal on -every route. - -**Why.** `toRowApiError` put a thrown REGISTERED code on the row verbatim, and -`DUPLICATE_RECORD` is registered, so a `DuplicateRecordError` row said -`DUPLICATE_RECORD` while the whole-request failure on the very same route (the -bulk door's classification in `@objectstack/rest`) answered `UNIQUE_VIOLATION` -— the standard-catalog member `content/docs/protocol/kernel/http-protocol.mdx` -documents for the 409 constraint-violation body. Since the bulk doors were -restored to `UNIQUE_VIOLATION`, the two spellings of one condition sat side by -side in one route's responses, which ADR-0112's one-name-per-concept and the -error-code ledger's own header both forbid. The duplication is removed, not -declared: no ledger waiver is added. - -**What changes.** The row derivation recognises the engine's envelope by the -same two-part gate the whole-request arm uses — the registered code AND the -class name `DuplicateRecordError`, never message text — and reports -`UNIQUE_VIOLATION`. Everything else on the row is unchanged: `httpStatus: 409`, -the platform sentence (no driver text, no bound value — the driver's error -stays on `cause` and never reaches the row), and the sibling `NOT_ATTEMPTED` / -`ROLLED_BACK` rows. - -**What does NOT change.** The engine's thrown identity: `DuplicateRecordError.code` -is still `DUPLICATE_RECORD` for an in-process caller of `engine.insert` / -`engine.update` (a hook, a flow node), and the objectql pins on `insert` / -`insertMany` hold. The single-record `/data` door, which has answered -`UNIQUE_VIOLATION` throughout, does not move. A producer that merely THROWS the -registered `DUPLICATE_RECORD` from its own body without being the engine's -class keeps its own code on the row, exactly as it does at the door. - -**Consumer note.** A batch client that branched on a row's `code` reading -`DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it -already handles for the whole-request 409 on that route and on the -single-record door. Measured in-repo and in the sibling repos (hotcrm, objectui, -non-test sources): zero consumers branch on either spelling of a row code. diff --git a/.changeset/better-auth-exact-family-pin.md b/.changeset/better-auth-exact-family-pin.md deleted file mode 100644 index ae4ac11d1b..0000000000 --- a/.changeset/better-auth-exact-family-pin.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -`@objectstack/plugin-auth` pins the `better-auth` family to an exact `1.7.2`, so a fresh install of a published `@objectstack/*` release loads the auth plugin again — and with it creates the system tables and seeds the admin. - -Published 17.1.0, 17.2.0 and 17.3.0 declared `"@better-auth/core": "^1.7.2"` and imported `createLocalAccountIssuer` / `createOAuthAccountIssuer` from `@better-auth/core/db`. `@better-auth/core@1.7.3` — a **patch** — deleted both names, and the `account.issuer` column behind them, because upstream rolled the issuer-scoped account identity back to opt-in (better-auth/better-auth#10909). A static ESM named import of a missing export is a link-time `SyntaxError`, so the plugin could not load at all. Every symptom followed from that one failure and every one of them was quiet: the scaffolded project's CLI printed the `SyntaxError` as a scrollable oclif warning and carried on, the server printed `✓ Server is ready` on the broken boot, `sys_user` / `sys_organization` / `sys_permission_set` / `sys_position` were never created, the seeded admin sign-in never answered, and the Console's sign-in form answered `Auth request failed with status 404`. - -**This is a stopgap, deliberately, and it is labelled as one.** Upstream removed the export on purpose; adopting 1.7.3 means dropping `sys_account.issuer` — a required column with a unique `(issuer, accountId)` index — from the platform object, retiring the boot-time backfill that stamps it, and migrating every existing deployment. That is its own change with its own decision to make; this one restores a working install today. - -All five members `plugin-auth` declares move together (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`), because they are only correct as one line: `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions. `better-auth@1.7.2` declares its own siblings exactly, so pinning those five resolves all twelve family members to 1.7.2 — measured on a fresh `npm install` with no lockfile. - -The workspace `overrides` move to the same exact target in step, so the version this repository tests is the version a consumer resolves. In-repo resolutions are unchanged: the lockfile already held 1.7.2 for all eleven overridden members. diff --git a/.changeset/blueprint-nav-view-name.md b/.changeset/blueprint-nav-view-name.md deleted file mode 100644 index 4471375bcd..0000000000 --- a/.changeset/blueprint-nav-view-name.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -A blueprint nav entry can say WHICH view it opens: `viewName` is added to `BlueprintNavItemSchema` and, in lockstep, to the strict mirror's `StrictNavItem` (required-but-nullable, per the strict convention). - -Without it the shape could only say which OBJECT an entry opens, so a model that had just designed a kanban and wanted it in the menu had one move left: emit a SECOND entry at the same `target` and carry the intent in `label`/`icon` alone. Both entries then opened the object's default view, and the consumer derived both ids from the target, so they collided — the user clicked 「工单看板」 and got the list, with nothing to see anywhere (the target object really exists, so a dangling-target lint has nothing to say). The runtime nav item could always express this — `ObjectNavItemSchema.viewName` is "Default list view to open" — so the gap was the blueprint's alone, and the model's duplicate entry was the reasonable move under the expressiveness it was given. - -`viewName` is deliberately NOT `.regex(SNAKE_CASE)` on either side, unlike `target`. A view answers to two interchangeable spellings — the bare key a blueprint's `views[].name` carries and the qualified `.` a staged view record's `name` carries — and consumers normalize between them. Constraining the leaf would make one spelling legal to GENERATE and illegal to APPLY, the failure mode that once refused an already-approved blueprint wholesale. - -The key-parity pin between the strict mirror and the lenient schema is widened a level further out — fields → objects → NAV ITEMS — so the next nav-level divergence fails a test rather than shipping as "the lenient side accepts a key no proposal can contain". diff --git a/.changeset/blueprint-strict-mirror-value-parity.md b/.changeset/blueprint-strict-mirror-value-parity.md deleted file mode 100644 index 2cb54a4e52..0000000000 --- a/.changeset/blueprint-strict-mirror-value-parity.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects. - -`SolutionBlueprintSchema` (what `apply_blueprint` validates against) and `SolutionBlueprintStrictSchema` (the OpenAI-strict structured-output contract the design model generates against) are two declarations of one shape. Their KEYS were pinned by an existing parity test; their VALUES had never been. Every identifier in the lenient schema carried `.regex(/^[a-z_][a-z0-9_]*$/)` and not one identifier in the strict mirror carried it — 20 leaves apart, measured. - -The consequence was a build whose approval did nothing. Asked for a CRM, the design model emitted a `company_size` select whose option values came straight off the labels — `1_49` for 「1-49人」. Generating that was legal. Applying it was not: on the turn the user clicked 「确认,开始搭建」 the deterministic confirm replay handed that exact blueprint to `apply_blueprint`, which refused it wholesale (`objects.0.fields.2.options.0.value: Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/`) and staged nothing. The app appeared only because the model noticed the error card and retried with a repaired blueprint the user had never seen. - -Every identifier leaf in the strict mirror now carries the same `SNAKE_CASE` constraint the lenient schema enforces — object / field / view / dashboard / widget / app / nav names, `reference`, `nameField`, `columns`, `groupBy`, `measure`, roll-up `object` / `field` / `relationshipField`, condition `field`, and select option `value`. The constraint is emitted into the JSON Schema the model is given (`pattern`), so an out-of-pattern identifier is refused at generation instead of after approval. Option `value` additionally spells out the case that produced the incident: it may never start with a digit, so 「1-49人」 is authored as `size_1_49` — the `label` keeps the human wording untouched, and only the stored value is an identifier. - -A new `strict mirror ↔ lenient schema — VALUE parity` test walks both schemas leaf by leaf and fails on any future divergence, the value-side twin of the key-parity gate that already guards this pair. - -Refs cloud#1967. diff --git a/.changeset/boot-sign-in-report-remedy-text.md b/.changeset/boot-sign-in-report-remedy-text.md deleted file mode 100644 index 2f330fb333..0000000000 --- a/.changeset/boot-sign-in-report-remedy-text.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -The `no_sign_in_account_at_boot` report now names a remedy that works — and warns off the one that silences the report itself. - -That boot line fires on the deployment nobody can sign in to: human `sys_user` rows, zero `sys_account` rows. It ended with two remedies, and measured on the exact population it fires on, neither did what its sentence said: - -- **"Open the audience posture so an existing person can register their own login"** produced no login, and for an existing person it never can: self-registration is a user-creation path, so it cannot attach a login to an address that already carries a `sys_user` row, whatever the posture. Widening only ever admits a *new* address — and then every posture other than `invite_only` forces `requireEmailVerification` on, so that login is refused `EMAIL_NOT_VERIFIED` at its first sign-in, and a locked-out self-hosted install is usually the shape with no mail transport wired. -- **"Write a `sys_account` credential row directly against the store"** was worse than useless. The `password` column carries a secret in the platform's own hash format, so a plaintext one authenticates nothing — and the probe behind this report asks only whether *any* `sys_account` row exists, so writing one turns the report off. The operator's first attempt at the named remedy turned the loud dead end back into the silent one the report was written to end. - -The line now names the path that was measured to work: write one pending `sys_invitation` row directly against the store — a lowercase address the directory does not already hold, `status` `pending`, a future `expires_at`, `inviter_id` of any existing `sys_user` — then register through the ordinary sign-up endpoint. The invitation carve-out admits that one creation under every posture, so no door needs widening. It is an admission verdict and not a verification bypass, though, so the line scopes what follows from that: only under the default `invite_only` posture is the recovery mail-transport-free, and it tells the operator to close a widened posture back to `invite_only` before the invited person registers — otherwise the invited login is created, refused `EMAIL_NOT_VERIFIED` at first sign-in, and has silenced this report on the way past. On the `single` tenancy posture that account holder is then promoted to platform admin. The other two are still named, as the two things that look like remedies and are not, because an operator who is going to hand-write a credential row anyway needs to know it blinds the probe. - -**Message text only — no admission semantics move.** Nothing widens, nothing narrows, no accept set changes, and the probe is untouched: this changes what an operator *reads*, not what the platform *admits*. The long form of the same three facts is on the self-hosting deployment page. diff --git a/.changeset/bulk-event-batch-organization.md b/.changeset/bulk-event-batch-organization.md deleted file mode 100644 index e82c5e2340..0000000000 --- a/.changeset/bulk-event-batch-organization.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a published `BulkDataEvent` now names the ONE organization the tenant wall named for the batch - -`BulkDataEventSchema.organizationId` (`@objectstack/spec/api`, declared by the -contract half) is one organization for a whole predicate write, or absent. The -only bulk producer — `publishBulkDataEvent`, behind the `multi: true` branches -of `update()` / `delete()` — never set it, so every `data.records.updated` / -`data.records.deleted` event read "not asserted" and a tenant-scoped consumer -could deliver nothing per organization on the bulk path. This is the bulk half -of the cross-tenant webhook fan-out leak; the single-record half (`DataEvent`) -landed separately. - -The producer now stamps the key from what it already holds — no second query -on the publish path: under `isolated` the caller's active organization (the -Layer 0 wall's equality term), under `group` the caller's membership set when -it names exactly one organization. It is OMITTED — never the caller's active -organization standing in — on a `single`-posture deployment, on an `isSystem` -context (no wall composed), on a multi-membership `group` sweep, when no -enforcement layer injected a posture (the `OS_TENANCY_POSTURE` env fallback is -deliberately not consulted), when the caller may have crossed the wall as a -`PLATFORM_ADMIN` or carries no resolved posture rung, and on an object the wall -does not key on. `absent` here means "the producer did not assert one -organization for the batch", deliberately NOT the `DataEvent` reading -"belongs to no organization". - -Which objects "the wall does not key on", stated exactly rather than claimed as -a mirror: plugin-security's Layer 0 composes no wall when its `tenancyDisabled` -input is true or the object carries no `organization_id`, and it folds THREE -clauses into `tenancyDisabled` — `tenancy.enabled === false`, -`systemFields.tenant === false`, and the deployment's `platformGlobalObjects` -carve-out. The producer reads the registry's binding of that predicate -(`carriesTenantScopeColumn`: the first two clauses plus the column clause) and -answers absent on a federated (`external`) object; a custom -`tenancy.tenantField` is therefore not an exit by itself — the object is walled -iff it carries `organization_id`, and the key follows the wall. The third -clause is deployment-declared and not readable by the engine: a -deployment-exempted object under an armed wall is still stamped with the -caller's organization by this producer alone, and that population's exact -answer is decided by the seam ruled on in #15706. - -`patch`, not `minor`: the act adds no member to this package's published -surface. `carriesTenantScopeColumn` is exported at module level inside -`registry.ts` only — `@objectstack/objectql`'s entries (`.`, `./core`) re-export -named members and never `export *`, so `dist/index.d.ts`, `dist/core.d.ts` and -both entries' runtime export lists are unchanged (measured on the built `dist`, -with a firing control) — and the emitted event's member was declared, typed -and paid for at `minor` by the spec half. Producer conformance to an existing -optional member under `fix(` changes no public surface of this package. diff --git a/.changeset/cast-blob-compile-option-claim.md b/.changeset/cast-blob-compile-option-claim.md deleted file mode 100644 index 62ff4360e6..0000000000 --- a/.changeset/cast-blob-compile-option-claim.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/driver-turso": patch -"@objectstack/driver-sql": patch -"@objectstack/service-analytics": patch ---- - -Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. - -Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. - -No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. - -Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. diff --git a/.changeset/chart-config-missing-overreach.md b/.changeset/chart-config-missing-overreach.md deleted file mode 100644 index c02a356bf6..0000000000 --- a/.changeset/chart-config-missing-overreach.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`chart-config-missing` no longer fires on a widget whose binding the renderer derives - -The rule warned on every chart-family widget that declared no `chartConfig`, on the -stated grounds that "the renderer cannot determine which measure to plot, so the series -renders empty". Measured against the `@object-ui` revision this repo pins -(`.objectui-sha`), that consequence is false: `DatasetWidget` derives the x-axis key and -one series per measure from the widget's own `dimensions` / `values` via -`buildChartSeries`, and refuses an authored `ChartAxis.field` / `ChartSeries.name` -outright — `chartConfig` carries presentation only. The renderer pins this by name: -"ignores an authored axis `field` and keeps the derived axis binding", "ignores an -authored series and keeps one derived series per measure", "emits none of the -presentation keys when no chartConfig is declared". - -The false finding was landing on this platform's own shipped metadata — the -`system_overview` dashboard's pie and bar tiles, on the Setup board every customer opens -first — which is the ADR-0072 D1 cost the rule family exists to avoid. - -The rule id is unchanged and keeps one true arm: a `combo` widget with no `chartConfig`, -whose per-series mark is authored as `chartConfig.series[].type` and has no other -channel, so every measure draws with the same default mark and the chart is not a -combination at all. Its message now names that consequence instead of the binding. -An existing `suppressWarnings: ['chart-config-missing']` entry stays valid. diff --git a/.changeset/chart-empty-selection-rules.md b/.changeset/chart-empty-selection-rules.md deleted file mode 100644 index 0a290341c1..0000000000 --- a/.changeset/chart-empty-selection-rules.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -Two new widget-binding rule ids for a chart widget with an empty selection - -`validateWidgetBindings` reported nothing about two dataset-bound chart shapes that the -`@object-ui` revision this repo pins (`.objectui-sha`) visibly degrades. Both are now -warnings, suppressible per widget with `suppressWarnings: ['']`: - -- `chart-measures-missing` — a chart-family widget selects no measures (`values` empty or - absent). `DatasetWidget.tsx:683` returns the authoring placeholder "Pick measures - (values) for this dataset widget." before any query runs, above every family branch, so - no chart is drawn at all. -- `chart-dimensions-missing` — a chart-family widget selects at least one measure but no - dimensions. `DatasetWidget.tsx:423` reads - `const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;`, so the - widget renders as a single KPI number and the declared chart family is silently ignored. - The hint steers the author to a dimension, or to the `metric`/`kpi` family that matches - what actually renders. - -Warning tier rather than error for both: an empty selection is a work-in-progress state a -build must tolerate, and erroring would gate the `sys_metadata` publish path on a -half-authored widget. Neither shape is folded into `chart-config-missing` — neither is -caused by, nor repairable with, `chartConfig`, which carries presentation only. - -"Chart family" is derived, not hand-listed: every declared `ChartTypeSchema` option that -the pinned renderer routes to its chart branch — the taxonomy minus the renderer's own -`METRIC_TYPES` (`metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`) and its `table`/`pivot` -tabular test. A `metric` tile with no dimensions, such as the shipped `system_overview` -board's own KPI tiles, is therefore not a finding. diff --git a/.changeset/chart-field-unknown-refused-binding-tier.md b/.changeset/chart-field-unknown-refused-binding-tier.md deleted file mode 100644 index f18c3f48ca..0000000000 --- a/.changeset/chart-field-unknown-refused-binding-tier.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -`chart-field-unknown` drops to `warning` on the three `chartConfig` binding keys the pinned renderer refuses, and says what actually happens - -The rule id covers exactly three positions, and the `@object-ui` revision this repo pins (`.objectui-sha`) refuses all three as bindings, so none of them can produce the data failure the messages described: - -- `chartConfig.xAxis.field` — `axisPresentation` (`@object-ui/core` `src/utils/chart-presentation.ts`) builds the axis presentation **minus** its `field`. The x-axis key is `buildChartSeries`' `xAxisKey`, i.e. the widget's `dimensions[0]`; an authored `field` re-points nothing. -- `chartConfig.yAxis[].field` — the same call, per entry. The entry keeps its slot (the count is what turns on a secondary axis) and its scale and chrome; only the binding is dropped. -- `chartConfig.series[].name` — `mergeAuthoredSeries` pairs an authored entry with the derived series whose `dataKey` it equals, one per entry of `values`. An entry naming no derived series is ignored whole, so the presentation hung on it — the mark, the colour, the stack, the axis side — lands on nothing. - -The renderer pins this by name in `DatasetWidget.chartConfig.test.tsx` ("ignores an authored axis `field` and keeps the derived axis binding", "ignores an authored series and keeps one derived series per measure"). - -So the old message — "the query result will not contain it" — named a query failure that never happens, and `error` blocked a build and a Studio publish for a key that changes nothing at runtime. That is the class `widget-legacy-analytics-shape` reports at `warning` in the same file ("the dashboard renderer ignores them … a silent no-op"), and this id now carries the same tier, the same suppressibility (`suppressWarnings: ['chart-field-unknown']` per widget) and the same kind of sentence. Each message states its own consequence, because the axis positions and the series position are refused for different reasons. - -The finding is **kept**, not deleted: unlike the `chart-config-missing` over-reach this measurement came from, the metadata really is wrong — the author wrote a binding and believes it is in force. - -## Migration - -**A publish that used to be refused now succeeds.** Ruled 2026-08-15, `validateWidgetBindings` put its whole error set on the `sys_metadata` publish door (Studio / REST `/meta` / MCP) as one "this board cannot render" reference-integrity class. That class was six ids and is now five — `chart-field-unknown` has left it. A dashboard write whose only reference-integrity problem is a refused `chartConfig` binding key is no longer a 422 `INVALID_METADATA`; it publishes, and the finding rides the non-blocking `advisories` channel on the 2xx response instead. The other five (`widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`) are unchanged. - -Same direction on the CLI: `os validate` / `os build` / `os lint` report the finding at `warning`, so a stack that used to fail the build over one of these keys now exits 0 with an advisory. If you were relying on the build to stop on it, add the key to your own gate, or fix the binding — the fix has not changed: - -- point `xAxis.field` at a dimension the widget selects (or drop the key — `xAxis` carries presentation only); -- point `yAxis[].field` at a selected measure (or drop it — `yAxis[]` carries presentation only); -- name a selected measure in `series[].name`, remembering that post-cutover (ADR-0021) result rows are keyed by the dataset's measure **name** (`sum_amount`), not the base column (`amount`). - -A deliberately inert key can be silenced per widget with `suppressWarnings: ['chart-field-unknown']`. diff --git a/.changeset/chart-measure-unknown-presentation-positions.md b/.changeset/chart-measure-unknown-presentation-positions.md deleted file mode 100644 index 4f1d16e175..0000000000 --- a/.changeset/chart-measure-unknown-presentation-positions.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`chart-measure-unknown` no longer blocks a build over a chart `series[].name` (or a page chart's `yAxis[].field`) that names nothing — those positions are presentation, and the message now says so. - -The rule fired at `error` on every measure position of the three chart surfaces it covers, with one consequence sentence: *"result rows are keyed by MEASURE NAME … so this series comes back empty"*. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), that is true only where the position feeds the dataset query, and the three surfaces do not agree: - -- **Report charts** run the chart's own query out of the two axis strings (`useDatasetRows(dataset, [xAxis], [yAxis], …)` — *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*), so `chart.xAxis`/`chart.yAxis` are the binding. `chart.series[]` is *"the author's per-chart override for ONE measure's display name"*, lowered through `mergeAuthoredSeries`, where *"an authored entry naming a measure that is NOT in the dataset selection is **ignored** — membership belongs to the dataset"*. -- **List-view charts** have no presentation position at all: `ListChartConfigSchema` is a strict object of `chartType`/`dataset`/`dimensions`/`values`, and `values[]` is handed to the chart as the dataset measures. -- **Dataset-bound page chart components** query `{ dimensions, measures: values }` and then replace the authored series wholesale with one derived entry per selected measure, so `properties.series[].name` reaches the renderer not at all and `properties.yAxis[].field` re-points nothing. - -**Behaviour change users see:** the three presentation positions — report `chart.series[].name`, page-component `properties.series[].name` and `properties.yAxis[].field` — drop from `error` to `warning`. A build or a metadata publish that used to be refused because of one of them now succeeds, with the finding on the advisory channel. The finding is KEPT, not deleted: the metadata really is wrong — the author wrote a key and believes it is in force. Every query position (report `chart.yAxis`, and `values[]` on all three surfaces) keeps `error` and its existing message verbatim. - -Two smaller corrections ride along, both from the same read: - -- The page surface's `yAxis[].field` refs are no longer concatenated into the `series[]` limb before the measure walk, so an axis position no longer takes the series message. Reading both shapes on that surface stays deliberate; giving them one sentence was not. -- `chart-axis-not-selected` (a declared measure outside the selection) took the same one-size consequence, *"the query does not return it, so the series plots nothing"*. It keeps that wording at a query position and states the real one at a presentation position, where no series is derived for the name in the first place. - -Note that none of these three surfaces declares `suppressWarnings` — it is a dashboard-widget key — so the new advisories cannot be individually silenced; the hint says so instead of pointing at a key that does not exist. diff --git a/.changeset/claim-capability-probe-before-mutate.md b/.changeset/claim-capability-probe-before-mutate.md deleted file mode 100644 index 96c34c184b..0000000000 --- a/.changeset/claim-capability-probe-before-mutate.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -`ObjectStoreSuspendedRunStore` no longer announces "no cross-replica advance guarantee is offered" *after* it has issued the guarded delete. - -`claimSuspension` is the cross-replica half of the resume idempotency guard: it removes the `sys_automation_run` row only if the run is still parked where this replica read it, and the affected-row count names the winner. The refusal for an engine that does not resolve such a count was decided on the SHAPE of the return value — one line after the compare-and-set had already gone out. On such an engine that made the refusal a statement about a write that had already landed: the conditional delete was performed against the shared row and its verdict discarded, `AutomationEngine.claimAdvance` read `'unsupported'` as `unguarded`, and a replica that **actually lost** the claim (0 rows affected) resumed anyway — running every downstream side effect a second time, on the one composition that declares itself unable to prevent that. - -The capability question is now settled before anything is claimed, and `'unsupported'` is retired as an answer once the row has been touched: - -- **A one-time capability probe, before the compare-and-set.** Once per store instance, `claimSuspension` issues one delete down the very route the claim takes (`multi: true` with a `where` carrying keys besides `id`, which is what dispatches to `driver.deleteMany`) against a sentinel predicate that matches no row — the same value in `id`, `node_id` and `correlation` at once. An engine that resolves something other than a count is refused with **nothing consumed**, so `claimAdvance`'s `unguarded` reading is true when it is taken. Concurrent first claims share one probe, and a probe that *throws* is deliberately not memoized: a store that was unreachable for one second must not answer for the life of the process. -- **After the write, an unreadable verdict is `STORE_UNAVAILABLE`, not `unguarded`.** If a probed-counting engine still resolves a non-count for a real claim, the compare-and-set is committed and its verdict is unrecoverable — a winner and a loser both find the row gone, so no follow-up read can tell them apart. The store throws instead of answering `'unsupported'`; `claimAdvance` already maps that to `STORE_UNAVAILABLE`, whose text is written for exactly this fact ("a failure can arrive after a committed delete"), and the resume is **refused** rather than continued. A claim that in fact won is then stranded until an operator retries — the deliberate direction, since a doubled side effect is the worse outcome. - -**What this does not do, stated so it is not read into it.** It does not give an uncounted engine the guarantee. `ObjectQL.delete` declares `Promise`, so "does a multi-delete return a count" has no contractual answer to look up and no read-only instrument to measure — a probe can observe the route once, never promise what the next call resolves to. Closing that gap belongs to the engine boundary, where the count is contracted one layer down (`IDataDriver.deleteMany`, `Promise`) and erased to `any` on the way up. On such a composition the store still degrades to an unguarded resume; what changed is that it says so before consuming anything, and the run's durable row is still removed by the consumption choke point exactly as before. - -Every measured shipped composition already resolves a count (memory, sql/better-sqlite3, sqlite-wasm, turso local and remote transport, sql with the security plugin composed), so the observable cost there is one extra `DELETE … WHERE` that matches nothing, once per process. It emits no hook dispatch, no realtime event and no row change: the per-row before phase is "zero matched rows is zero dispatches", the after phase iterates the same empty set, and `publishBulkDataEvent` returns at `matched === 0` by design. diff --git a/.changeset/classified-lookup-keeps-its-scope.md b/.changeset/classified-lookup-keeps-its-scope.md deleted file mode 100644 index 32522a1a40..0000000000 --- a/.changeset/classified-lookup-keeps-its-scope.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -A scoped service that has no instance for your environment no longer answers as if you had forgotten to name one. - -`HttpDispatcher`'s classified service lookup — the read behind the identity step, the `POST /keys` mint gate, the install-wide activation write and `POST /automation/:name/toggle` — took the scope it was handed, missed on it, and then re-resolved on the request's own kernel **without** that scope. A service registered `ServiceLifecycle.SCOPED` and resolved without a scope id is rejected by the plugin loader with `Scope ID required for scoped service ''`, and that rejection is not the branded "never registered" the lookup absorbs — so it was re-raised, and each of those four doors answered `503 SERVICE_UNAVAILABLE` on a deployment where nothing was unwell. A caller that passed its environment correctly was told it had passed nothing. - -Concretely: a `tenancy` factory that serves one environment and legitimately returns `undefined` for another made every one of those four doors fail for the second environment — no API key could be minted, no activation switch flipped, and the identity step itself raised the outage. - -- **The scope now travels with every leg of the chain**, which is what the leg before it and the fallback tail already did. Nothing else about the resolution order changes: which registry answers is unchanged, only whether it is asked the question the caller actually asked. -- **The lookup tells its three answers apart.** "Nothing was ever registered under this name" and "this name is registered and produced no instance in the scope you passed" are two different facts. They still license the same quiet `undefined` at the door — a factory that returns `undefined` for a scope has *answered*, so it is an absent fact rather than an unread one, and ADR-0093 D4/D5 reads a scope with no tenancy service the way it reads a deployment with none — but they are no longer the same answer inside the lookup. -- **The loader's message is untouched, and so is the caller it is about.** A door that really resolves a scoped service without a scope still receives `Scope ID required for scoped service ''` and still answers 503. That direction is pinned explicitly, because an implementation that answered every scoped miss with `undefined` would fix the misattribution by deleting a correct diagnostic. - -No exported type changes: `DomainHandlerDeps.resolveServiceOrLoud` keeps its signature and keeps answering the service or `undefined`. diff --git a/.changeset/cli-explain-catalog-parses-against-spec.md b/.changeset/cli-explain-catalog-parses-against-spec.md deleted file mode 100644 index 238ce06fb9..0000000000 --- a/.changeset/cli-explain-catalog-parses-against-spec.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os explain` stops teaching shapes the spec rejects: all seven remaining broken catalog entries are corrected, and the sweep's xfail ledger is now empty. - -The catalog in `packages/cli/src/commands/explain.ts` is hand-maintained and does not derive from the spec, so its examples drifted behind the schemas they claim to demonstrate. The sweep landed for #14811 parses every entry's `example` against its real schema and pinned each failure as an `it.fails` xfail naming a card. This corrects all six that carried one, disposes of the seventh (an entry with no type at all), and promotes every xfail to a plain assertion — leaving the ledger empty, so the identical error cannot return silently. - -- **`object`** — select `options` are sampled as objects. A bare `['open', 'closed']` was rejected twice over (`expected object, received string`); each option is `{ label, value }`, where `value` is the stored lowercase machine identifier. -- **`field`** — the prose half of the same defect, which the sweep structurally cannot see: the optional-property table documented `options` as `type: 'string[]'`, and that row is where the `object` example's error came from. It now names `SelectOption[]` and spells the member shape out, and the entry's example demonstrates the real option list instead of a plain text field. -- **`view`** — the entry taught the wrong LEVEL, not a drifted key name. `ViewSchema` is the per-object view CONTAINER (`list` / `form` / `listViews` / `formViews`); a flat list-view literal is rejected wholesale. The tables now document the container, and the example shows a single view's own keys inside a slot. -- **`agent`** — `tools` was removed in `@objectstack/spec` 17 with no key its value moves to (ADR-0064: an agent reaches exactly the tools its surface-compatible skills declare). The entry now teaches `skills`, `model` as the configuration OBJECT it is, and the required `label` / `instructions` it had listed as optional or omitted. Two rows naming keys the schema does not have (`objects`, a top-level `temperature`) are gone. -- **`app`** — navigation items carry `id` plus the discriminant's own target key (`objectName` / `dashboardName`), not a bare `object` / `dashboard`. The table's `logo` and `defaultRoute` rows were the same class of error — neither is an `AppSchema` key, and `logo` is now rejected by name in favour of `branding`. -- **`dashboard`** — widgets bind a `dataset` and select `dimensions` / `values` by name (ADR-0021); the pre-ADR-0021 inline analytics shape (`object` + `groupBy` + `aggregate`) was removed, and `'chart'` is not a widget type at all — the enum names the concrete mark. `label` and `widgets` move to required, where the schema has them, and the non-existent dashboard-level `layout` row is dropped for the per-widget `layout: { x, y, w, h }`. -- **`trigger`** — not a wrong example but an entry with no type to check against. ADR-0088 §1 retired the `trigger` metadata kind and records that its enum comment referenced a `TriggerSchema` that never existed. The entry becomes an explicit redirect, the same shape the `workflow` entry already uses, pointing at the two delivered mechanisms the ADR names: a `hook` for synchronous in-transaction data-layer logic, a `record_change` flow for asynchronous automation. Its `docsPath` moves off a page that does not exist. -- **`action`** — `object` is `objectName`, `confirmation` is `confirmText`, and a `type: 'flow'` action names its flow in `target` (there is no `flow` key, and none was suggested, which is why the entry could not simply be renamed). The required table also advertised a `"button"` action type that is not in the enum, and a `url` key the schema does not have. diff --git a/.changeset/cli-explain-dashboard-refresh-interval-seconds.md b/.changeset/cli-explain-dashboard-refresh-interval-seconds.md deleted file mode 100644 index 148570312c..0000000000 --- a/.changeset/cli-explain-dashboard-refresh-interval-seconds.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `explain` names the renamed `dashboard.refreshIntervalSeconds` (#14478) - -The dashboard key catalogue `os explain` prints lists -`refreshIntervalSeconds` instead of `refreshInterval`, following the -`@objectstack/spec` rename of the authored key (the unit now lives in the key -name). Same key, same seconds; no other command output and no public surface of -this package changes. diff --git a/.changeset/cli-hook-body-subpath-export.md b/.changeset/cli-hook-body-subpath-export.md deleted file mode 100644 index af97e82057..0000000000 --- a/.changeset/cli-hook-body-subpath-export.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -Ratify `./hook-body` as a public subpath export — `extractHookBody`, `HookBodyExtractionError`, `HookBodyRefusalKind` and `ExtractedBody` were reachable as a deep `dist/utils/extract-hook-body.js` import until #13123 sealed the surface, and an app's hook-body fidelity harness (hotcrm's `test/helpers/action-sandbox.ts`) consumes them to run the SAME body-only lowering `os build` ships through the real QuickJS runner, so a test executes what production executes rather than a lookalike. The #13123 body names exactly this remedy for an out-of-repo consumer — ratify the subpath as public surface rather than read `dist/` paths — and 17.3.0 applied it to `./console` for cloud's `objectos-runtime`; this applies it to the second consumer (#15325). `@objectstack/cli/hook-body` is a dedicated entry that re-exports those four names and nothing else; the deep `dist/` path stays sealed. Also admits `./package.json`, so the ordinary tooling idiom of reading a dependency's own manifest resolves again. - -`minor`, not `patch`: a new subpath on a published package's `exports` map is a purely additive widening of its public surface — a new accepted key — which takes at least `minor` under the maintainer's 2026-09-04 rule (decision batch #35, on #15294) in the Check Changeset step's "WHICH LEVEL" prose; the commit type never lowers it. diff --git a/.changeset/cli-lint-strict-warnings-fail.md b/.changeset/cli-lint-strict-warnings-fail.md deleted file mode 100644 index 62a098d4ae..0000000000 --- a/.changeset/cli-lint-strict-warnings-fail.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os lint --strict` makes warning-severity findings fail the run, so an app can rely on the platform's warning-level rules as its gate instead of re-implementing them locally (#15935) - -Only an `error` failed `os lint` before. `packages/lint` ships ≈250 authoring rules, 119 of them at `warning`, and a run with any number of warnings and no errors exited 0 — so an app that wanted one of those rules to gate its CI had to re-implement it locally at error level, or bolt a script onto the JSON output to promote a family by hand. - -New public flag: **`os lint --strict`**. With it, a run with one or more `warning`-severity findings exits 1 exactly as an `error` does, and the console says why, naming the count and the flag: - -``` -✗ 1 warning(s) fail this run under --strict (a warning is advisory without the flag) -``` - -`suggestion`s stay advisory under both. ⛔ The default is unchanged: without the flag the same stack still exits 0, and no existing `os lint` expectation moves. - -The `--json` face carries the verdict so a gate can read it without re-deriving it from the counts. Two keys, unconditionally present on every project-lint payload, flag or no flag: - -```json -{ "passed": false, "errors": 0, "warnings": 1, "suggestions": 0, "strict": true, "failing": 1 } -``` - -`strict` says whether the flag was in effect; `failing` is the count the exit code was read from — `errors`, or `errors + warnings` under `--strict`; and `passed` is `failing === 0`, the same statement the exit code makes — so `--strict --json` on a warning-only stack reads `passed: false` beside exit 1, never `passed: true` next to a failing exit. - -Not in this change: per-rule severity configuration, any change to a rule's severity, and `--eval` mode, which keeps its own pass bar (`--eval-min`). diff --git a/.changeset/cli-manifest-id-parse-through-schema.md b/.changeset/cli-manifest-id-parse-through-schema.md deleted file mode 100644 index 4cc6bf4c72..0000000000 --- a/.changeset/cli-manifest-id-parse-through-schema.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os package publish` now decides what a manifest id is by parsing it through `PackageSchema.manifestId` — the schema for the very column it publishes into — instead of testing it against a hand-copied look-alike. - -The command carried its own rule (`MANIFEST_ID_RE`, a case-insensitive "starts alphanumeric, then any of a-z 0-9 dot underscore hyphen, up to 255 chars"), which is looser than the declared contract on every axis. The local preflight therefore **admitted what the control plane refuses**: a single segment (`crm`), an underscore (`com.acme.repair_desk`), upper case (`COM.ACME.CRM`), a digit-first segment (`9foo.bar`), an empty segment (`com..acme`) and a trailing dot (`com.acme.`). The preflight passed, the request went out, and the server answered `400`. Its error text, when it did fire, named a contract (`a-z0-9._-`) that does not exist — so a user who followed the message walked into a second refusal. - -- **One rule, both paths.** `MANIFEST_ID_RE` is deleted. The explicit `--manifest-id` / `objectstack.manifest.json` path and the derive path (`deriveManifestId`, which adopts `artifact.manifest.id`) now ask the same imported schema. They previously disagreed with each other as well as with the declaration: the derive path additionally required a dot, so a bare `crm` was blocked there and accepted on the explicit path. That extra condition is gone because the schema subsumes it — its pattern requires at least two segments. -- **The refusal text is quoted from the schema**, from its own `invalid_format` issue plus its `.describe()`, so it can no longer drift from the rule it describes. -- **A derived id the schema rejects is refused, not rewritten.** `slugify` has no letter-first rule, so an app named `2024 App` derives `local.2024-app` — digit-first, and rejected. That is now refused before any network call, with a message naming where the id came from and how to set one (`--manifest-id`, `manifestId` in `objectstack.manifest.json`, or `manifest.id`). It is deliberately not normalised into some other id: `manifestId` is immutable once published, and minting a different permanent global identifier than the inputs imply is worse than saying what is wrong. - -Publishing is unaffected for every id the control plane accepts — a legal reverse-domain id passes both paths with unchanged bytes. What changes is that the ids the server was going to reject are now refused locally, with the real rule in the message. diff --git a/.changeset/cli-migration-audit-stamp-nullability-and-default.md b/.changeset/cli-migration-audit-stamp-nullability-and-default.md deleted file mode 100644 index 988625a161..0000000000 --- a/.changeset/cli-migration-audit-stamp-nullability-and-default.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os generate migration`: the builtin `created_at` / `updated_at` columns now match `driver-sql` on nullability and default text, and the SQL format declares itself PostgreSQL-only. - -Both formats emitted `NOT NULL` on the two audit-stamp columns while the driver creates them nullable, and the SQL format spelled their default `now()` while both knex producers emit `CURRENT_TIMESTAMP`. Nothing failed either way, but `information_schema` kept the pair textually apart forever, so a schema diff between a generated table and a platform-created one was permanently noisy. Both generators now follow the driver — the same rule the `id` column already follows — and `--format sql` states in its help text and its docblock that it targets PostgreSQL only and makes no MySQL or SQLite claim. diff --git a/.changeset/cli-package-publish-help-local-dev-example.md b/.changeset/cli-package-publish-help-local-dev-example.md deleted file mode 100644 index 6d22c35109..0000000000 --- a/.changeset/cli-package-publish-help-local-dev-example.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os package publish --help` no longer points its local-dev example at a directory this repo does not have. - -The last line of the command's `EXAMPLES` block read: - -``` -$ OS_CLOUD_URL=http://localhost:4000 os package publish # local dev (apps/cloud) -``` - -`apps/cloud` was deleted from this repository — the reference cloud host now lives in `objectstack-ai/cloud` — so the parenthetical sent a reader to a path that is not in the tree they cloned. This is help text, not a source comment: it is printed verbatim to anyone who runs the command. - -The parenthetical is dropped rather than re-pointed at the other repo. The example is about `OS_CLOUD_URL` overriding the control-plane URL, which the `--server` flag already documents in the same output; which directory happens to serve `localhost:4000` was never part of what the example teaches, and a `--help` reader is not looking for a file in a monorepo. `# local dev` alone carries it, and it now matches how the CLI reference docs have long published the same example. - -No behaviour changes: `examples` is a static help string, and no flag, argument, default or exit code moves. diff --git a/.changeset/cli-plugin-word-disambiguation.md b/.changeset/cli-plugin-word-disambiguation.md deleted file mode 100644 index 0fe0a2fc3a..0000000000 --- a/.changeset/cli-plugin-word-disambiguation.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -docs(cli): give the two `plugin` artifacts their own nouns, and rewrite "Which scaffolder?" as a two-question decision (#16484, #15531) - -`plugin` names two different artifacts in this CLI, and neither the help text -nor the docs said which one a reader was about to get: - -- `os init -t plugin` scaffolds a **metadata package** — declarative - objects another stack loads, built by `objectstack compile`, emitted - `private: true`. -- `os create plugin ` scaffolds a **kernel code plugin** — TypeScript - implementing the kernel `Plugin` contract, built by `tsc`, publishable as - `@objectstack/plugin-`. - -Someone who wanted a "plugin skeleton" and reached for the nearer of the two got -the wrong artifact, with no failure anywhere to tell them so — the metadata -package has no `Plugin` to implement, and the kernel code plugin has no -declarative objects to compile. - -**No flag and no subcommand is renamed.** `-t plugin` and `os create plugin` are -published surface and are spelled exactly as before; renaming them is a separate -decision, not this change. What moved is the NOUN each surface uses for the -artifact, so the two shapes stop sharing one word: - -- `os init --help` now reads `Template: app, plugin (a metadata package), empty`, - and the `plugin` template describes itself as - `Metadata package: declarative objects another stack loads` rather than - `Reusable plugin with objects`. -- `os create`'s `plugin` template describes itself as a **kernel code** plugin. - -The "Which scaffolder?" guidance in `content/docs/deployment/cli.mdx` is now a -two-question decision — *metadata or kernel code?* then *a new project, or an -addition to a directory you already have?* — landing on exactly one of the four -entry points, each with the reason to pick it: `npm create objectstack@latest` -(equivalently `npx create-objectstack`), `os init`, `os init -t plugin`, -and `os create plugin `. `os create example` is deliberately absent: it was -retired in #16483. diff --git a/.changeset/client-auth-family-wire-shape-binding.md b/.changeset/client-auth-family-wire-shape-binding.md deleted file mode 100644 index b7216f2cc2..0000000000 --- a/.changeset/client-auth-family-wire-shape-binding.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client)!: the `auth.*` family declares the wire shapes better-auth actually sends — thirteen published `Promise< any >` returns narrowed (#14313) - -**BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is. - - - -Card 2 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, and every timestamp is declared as the ISO-8601 `string` the wire carries — no `Date`, no revival layer. - -## What changed - -Thirteen `auth.*` methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change (35 entries before, 22 after): - -| method | resolved to (before) | resolves to (now) | -|:--|:--|:--| -| `client.auth.updateUser(data)` | `any` | `AuthStatusReceipt` | -| `client.auth.changePassword(req)` | `any` | `AuthPasswordChangeResult` | -| `client.auth.setInitialPassword(req)` | `any` | `AuthSetInitialPasswordResult` | -| `client.auth.changeEmail(req)` | `any` | `AuthStatusReceipt` | -| `client.auth.sendVerificationEmail(req)` | `any` | `AuthStatusReceipt` | -| `client.auth.verifyEmail(params)` | `any` | `AuthEmailVerificationResult` | -| `client.auth.sessions.revoke(token)` | `any` | `AuthStatusReceipt` | -| `client.auth.sessions.revokeOthers()` | `any` | `AuthStatusReceipt` | -| `client.auth.sessions.revokeAll()` | `any` | `AuthStatusReceipt` | -| `client.auth.twoFactor.verifyTotp(req)` | `any` | `AuthTwoFactorVerificationResult` | -| `client.auth.twoFactor.disable(req)` | `any` | `AuthStatusReceipt` | -| `client.auth.twoFactor.verifyBackupCode(req)` | `any` | `AuthTwoFactorVerificationResult` | -| `client.auth.accounts.unlink(req)` | `any` | `AuthStatusReceipt` | - -`AuthWireUser`, `AuthStatusReceipt`, `AuthPasswordChangeResult`, `AuthEmailVerificationResult`, `AuthTwoFactorVerificationResult` and `AuthSetInitialPasswordResult` are newly exported from `@objectstack/client`. Twelve of these routes are served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap and none is introduced; `setInitialPassword` is ObjectStack's own mount and answers the platform's `{ success: true }` envelope. - -## The exact reads that stop compiling - -Everything below compiled before only because `any` is assignable to, and indexable by, everything. - -```ts -const r = await client.auth.updateUser({ name: 'Ada' }); -r.user; // now TS2339 — the route answers `{ status: true }`, NOT the updated user -r.data; // now TS2339 — these routes carry NO envelope - -const cp = await client.auth.changePassword({ currentPassword, newPassword }); -cp.user.createdAt.getTime(); // now TS2339 — the wire sends an ISO-8601 STRING, not a Date -new Date(cp.user.createdAt); // the correct rewrite -cp.token.length; // now TS18047 — `token` is `string | null` (null unless other sessions were revoked) - -const v = await client.auth.verifyEmail({ token }); -v.user.email; // now TS18047 — `user` is `AuthWireUser | null` (null on a plain verification) - -const ok = await client.auth.setInitialPassword({ newPassword }); -ok.status; // now TS2339 — ObjectStack's mount answers `{ success: true }`, not `{ status }` - -const t = await client.auth.twoFactor.verifyTotp({ code }); -t.user.locale; // now TS2339 — ObjectStack's own sys_user columns are not on better-auth's wire user -``` - -A caller that only read `status`, `success`, `token` (guarding `null`) or the base user columns needs no change. - -## Timestamps: ISO-8601 `string`, never `Date` - -`AuthWireUser.createdAt` / `updatedAt` (and `banExpires`) are the vendor's `Date`-typed fields. The adapter is declared `supportsDates: false`, better-auth revives the stored string into a `Date` server-side, and `JSON.stringify` puts an ISO-8601 string back on the wire — measured `"createdAt":"2026-09-07T07:02:20.593Z"` on a real SQL driver. They are declared `string`, a type-level pin holds them there, and no revival layer exists in the SDK. - -## Where the vendor's own declarations were the wrong answer - -- `updateUser`'s OpenAPI stub promises `{ user }`; its handler answers `{ status: true }` and puts the new fields into the session cookie. The receipt is what is declared. -- `verifyEmail`'s stub declares `user` required; the handler answers `user: null` on a plain verification and the updated user only on a change-email verification. -- A nullable column (`image`, `banReason`, `banExpires`) arrives as `null` on the SQL drivers and as an ABSENT key on a store that does not materialise unset columns — both measured — so each is `?: … | null`. - -## `auth.deleteUser` is deliberately NOT bound - -The fourteenth method keeps its `Promise< any >` and its ledger entry. Its route is switched off by maintainer ruling (2026-08-12 on #7735; `auth-route-ledger.ts` books it `disabled`), and measured against a real server it answers HTTP 404 with a ZERO-BYTE body once the last-local-credential guard is satisfied — so `this.fetch` throws before `res.json()` ever runs and the method has no success path a caller can observe. No declared return type can be honest for a value the runtime never delivers. That the shrink-only ledger still carries exactly this one `auth.*` entry is the mechanism working. diff --git a/.changeset/client-honours-data-prefix.md b/.changeset/client-honours-data-prefix.md deleted file mode 100644 index 7ce48644af..0000000000 --- a/.changeset/client-honours-data-prefix.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/client": patch ---- - -The client SDK reads the CRUD data prefix off the discovery document instead of restating `/data` as a literal, so a deployment that sets a non-default `crud.dataPrefix` is reachable through the scoped surface. - -`crud.dataPrefix` moves two things together: REST mounts every CRUD route under `${basePath}${crud.dataPrefix}`, and the discovery handler advertises the same value as `routes.data = ${realBase}${crud.dataPrefix}`. The SDK is the third surface describing those same paths, and its scoped half was not reading the value — it wrote `/data` into all seventeen of its data methods. On a deployment that moved the prefix, that half called paths the server does not mount, while the unscoped half of the *same* SDK called the right ones: the unscoped methods build `${baseUrl}${getRoute('data')}` and `routes.data` already carries the prefix. One SDK disagreed with itself about where the data routes are. - -- **`_dataPrefix()` recovers the prefix from the advertised routes.** `routes.data` is one string carrying two unknowns (`{realBase}{dataPrefix}`) and no discovery key carries either half alone, so the split is recovered in two steps. A value that already ends with the conventional `/data` *is* the default prefix — taken first, which is what makes the change unable to regress any deployment that works today: every later rule can only run in the branch where the previous single-literal code was already wrong. Otherwise `routes.metadata` supplies the missing equation, being `{realBase}{metadata.prefix}` over the same base, so the two advertised routes share exactly `realBase` plus whatever their prefixes share; cutting that common run back to its last `/` lands on the boundary. This also covers a document served from the environment-scoped mount, where both routes carry the same `/environments/{id}` segment. -- **It declines rather than guess.** Where the document does not determine the split — no advertised routes, no `routes.metadata`, or a derived prefix of `/` or empty — the derivation returns the conventional `/data`, which is byte-identical to the previous behaviour. This follows the rule the neighbouring `_apiBase()` already sets in this file, and it is why an unconnected client is unaffected. -- **`_apiBase()` strips the advertised prefix instead of the literal `/data`.** It previously declined whenever the prefix was non-default, because the only suffix it knew how to strip was `/data`. It now strips whatever `_dataPrefix()` read, so the base and the prefix are derived by one rule and cannot disagree. On every default-prefix deployment the result is unchanged. - -No new client option and no new configuration: the value is read from the server that already publishes it. A client that never calls `connect()` builds exactly the URLs it built before. diff --git a/.changeset/client-oauth-delete-zero-byte-200.md b/.changeset/client-oauth-delete-zero-byte-200.md deleted file mode 100644 index 0b9eed9d49..0000000000 --- a/.changeset/client-oauth-delete-zero-byte-200.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client)!: `oauth.applications.delete` resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete (#15451) - -**BREAKING** on two independent axes, and it makes a published method usable for the first time. Before this change `client.oauth.applications.delete(id)` **rejected on every successful delete** — there was no success path a caller could observe. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`); the version number is not the migration signal here, this entry is. - - - -The fifth and last method of the `oauth.*` family, and the one #14312 / PR #15445 deliberately could not close: its ruling fenced that card to *narrowing published return types*, and no declared return type could be true while the `res.json()` call stood. - -## The defect, measured end to end - -Real `betterAuth` + real `@better-auth/oauth-provider` over the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the **real** `ObjectStackClient` with only the socket stood in for: - -``` -POST /api/v1/auth/oauth2/delete-client -> 200 · 0 bytes - content-type: application/json - content-length: (absent) -through the client, BEFORE -> REJECTED: SyntaxError | Unexpected end of JSON input -the row, server-side -> ALREADY GONE (get-client answers 404 not_found) -through the client, AFTER -> RESOLVED | undefined -``` - -The handler returns nothing and the vendor declares the endpoint `void`. `res.json()` had nothing to parse, so the method rejected — *after* the delete had committed. A caller who did the obvious thing saw a failure, retried, and the retry failed **differently**, because the row no longer existed. - -## What changes for a caller - -| | before | now | -|:--|:--|:--| -| a successful delete | rejects `SyntaxError` | resolves | -| the resolved value | `any` (unreachable — the promise never resolved) | `void` | -| deleting a client that is not there | rejects `not_found` | rejects `not_found` — unchanged | -| a malformed non-empty body | rejects `SyntaxError` | rejects `SyntaxError` — unchanged | - -⚠️ **The `catch` you wrote around this call stops firing on success.** Code shaped like - -```ts -try { await client.oauth.applications.delete(id); } -catch { /* the delete probably worked anyway */ } -``` - -still compiles and still runs, but its catch block was executing on **every** successful delete and now executes only on a real failure. Any workaround that lived in there is now inert and can be deleted. And because the promise never used to resolve, a read off its resolved value — `(await …delete(id)).deleted` — was dead code that has never executed; it now stops compiling (TS2339), which is the compiler delivering the change at the call site. - -## Why `void`, and not `{ deleted: boolean }` - -"Deleted" and "was already gone" **are** distinguished by the route, but on the error channel: a missing client answers 404 `{ error: 'not_found' }`, which the client already raises as a throw. The 200 answer carries zero bytes and therefore zero information, so a synthesised `{ deleted: true }` would be a shape the wire never sends and strictly less informative than the 404 a caller already receives. - -## Why the emptiness is detected by reading the body - -Both shortcuts were measured against the real route and both are unusable: the status is **200**, not the `204` five other delete surfaces in this client key off, and the response carries **no `content-length` header at all** — so a header test would never fire and would leave the defect in place while looking like a fix. The body itself is the only thing that answers. - -A non-empty body is still parsed and its failure still thrown, so **the only behaviour this change moves is the zero-byte case**: a malformed response stays loud, and the day this route grows a payload, surfacing it is a deliberate widening of the return type rather than a silent change of shape. - -`packages/client/exported-any-returns.json` loses this method's entry in the same change — the ledger is shrink-only, so the entry goes **with** the binding. Its last `oauth.*` entry is now gone; 35 sites remain open. diff --git a/.changeset/client-oauth-family-wire-shape-binding.md b/.changeset/client-oauth-family-wire-shape-binding.md deleted file mode 100644 index 48bc5fa893..0000000000 --- a/.changeset/client-oauth-family-wire-shape-binding.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client)!: the `oauth.*` family declares the wire shapes better-auth actually sends — four published `Promise< any >` returns narrowed (#14312) - -**BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is. - - - -Card 1 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, and better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, not the wire fact. - -## What changed - -Four methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change: - -| method | resolved to (before) | resolves to (now) | -|:--|:--|:--| -| `client.oauth.applications.register(req)` | `any` | `OAuthApplicationRegistration` | -| `client.oauth.applications.get(id)` | `any` | `OAuthApplication` | -| `client.oauth.applications.getPublic(id)` | `any` | `OAuthApplicationPublic` | -| `client.oauth.consent(req)` | `any` | `OAuthConsentResult` | - -`OAuthApplication`, `OAuthApplicationRegistration`, `OAuthApplicationPublic` and `OAuthConsentResult` are newly exported from `@objectstack/client`. These four routes are served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap, and none is introduced. - -## The exact reads that stop compiling - -Everything below compiled before only because `any` is assignable to, and indexable by, everything. - -```ts -const app = await client.oauth.applications.get('c_1'); -app.data; // was fine; now TS2339 — these routes carry NO envelope -app.anythingAtAll; // was fine; now TS2339 - -const pub = await client.oauth.applications.getPublic('c_1'); -pub.client_secret; // now TS2339 — the public projection hand-picks 7 columns -pub.grant_types; // now TS2339 — same reason -pub.disabled; // now TS2339 — same reason - -const decision = await client.oauth.consent({ accept: true }); -decision.client_id; // now TS2339 — consent answers `{ redirect, url }` - -// Timestamps are RFC 7591 NUMBERS (Unix epoch seconds), so a caller that -// guessed `Date` or ISO `string` now fails: -new Date(app.client_id_issued_at!).toISOString(); // TS2769: number is not a Date arg -app.client_id_issued_at!.slice(0, 10); // TS2339: not a string -new Date(app.client_id_issued_at! * 1000); // the correct rewrite -``` - -A caller that only read `client_id`, `client_secret`, `redirect_uris` or `url` needs no change. - -## Timestamps: `number`, not `Date` and not ISO-8601 - -The ruling ordered every `Date`-typed field declared as an ISO `string` and forbade both a `Date` declaration and a runtime revival layer. **This family has no `Date` field to convert.** RFC 7591 carries `client_id_issued_at` and `client_secret_expires_at` as Unix-epoch SECONDS, and the provider converts its stored `Date` to a number before serialising, so the wire sends neither a `Date` nor an ISO string. Both are declared `number`, and a type-level pin holds them there. The ruling's prohibitions are satisfied: nothing declares a `Date`, and no revival layer exists. - -## Two places better-auth's own types were the wrong answer - -Read off the wire against a real server, not off the vendor's `.d.ts`: - -- `getPublic` is declared `OAuthClient` — the full row — but its handler hand-picks seven columns. `OAuthApplicationPublic` is that projection, derived with `Pick` so it cannot drift from its parent. Its `redirect_uris` is always `[]` on this route and carries no information. -- `user_id` and `application_type` are declared nullable by the vendor, but the serialiser folds a null column to `undefined`, so `null` is unreachable and is not declared. - -## `oauth.applications.delete` is deliberately NOT bound - -The fifth method of the family keeps its `Promise< any >` and its ledger entry. Its route answers HTTP 200 with a zero-byte body, so its `res.json()` rejects with a `SyntaxError` on every successful delete. No annotation can be honest while that call stands, and binding it needs a behaviour change — a decision beyond this card's type-narrowing scope. That the shrink-only ledger still carries exactly this one entry is the mechanism working. diff --git a/.changeset/client-organizations-family-wire-shape-binding.md b/.changeset/client-organizations-family-wire-shape-binding.md deleted file mode 100644 index e9cb11b2ca..0000000000 --- a/.changeset/client-organizations-family-wire-shape-binding.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client)!: the `organizations.*` family declares the wire shapes better-auth actually sends — nineteen published `Promise< any >` returns narrowed, twenty ledger entries closed (#14314) - -**BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is. - - - -Card 3 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, and every timestamp is declared as the ISO-8601 `string` the wire carries — no `Date`, no revival layer. - -## What changed - -Nineteen `organizations.*` methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change — together with the entry for `organizations.invitations.resend`, which has no annotation of its own and inherits `invite`'s (22 entries before, 2 after): - -| method | resolved to (before) | resolves to (now) | -|:--|:--|:--| -| `client.organizations.create(req)` | `any` | `OrganizationCreateResult` | -| `client.organizations.update(id, data)` | `any` | `OrganizationEchoWire` | -| `client.organizations.setActive(id)` | `any` | `OrganizationWire \| null` | -| `client.organizations.get(id)` | `any` | `OrganizationFullWire \| null` | -| `client.organizations.listMembers(id)` | `any` | `OrganizationMembersPage` | -| `client.organizations.invite(req)` | `any` | `OrganizationInvitationWire<'pending'>` | -| `client.organizations.leave(id)` | `any` | `OrganizationMemberWithUserWire` | -| `client.organizations.delete(id)` | `any` | `OrganizationWire` | -| `client.organizations.removeMember(id, params)` | `any` | `OrganizationRemoveMemberResult` | -| `client.organizations.updateMemberRole(id, params)` | `any` | `OrganizationMemberWire` | -| `client.organizations.getActiveMember(id)` | `any` | `OrganizationMemberWithUserWire` | -| `client.organizations.invitations.cancel(id)` | `any` | `OrganizationInvitationWire<'canceled'>` | -| `client.organizations.invitations.accept(id)` | `any` | `OrganizationInvitationAcceptResult` | -| `client.organizations.invitations.reject(id)` | `any` | `OrganizationInvitationRejectResult` | -| `client.organizations.invitations.resend(inv)` | `any` (inherited) | `OrganizationInvitationWire<'pending'>` (inherited from `invite`) | -| `client.organizations.teams.create(req)` | `any` | `OrganizationTeamWire` | -| `client.organizations.teams.update(params)` | `any` | `OrganizationTeamWire` | -| `client.organizations.teams.delete(params)` | `any` | `OrganizationTeamRemovedReceipt` | -| `client.organizations.teams.addMember(params)` | `any` | `OrganizationTeamMemberWire` | -| `client.organizations.teams.removeMember(params)` | `any` | `OrganizationTeamMemberRemovedReceipt` | - -`OrganizationWire`, `OrganizationEchoWire`, `OrganizationCreateResult`, `OrganizationFullWire`, `OrganizationMemberWire`, `OrganizationMemberUserWire`, `OrganizationMemberWithUserWire`, `OrganizationMembersPage`, `OrganizationRemoveMemberResult`, `OrganizationInvitationWire`, `OrganizationInvitationAcceptResult`, `OrganizationInvitationRejectResult`, `OrganizationTeamWire`, `OrganizationFullTeamWire`, `OrganizationTeamMemberWire`, `OrganizationTeamRemovedReceipt` and `OrganizationTeamMemberRemovedReceipt` are newly exported from `@objectstack/client`. Every one of these routes is served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap and none is introduced. `@objectstack/spec/identity`'s `Organization` / `Member` / `Invitation` are deliberately NOT relayed: each declares `updatedAt` required, and the wire never carries it (the adapter's output transform walks better-auth's own schema, which has no such column); `InvitationStatus` IS relayed, narrowed to the literal each handler pins. - -## The exact reads that stop compiling - -Everything below compiled before only because `any` is assignable to, and indexable by, everything. - -```ts -const org = await client.organizations.setActive(id); -org.id; // now TS18047 — `setActive` (and `get`) answer `null` for an empty id with no active organization -if (org?.metadata) JSON.parse(org.metadata); // fine — on the READ routes `metadata` is the stored JSON text, `null`/absent when unset -(await client.organizations.get(id))!.metadata.plan; // now TS2339 — it is a string here, not an object - -const echo = await client.organizations.update(id, { metadata: { plan: 'pro' } }); -JSON.parse(echo.metadata); // now TS2345 — the two WRITE routes (`create`, `update`) echo `metadata` already decoded - -const deleted = await client.organizations.delete(id); -deleted.length; // now TS2339 — the route answers the organization ROW, not the id string the vendor's OpenAPI stub declares -deleted.updatedAt; // now TS2339 — `sys_organization.updated_at` never reaches the wire -deleted.createdAt.getTime(); // now TS2339 — ISO-8601 STRING, not a Date; `new Date(deleted.createdAt)` is the rewrite - -const m = await client.organizations.updateMemberRole(id, { memberId, role: 'admin' }); -m.member.role; // now TS2339 — the row is answered BARE, not as `{ member }` (the vendor's stub is wrong) - -const removed = await client.organizations.removeMember(id, { memberIdOrEmail }); -removed.member.user.email; // now TS18048 — `user` is joined on ONLY when the member was addressed by email - -const inv = await client.organizations.invite({ email, organizationId: id }); -if (inv.status === 'accepted') { /* now TS2367 — `invite` answers the literal `'pending'` */ } - -(await client.organizations.listMembers(id)).data; // now TS2339 — no envelope on any route of this family -``` - -A caller that read `id`, `name`, `slug`, `role`, `email`, `members`, `total` or `message` off these values, or narrowed `null` where it can arrive, needs no change. - -## Timestamps: ISO-8601 `string`, never `Date` - -`createdAt` on every row type, `updatedAt` on teams and `expiresAt` on invitations are the vendor's `Date`-typed fields. The adapter is declared `supportsDates: false`, better-auth revives the stored string into a `Date` server-side, and `JSON.stringify` puts an ISO-8601 string back on the wire — measured `"createdAt":"2026-09-07T09:27:01.545Z"` on a real SQL driver. They are declared `string`, a type-level pin holds them there, and no revival layer exists in the SDK. - -## Where the vendor's own declarations were the wrong answer - -- `delete`'s OpenAPI stub declares the deleted id as a `string`; the handler answers the organization row. -- `updateMemberRole`'s stub declares `{ member }`; the handler answers the membership row bare, without `user`. -- `metadata` is one column with two wire forms: `create` and `update` decode it, every read route answers the stored JSON text (`setActive`, `get`, `delete`, `list`). -- `removeMember` joins `user` on only when the member was addressed by email; the by-id path strips it. -- Inside `get(...).teams` the vendor's `memberCount` is NOT stripped (it is on `teams.create` / `teams.update`). `teams.update` writes no timestamp of its own — `updatedAt` there comes from better-auth's team schema (`onUpdate` default, applied on every update) with the platform's `sys_team.updated_at` stamping behind it, measured on a real SQL driver; the default team minted at organization creation is written without `updatedAt` by the vendor and carries the platform's stamp, so `get(...).teams[].updatedAt` is declared optional as the safe direction. - -## Not a behaviour change - -`getActiveMember(organizationId)` keeps sending its query parameter; the measured fact that the server ignores it and answers the session's ACTIVE organization is recorded in the method's JSDoc and filed separately — a body change is outside this family's ruled narrowing scope. diff --git a/.changeset/client-readme-analytics-automation-payload-reads.md b/.changeset/client-readme-analytics-automation-payload-reads.md deleted file mode 100644 index 8afd188304..0000000000 --- a/.changeset/client-readme-analytics-automation-payload-reads.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/client": patch ---- - -The README's analytics and automation examples read the resolved payload. - -`client.analytics.query` / `analytics.meta` and `client.automation.trigger` stopped handing back the dispatcher's `{ success, data }` envelope in 17.0.0: each resolves to the payload itself. The README's namespace tour still showed all three as bare `await` calls with nothing reading the resolved value, so the package's own front page taught nothing about which shape comes back — neither wrong nor useful. Each of the three now assigns its result and reads one member of it: `report.rows` / `report.fields[0].name` (`AnalyticsResult`), `cubes[0].name` (the bare `CubeMeta[]`), `run.status` (`AutomationResult`) — the members those contracts actually declare, read off the payload rather than off a `data` wrapper. - -No behaviour changes; this is the README that ships inside the package. The docs site's Client SDK and Data API pages take the same treatment in the same PR. diff --git a/.changeset/client-readme-approvals-decision-object.md b/.changeset/client-readme-approvals-decision-object.md deleted file mode 100644 index b22c479c7b..0000000000 --- a/.changeset/client-readme-approvals-decision-object.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/client": patch ---- - -The README's namespace tour calls `approvals.approve` / `approvals.reject` with the decision object they declare, and `auth.register` with the field its schema requires. - -`approve` and `reject` take `(requestId: string, decision?: { actorId?: string; comment?: string; attachments?: string[] })`. The tour passed the comment as a bare string — `approve(requestId, 'LGTM')` — which a TypeScript reader hits as `TS2559` and a JavaScript reader does not hit at all: the string goes out as the request body where the route reads the decision object's fields, so the approval is recorded and its **reason is silently dropped**. In an approvals surface a lost reason is not a typo. The calls now read `{ comment: 'LGTM' }` / `{ comment: 'Incomplete' }`, the spelling the docs site's Client SDK page already carried. - -Type-checking the whole fence against the package's own built `dist/index.d.ts` found one more call in the same defect class — a live method given the wrong argument shape. `auth.register` takes `RegisterRequest`, whose schema declares `name: z.string()` as required (and pins the rejection of a request without it); the tour passed only `{ email, password }`, failing `TS2345`. It now passes `name` as well, again matching the Client SDK page. All 35 calls in the fence type-check clean against the built declarations after this change. - -No behaviour changes and no source change: this is the README, and `files` ships `README.md` inside the tarball, so correcting it moves what `@objectstack/client` publishes — it is the package's npm front page. diff --git a/.changeset/client-readme-retired-ai-methods.md b/.changeset/client-readme-retired-ai-methods.md deleted file mode 100644 index 0013cadf5e..0000000000 --- a/.changeset/client-readme-retired-ai-methods.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/client": patch ---- - -The README's namespace tour documents the `ai` surface that exists, not the three methods v17 removed. - -`client.ai.nlq` / `.suggest` / `.insights` were deleted in 17.0.0 (#3718) — and no server in any repo ever mounted `/api/v1/ai/{nlq,suggest,insights}`, so they 404ed for the whole life of the namespace. The README's "AI Services" example still showed all three. Because `files` ships `README.md` inside the tarball, that example is the package's npm front page: a TypeScript reader copying it gets TS2339 on three properties that are not on `client.ai`, and a JavaScript reader gets a runtime `TypeError`. - -The block now shows the surface the client really exposes — `ai.chat` (with a read of `answer.content` / `answer.usage`), `ai.complete`, `ai.models`, `ai.conversations.list`, `ai.agents.chat`, `ai.pendingActions.list` — every call type-checked against the package's own published `dist/index.d.ts`. It also names the condition a reader will otherwise hit unexplained: the AI routes are served by `service-ai` (a Cloud/EE package), and an environment without it answers 501 rather than 404, with the remedy discovery reports under `services.ai`. - -No behaviour changes. `patch` rather than no changeset because the README is a published file of this package, so correcting it changes what `@objectstack/client` ships; the docs site's Client SDK page already carried this correction and is untouched here. diff --git a/.changeset/clone-data-dropped-fields.md b/.changeset/clone-data-dropped-fields.md deleted file mode 100644 index 0ca58322b2..0000000000 --- a/.changeset/clone-data-dropped-fields.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': minor -'@objectstack/client': minor ---- - -`cloneData` reports `droppedFields` like every other create face: `CloneDataResponseSchema` (`@objectstack/spec/api`) gains an optional `droppedFields` member of the same shape as `CreateDataResponseSchema`'s, and the `POST /data/:object/:id/clone` 201 body carries it whenever the engine stripped a static `readonly` column from the clone. - -A clone IS a create, and it is the one create shape that can carry a read-only column without the caller typing it: the source row is copied whole (`approval_status: 'approved'` included), `overrides` are applied on top, and the copy is inserted. Since the create-side strip moved into `engine.insert` that column has been stripped and logged at `warn` — but the 201 body said nothing, so a caller that cloned an approved record and read `record.approval_status: 'draft'` back had no field in the response telling it why, while `createData`, `createManyData`, `insertManyData` and every `batchData` row that created already answered on the wire. Maintainer ruling 2026-09-08 (option 1 on #15703): report it, the same way. - -- **`@objectstack/spec`** — `CloneDataResponseSchema.droppedFields`: `DroppedFieldsEvent[]`, optional, omit-when-empty — present ONLY when ≥1 field was dropped, and the clone still succeeded without them (status unchanged). The schema is declared AS PRODUCED, so the member and the producer land in one change. Additive: a client that reads only `object` / `id` / `sourceId` / `record` sees no difference. -- **`@objectstack/metadata-protocol`** — `cloneData` passes the engine the same `onFieldsDropped` listener `createData` wires and spreads the collected events onto its return as `droppedFields`. The strip itself is unchanged and still the engine's (`isSystem`-gated, `defaultValue` re-derived); what is new is that a copied-in or overridden readonly key is now named in the body instead of only in the server log. -- **`@objectstack/client`** — `CloneDataResult` (the declared mirror of `CloneDataResponseSchema`, the return type of `client.data.clone`) gains the same optional `droppedFields?: DroppedFieldsEvent[]`, so a TypeScript caller reads the member without a cast; its docblock no longer states that the clone producer emits no write-observability event. - -Body only, deliberately: the clone route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header (the single-record `POST /data/:object` and `PATCH /data/:object/:id` mounts do); the schema's `.describe()` says so rather than promising a header the route does not send. diff --git a/.changeset/collect-flow-graphs-drops-non-record-members.md b/.changeset/collect-flow-graphs-drops-non-record-members.md deleted file mode 100644 index e990b91667..0000000000 --- a/.changeset/collect-flow-graphs-drops-non-record-members.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`collectFlowGraphs` now honours the `readonly FlowNodeParsed[]` it declares: a member of a region's node list that is not a record is dropped from the `FlowGraph` it hands out, instead of being passed through verbatim. - -An ADR-0031 container keeps a whole sub-graph inside `FlowNodeSchema.config`, a deliberately open `z.record`, so `collectFlowGraphs` re-derives those inner node lists at run time and checks them with `Array.isArray` — which proves the LIST and never its MEMBERS. An empty item in a YAML `nodes:` list under a `loop` body deserialises to `null`, and that `null` reached `graph.nodes` on every returned graph, at every depth. No caller could prevent it: this is an array the walk picks up itself, so no coercion at a call site ever holds it. Filed as #16752. - -- **What changed.** The walk filters what it hands out and skips what it descends into, through one predicate. Array identity is preserved when nothing is dropped, so a well-formed flow allocates nothing new. -- **What deliberately did NOT change.** The declared input type is untouched — widening it to tolerate malformed members was refused on the anti-AI-error axis, and this is the opposite move: the producer now keeps the promise it already made. The schema refusal that owns a malformed region still fires, unchanged; this walk runs inside `FlowSchema`'s parse, where a thrown `TypeError` would escape `safeParse`, so the repair is a drop and a skip and never a throw. `FlowGraph.path` still indexes the raw authored list, so a Zod issue stays anchored where the author wrote the node. -- **Visible consequence.** As with the sibling repairs that read their lists through a record filter, a dropped member renumbers the ones behind it *within* `graph.nodes` — a difference in the index, never in whether a node was judged, and only in a list that was already malformed. diff --git a/.changeset/colorfield-derive-describe.md b/.changeset/colorfield-derive-describe.md deleted file mode 100644 index 099854c9e0..0000000000 --- a/.changeset/colorfield-derive-describe.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`colorField` now documents what it means: a field to DERIVE a colour from, not a field holding one. - -`TimelineConfigSchema`, `CalendarConfigSchema` and `GanttConfigSchema` each declare a `colorField`, and all three `.describe()` strings said only that the field "determines"/"drives" the colour — `'Field to determine item color'`, `'Field whose value determines the event color'`, `'Field that drives the bar color'`. Read literally, that invites pointing the key at a field whose stored value *is* a colour, which is the one case the renderers need the least: the common author intent is `colorField: 'status'`, a select field whose options already carry the colours. - -The renderers resolve it as a derivation ladder (objectui#7243, shared as `createFieldColorResolver` in `@object-ui/core`): - -1. the option `color` the field declares for the record's stored value; -2. else the value itself, when it already is a colour literal (hex 3/6/8-digit, `rgb(...)`, `hsl(...)`); -3. else each renderer's own last rung — the gantt derives a semantic colour token, the calendar hashes onto its theme-aware palette, the timeline draws its default marker. - -The three strings now say that, each naming its own last rung. **Nothing in the accept set moves**: all three keys stay `z.string().optional()`, and a config pointing `colorField` at a plain hex field is still exactly as valid as before — that is rung 2. This is prose on a declared key, so the only regenerated follower is `content/docs/references/ui/view.mdx`. diff --git a/.changeset/compliance-families-retired.md b/.changeset/compliance-families-retired.md deleted file mode 100644 index 56bbfc3682..0000000000 --- a/.changeset/compliance-families-retired.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: retire the incident-response, training and change-management families whole — nineteen defs and every name they exported — and the `ESignatureConfig` deadline pair (#15513, #14477, ADR-0049) - - - -**BREAKING** — published exported symbols leave `@objectstack/spec/system`, and -two authorable keys leave `data/ESignatureConfig` — landing after the v17.0.0 -cut (the lockstep launch-window convention ships it as `minor`; the -registrations live under protocol major 18, where `os migrate meta` users will -look). Maintainer ruling 2026-09-05 on #15513 (decision batch #40, ruled A: -retire the three compliance-shaped families whole via `RETIRED_DEFS_BY_MAJOR`, -the `integration/ErrorMappingConfig` precedent; none of the three is -roadmapped) and, in the same stroke, the answer the 2026-09-02 ruling on #14477 -had held open (no roadmapped e-signature consumer ⇒ the pair retires with the -rest). ADR-0049 enforce-or-remove decides it — declared-but-unenforced surface -with zero measured readers comes off. - -## What leaves the public surface — the three families, whole - -Nineteen defs (the card counted fifteen; the manifest counts nineteen — the -ruling names the families, the number is the files' reading), forty-five -exported names, roughly a hundred declared keys, and the generated reference -pages `references/system/incident-response`, `training` and -`change-management`: - -| file | defs (`json-schema.manifest/system.json` spelling) | -|:--|:--| -| `system/incident-response.zod.ts` | `system/Incident`, `system/IncidentCategory`, `system/IncidentNotificationMatrix`, `system/IncidentNotificationRule`, `system/IncidentResponsePhase`, `system/IncidentResponsePolicy`, `system/IncidentSeverity`, `system/IncidentStatus` | -| `system/training.zod.ts` | `system/TrainingCategory`, `system/TrainingCompletionStatus`, `system/TrainingCourse`, `system/TrainingPlan`, `system/TrainingRecord` | -| `system/change-management.zod.ts` | `system/ChangeImpact`, `system/ChangePriority`, `system/ChangeRequest`, `system/ChangeStatus`, `system/ChangeType`, `system/RollbackPlan` | - -With them: every `*Schema` const, every `z.input` alias (`Incident`, -`IncidentResponsePolicy`, `TrainingCourse`, `ChangeRequest`, …) and the six -`*Parsed` aliases (`IncidentNotificationRuleParsed`, -`IncidentNotificationMatrixParsed`, `IncidentResponsePolicyParsed`, -`TrainingCourseParsed`, `TrainingPlanParsed`, `ChangeRequestParsed`). - -**Why.** The schemas were exported from `@objectstack/spec/system`, mounted by -no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 -liveness ledgers, and **read by nothing**: the reader census over every package -outside `packages/spec` (tests and changelogs excluded), over `examples/**` and -`skills/**`, and over objectui at the pinned sha (`a472b07`) returned zero hits -for every one of the forty-five names, with a lit control on the same pattern -(`ObjectSchema` / `FieldSchema`: 336, 200 and 342 hits per leg). Several keys -were boolean capability claims of exactly the shape ADR-0049 names — -`IncidentNotificationRule.notifyRegulators`, -`IncidentResponsePolicy.requirePostIncidentReview`, `TrainingCourse.mandatory`, -`TrainingPlan.trackCompletion` / `sendReminders`, -`ChangeRequest.approval.required`, -`ChangeRequest.securityImpact.requiresSecurityApproval` — so an author writing -`notifyRegulators: true` held a compliance promise the platform never kept, -with no error and no feedback, and the reference docs advertised a compliance -subsystem that does not exist. Tagging the families -`[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is -a human-only signal, and an AI generating from the schema still writes the key -and believes it. - -**What happened to the fourteen #14477 deadline-key tombstones** (PR #15514, -merged 2026-09-04): they leave with their defs' source. Their fourteen -`RETIRED_KEYS_BY_MAJOR[18]` entries and three D3 entries stay as history — gate -(b2) of `build-schemas.ts` accepts an entry naming a key the build no longer -emits, and the 17→18 upgrade guide still owes the reader those prescriptions. -`deadline-keys-retirement.test.ts`, whose every pin needed the schemas to exist, -is replaced by `compliance-families-retirement.test.ts`. - -## What is refused — the `ESignatureConfig` pair - -Authoring `expirationDays` or `reminderDays` on an `ESignatureConfig`, with any -value, on the base schema and through `Document.eSignature`. The schema is not -`.strict()`, so each key is a `retiredKey()` tombstone rather than a bare -deletion (a deletion would have stripped it in silence): authoring it is a `tsc` -error (`never`) and a parse error carrying the prescription (`invalid_type` at -the path of the key). Both carried defaults (30 days, 7 days) that were -materialized into every parsed configuration without ever being consulted; -parsed configurations no longer carry them. `provider`, `enabled` and `signers` -stay, byte-identical. Census for the pair: zero hits for `expirationDays`, -`reminderDays`, `eSignature` and the `ESignatureConfig` names on all three legs, -control lit inside `packages/spec` (`document.zod.ts` 9, `document.test.ts` 24). - -**Unmeasured, verbatim:** `cloud` and real customer configurations are -UNMEASURED for both the families and the pair — this census covers this repo -and objectui at the pin. - -## FROM → TO - -```ts -// before — imported and parsed green; no engine ever read a single key -import { IncidentResponsePolicySchema, type IncidentResponsePolicy } from '@objectstack/spec/system'; -const policy: IncidentResponsePolicy = { - notificationMatrix: { rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], notifyRegulators: true }] }, - defaultResponseTeam: 'security_team', - requirePostIncidentReview: true, -}; -IncidentResponsePolicySchema.parse(policy); - -const signing: ESignatureConfig = { - provider: 'docusign', - signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], - expirationDays: 30, - reminderDays: 7, -}; - -// after — the import is TS2305 and there is no replacement to point at, because -// no incident-response, training-management or change-management engine exists. -// A compliance record the organisation keeps is ordinary object data, declared -// as an object with its own fields and enforced by the object engine; an -// approval that must actually gate something is a flow (ADR-0018) with an -// approval node. -// -// The e-signature pair: delete the keys. `ESignatureConfig` itself stays. -const signing: ESignatureConfig = { - provider: 'docusign', - signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], -}; -``` - -One-line fix: delete the import (families) or the key (pair) wherever it is -authored. There is no `os migrate meta` edit list — none of the schemas is a -stack collection member and `document` is no metadata type, so the conversion -chain has no seam to walk (the `MetadataPluginConfig.additionalTypes` -precedent); the tombstone prescriptions, the `tsc` refusals and the protocol-18 -upgrade guide are the channels. - -The retirement kit: - -- the three schema files and their tests deleted whole; the survivor notes in - `packages/spec/src/system/index.ts` record what each module declared and why - nothing ever read it -- ADR-0087 registration: nineteen `RETIRED_DEFS_BY_MAJOR[18]` entries - (`entries/retired-defs/18.system__*.ts`) and three D3 semantic entries, one - per family; for the pair, `data/ESignatureConfig:expirationDays` and - `data/ESignatureConfig:reminderDays` in `RETIRED_KEYS_BY_MAJOR[18]` plus the - D3 entry `esignature-config-deadline-keys-retired`; the step-18 `rationale` - extended -- no liveness-ledger row: none of the families and neither `document` nor - `ESignatureConfig` is an enrolled ledger type, so there is no row to keep or - drop -- pin tests: `compliance-families-retirement.test.ts` (zero holders of the - forty-five names on every public entry via `export-origins/`, the deletion - probe, the in-package importer walk, the runtime namespace, the shards' - absence, the ADR-0087 registration, the #15514 history kept, and a - tree-scoped absence leg whose walk radius is DECLARED in - `scripts/cross-package-test-inputs.mjs` / `turbo.json` — the playbook rule - #15566 added after PR #15514); `esignature-deadline-keys-retirement.test.ts` - (refusal pins asserting issue path, code and prescription on the base schema - and through `Document.eSignature`; the tsc `never` channel; no-materialize - pins for the two former defaults; the ADR-0087 registration); the thirteen - isomorphism pins the three modules held leave `type-alias-convention.pin.test.ts` -- generated baselines and docs follow the schema: `json-schema.manifest/` - loses nineteen keys (the manifest-deletion gate adjudicates whole-def - removals against the merge base), `api-surface/`, `declaration-map/`, - `export-origins/`, `authorable-surface/` and `authorable-defaults/` lose the - families' rows, `authorable-surface/data.json` gains two `[RETIRED]` rows and - `authorable-defaults/data.json` loses two, the three system reference pages - are removed and `references/system/index.mdx`, `references/index.mdx` and - `references/data/document.mdx` regenerated, `spec-changes.json` and the - upgrade guide carry the four new registrations at the 18 cut -- hand-written docs: the `Change Management` row leaves - `getting-started/quick-reference.mdx` -- zero authored occurrences in this repo's examples, skills and hand-written - docs beyond that row, and zero hits in objectui at `a472b07`, so no sibling - change and no pin bump ride along diff --git a/.changeset/compose-merge-refuses-object-collections.md b/.changeset/compose-merge-refuses-object-collections.md deleted file mode 100644 index 278a7b137d..0000000000 --- a/.changeset/compose-merge-refuses-object-collections.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses object pairs whose object-level collections cannot be merged (#14848) - - - -**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })` -— shipped as `minor` under the repo's launch-window convention for breaking -changes. Maintainer ruling 2026-09-04 on #14848 (director decision batch #38 -item 5, verbatim 「同意」): option 4, `'merge'` **refuses** what it cannot merge -instead of dropping it. - -**What changed.** `'merge'` was implemented as -`{ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }`: -`fields` was the only key merged, and every other key the later object carried -— `actions`, `indexes`, `listViews`, `validations`, … — replaced the earlier -package's value wholesale, with nothing at compose, build or boot saying so. -Two packages each embedding an action on one shared object composed to the -later package's array alone; the earlier package's action was gone. - -Now, when both objects declare an object-level **collection** other than -`fields` with different values, `composeStacks` throws — the refusal shape -`'error'` uses — naming the object, the colliding collection and both stacks -by manifest id: - -``` -composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1). -objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged: the later declaration would replace the earlier one wholesale, silently dropping every entry 'com.example.a' (stack #0) wrote. -Fix: declare 'actions' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack. -``` - -The refusal set is **derived from `ObjectSchema`'s shape** — every key whose -declared type is an array or a record (through optional/default wrappers and -into a union's members), except `fields` — not hand-listed, so a collection key -added to the object schema joins the refusal without an edit to the composer. -Today that set is `actions`, `activityMilestones`, `fieldGroups`, -`highlightFields`, `indexes`, `listViews`, `requiredPermissions`, -`searchableFields`, `validations`. - -**What did not change.** - -- `fields` keeps its documented shallow merge (later fields win, earlier - fields kept). -- **Identical** declarations on both sides pass through and are carried once - — the same reading `composeStacks` already gives identical top-level values - — so two built stacks that each bind one standalone action to the same - object (identical copies) still reach the cross-stack action-key check - (#14662) and are refused there, by name, as before. -- A scalar or fixed-shape config object the later object declares (`label`, - `sharingModel`, `enable`, `access`, …) still replaces the earlier one: the - ruling narrows collections only, and the docblock now says so. -- The default `'error'` and `'override'` are untouched, message for message. -- An explicit `undefined` on the later object is read as no declaration — it - neither counts as a differing value nor erases what the earlier stack - declared (the bare spread used to let it). - -**Who is affected.** Measured on `origin/main` @ `53cbad9f7`: **zero** non-test -call sites in `packages/**`, `examples/**`, `apps/**` pass `objectConflict` at -all — every real caller takes the default `'error'`. An external author who -opted into `'merge'` and relied on the later package's collection winning -silently now gets the refusal above; the fix is the one it names. - -The `ConflictStrategySchema` docblock for `'merge'` states the rule. diff --git a/.changeset/config-miss-refusal-to-stderr.md b/.changeset/config-miss-refusal-to-stderr.md deleted file mode 100644 index ea723170f8..0000000000 --- a/.changeset/config-miss-refusal-to-stderr.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os validate|info|diff|lint|compile|build|verify|migrate meta|i18n check|i18n extract --json` no longer print human text on stdout when the config file is missing. - -`resolveConfigPath()` emitted both of its refusals — the explicit-path miss and the auto-detect miss — through `printError` and `console.log`, **both of which write to stdout**, and then called `process.exit(1)` directly. Ten published `--json` faces reach that helper, so a missing config file answered them with exit 1, an unparseable stdout and an **empty stderr**: 206 bytes of prose on the one stream `--json` reserves for the machine. And because the exit was called rather than thrown, every command's catch-all `--json` error exit — all of which sit downstream of a throw — never ran. - -The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. Nothing else moves: - -- **the exit code is still 1**, so a consumer branching on exit status sees no change at all; -- **the wording is unchanged**, hints included, so a human reading a terminal sees the same three lines; -- **nothing is accepted or rejected differently** — no config that loaded before fails now, and none that failed now loads. - -⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* when the config file is missing is an envelope question that touches ten published faces at once, and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today. - -A new pin (`config-miss-stdout-purity.e2e.test.ts`) drives all ten faces on both branches of the helper. The existing purity pin could not: it discovers its family as the commands that call `bootSchemaStack`, and these fail before any kernel boots. diff --git a/.changeset/connector-error-mapping-retired.md b/.changeset/connector-error-mapping-retired.md deleted file mode 100644 index 3893d2d28a..0000000000 --- a/.changeset/connector-error-mapping-retired.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `connector.errorMapping` — eleven authorable keys nothing ever read, one of them spelled like the live `userMessage` channel (#14676, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). -Triage ruling 2026-09-02 on the census card: ADR-0049 enforce-or-remove decides -it — declared-but-unenforced authorable surface with zero measured pull for a -reader comes off. - -`ConnectorSchema.errorMapping` carried `ErrorMappingConfig` (`rules`, -`defaultCategory`, `unmappedBehavior`, `logUnmapped`) and its -`ErrorMappingRule[]` (`sourceCode`, `sourceMessage`, `targetCode`, -`targetCategory`, `severity`, `retryable`, `userMessage`) — eleven keys on the -published authorable surface that **nothing read**: measured on `origin/main`, -the only reference outside the declaring file and its unit test was a -type-identity pin. No provider, dispatcher or materializer ever mapped an -external error through the rules, so `unmappedBehavior` configured nothing and -a rule's `userMessage` was never shown to anyone. That spelling is what made -this worse than ordinary dead surface: it is the name of the **live** -API-error channel (`ApiError.userMessage`, the user-facing refusal text a -thrown HTTP error declares), so an author who had read that documentation and -wrote a connector rule reasonably believed they were marking a refusal for an -end user — and the failure was silent in both directions (it validated, it -published, no message was ever shown). Removal resolves the collision by -deletion; the live channel is untouched. - -**What is refused:** authoring `errorMapping` on a connector, with any value. -`ConnectorSchema` is a non-strict `z.object`, so the key is a `retiredKey()` -tombstone rather than a bare deletion (a deletion would have stripped it in -silence): authoring it is a `tsc` error (`never`) and a parse error carrying -the prescription, on the base schema and — through -`DeclarativeConnectorEntrySchema`, which `superRefine`s the same shape — on -`stack.connectors[]` and the `PUT /api/v1/meta/connector/:name` door. - -**What leaves the public surface:** `ErrorMappingConfigSchema` / -`ErrorMappingConfig` / `ErrorMappingConfigParsed`, `ErrorMappingRuleSchema` / -`ErrorMappingRule`, and `ConnectorErrorCategorySchema` / `ConnectorErrorCategory` -(the enum's only consumers were the two removed shapes; an exported value -schema with no consumer reads as a capability). `api/ErrorCategory` — the -HTTP-response vocabulary — is unaffected. - -**What stays, byte-identical:** every other connector key (`health`, `retry`, -`webhooks`, `fieldMappings`, `syncConfig`, `actions`, `triggers`, `provider`, -`providerConfig`, `auth`, …) with its default and its readers. - -## FROM → TO - -```ts -// before — parsed green; nothing ever read the block, no message was ever shown -defineStack({ - connectors: [{ - name: 'payments_api', - label: 'Payments API', - type: 'api', - errorMapping: { - rules: [{ - sourceCode: 429, - targetCode: 'RATE_LIMITED', - targetCategory: 'rate_limit', - severity: 'medium', - retryable: true, - userMessage: 'The payment provider is busy; try again shortly.', - }], - unmappedBehavior: 'generic_error', - }, - }], -}); - -// after — delete the key; there is no replacement because no error-mapping -// engine exists: a connector's failures reach callers as the provider's own -// errors (ADR-0097). A user-facing refusal text is the API error envelope's -// `userMessage`, declared by the code that throws — not connector metadata. -defineStack({ - connectors: [{ name: 'payments_api', label: 'Payments API', type: 'api' }], -}); -``` - -One-line fix: delete the `errorMapping` block; `os migrate meta --from 17` -lists the mechanical edits for existing sources. - -The retirement kit: - -- `retiredKey()` tombstone on `ConnectorSchema.errorMapping` - (`packages/spec/src/integration/connector.zod.ts`; the section comment - records what the shape was), inherited by `DeclarativeConnectorEntrySchema` -- ADR-0087 registration: `integration/Connector:errorMapping` and - `integration/DeclarativeConnectorEntry:errorMapping` in - `RETIRED_KEYS_BY_MAJOR[18]`; `integration/ErrorMappingConfig`, - `integration/ErrorMappingRule`, `integration/ConnectorErrorCategory` in - `RETIRED_DEFS_BY_MAJOR[18]`; the D2 conversion - `connector-error-mapping-removed` (protocol 18) wired into the step-18 chain - — a pure lossless strip of the block from every `connectors[]` entry, one - notice per connector (the eleven nested keys leave with the block) -- no liveness-ledger row: `connector` is not an enrolled ledger type, so - there is no row to keep or drop -- pin tests (`connector.test.ts`): refusal pins asserting the issue path, - code and prescription on the base schema, the declarative entry, and the - `stack.connectors[]` authoring path; the tsc `never` channel; a - no-materialize pin; the conversion's strip and notice; zero holders of the - seven retired names on every public entry; the ADR-0087 registration -- generated baselines/docs follow the schema (`authorable-surface/`, - `authorable-defaults/`, `api-surface/`, `json-schema.manifest/`, - `declaration-map/`, `export-origins/`, spec-changes, upgrade guide, - reference docs) -- zero authored occurrences in this repo's examples, skills and docs, and - zero hits in objectui at `0d8fd7c`, so no in-repo source changes ride along diff --git a/.changeset/console-53ded82bf7a4.md b/.changeset/console-53ded82bf7a4.md deleted file mode 100644 index a43ad9a9c0..0000000000 --- a/.changeset/console-53ded82bf7a4.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `53ded82bf7a4`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 23 releasing of 28 changesets added across 34 non-merge commits; omitted: 5 release-nothing changesets, 7 commits carrying no changeset (they ship no package code). - -- **minor** — Array filters on analytics aggregates were posted un-lowered and refused by the runtime with 400; they are now lowered to the canonical `FilterCondition` before the wire. (objectui `53ded82bf`) -- **minor** — Retire `ChatbotSchema.displayMode` — and its copy on `ChatbotFloatingSchema` — as an ADR-0049 retirement tombstone, and remove the `chatbot-floating` registration's "Display Mode"… (objectui `3e377c931`) -- **minor** — `ChartDataSeriesSchema` (and its TS twin `ChartDataSeries`) now REFUSES `chartType` on a chart series BY NAME and points at `type` — the renderer-internal spelling the non-strict… (objectui `caf477fa8`) -- **minor** — `DataScopeManager` now **denies** a row when a row-level scope rule carries an operator its evaluator does not implement. It used to **admit** the row. (objectui `83c77dc30`) -- **minor** — One home for the `datetime` display convention (objectui#7443). (objectui `81a2eb1fb`) -- **minor** — **`BaseSchema.visible` / `.hidden` / `.disabled` now declare the CEL envelope object the renderer already evaluates, as one named wire type** (objectui#7530, maintainer ruling 202… (objectui `c354ce5db`) -- **minor** — **Breaking for authored metadata:** `MarkdownSchema.sanitize` and `MarkdownSchema.components` are RETIRED (objectui#6972, ADR-0049 enforce-or-remove). A `markdown` node that autho… (objectui `446d93d4e`) -- **minor** — One named, importable authoring-face type per `plugin-chatbot` registration: `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` join `ChatbotSchema` (objectui#7655, under the obj… (objectui `4ce14f125`) -- **minor** — Six user-visible fixes across the maker surface, the assistant rail and the dataset captions. (objectui `64dae8e71`) -- **minor** — `DrillDownConfigSchema` is the zod mirror of `DrillDownConfig`, and both declarations that carry `drillDown` reference it — `ChartSchema` (`zod/data-display.zod.ts`) and `ObjectDa… (objectui `52c8cf741`) -- **minor** — `ObjectGallerySchema` and `ObjectDataTableSchema` are members of `ObjectQLComponentSchema` on both faces — the TS union in `objectql.ts` and the zod union in `zod/objectql.zod.ts`… (objectui `52c8cf741`) -- **minor** — `ChartDataSeriesSchema` (and its TS twin `ChartDataSeries`) declares the six series keys the renderer reads — `label`, `variant`, `opacity`, `dashArray`, `stack`, `yAxis` — which… (objectui `8fe8e5c16`) -- **minor** — `AlertDialogSchema` now declares the four keys the `alert-dialog` renderer actually reads (objectui#7104): `content` (the dialog body, `SchemaNode | SchemaNode[]` like every sibli… (objectui `8ad218d58`) -- **minor** — **BREAKING** — `SchemaRegistry['kanban']` stops describing a component it cannot name (objectui `bc640ec56`) -- **minor** — One authority per exported type name, batch 3 of objectui#6349: `ComboboxOption`, `NamedActionDef`, `OrgTranslate`. (objectui `6e8863093`) -- **minor** — **Removes two published exports.** Retire the `MobileResponsiveConfig` and `GestureConfig` types (objectui#7519, ADR-0049 enforce-or-remove). Both names are deleted from `@object-… (objectui `51eb51558`) -- **minor** — Retire `FloatingChatbotConfig.triggerIcon` (objectui#7654, ADR-0049 enforce-or-remove). (objectui `a3eb5d07a`) -- **minor** — One shared record-source ladder, five plugins delegate (objectui#7632). (objectui `ce2aaefe1`) -- **patch** — The published `@default` documentation on two `layout.ts` members now matches the value the renderer actually applies. `ContainerSchema.maxWidth` documented `'lg'` while `containe… (objectui `e546222b3`) -- **patch** — Declare `avatar` and `avatarFallback` on `ChatMessage`, on both faces (objectui#7295 — the residue of objectui#4424, whose `RuntimeOnlyMessageKeys` named only the three keys API m… (objectui `858cd72e3`) -- **patch** — `ClassNameStylePropsSchema` describes itself by its two keys (objectui#7578). (objectui `9587fc959`) -- **patch** — Declare `wrapperClass` on `CheckboxSchema`, on both faces (objectui#6938 — the residue of that card; its `context-menu` half landed with objectui#6939 group 1). (objectui `b74a8598d`) -- **patch** — The standalone runner renders `AppAction.items` from its declared type only, which makes `AppActionSchema.onClick`'s retirement message true again (objectui#6854, maintainer rulin… (objectui `adb2a86db`) - -⚠️ 2 of these carry a breaking change: 2 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 7 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(hooks): read the edited path from the key the routed tool carries (#7686) (objectui `565f2b6aa`) -- _(no changeset)_ fix(schema-catalog): drop the undeclared `variant` key from the dropdown-menu Delete item (#7717) (objectui `f96a781c8`) -- _(no changeset)_ fix(hooks): an escaped quote inside a double-quoted word does not close it (#7695) (objectui `6eebc54b6`) -- _(no changeset)_ docs(skills): correct six wrong shipped-API facts in the published objectui guides (#7677) (objectui `ab771d2dd`) -- _(no changeset)_ docs(skills): record ComponentInput's five ADR-0049 tombstones in the plugin guide (objectui#7636) (#7647) (objectui `09163884f`) -- _(no changeset)_ docs(agents): state the changeset gate's full population, not `src/` alone (#7640) (objectui `0b1e39d4a`) -- _(no changeset)_ docs(charts): rewrite the inline-series chart examples into the model the renderer implements (#7679) (objectui `d3f5256ce`) - - - -objectui range: `a472b07167a3...53ded82bf7a4` diff --git a/.changeset/console-a472b07167a3.md b/.changeset/console-a472b07167a3.md deleted file mode 100644 index 1cda964017..0000000000 --- a/.changeset/console-a472b07167a3.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `a472b07167a3`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 15 releasing of 18 changesets added across 29 non-merge commits; omitted: 3 release-nothing changesets, 11 commits carrying no changeset (they ship no package code). - -- **minor** — **BREAKING** — Converge the lookup/user widget metadata on the spec's camelCase — one concept, one spelling (objectui#7155, maintainer ruling A′ of 2026-09-03, director decision batch #19). (objectui `351eb3181`) -- **minor** — **BREAKING** — One authority for `KanbanSchema` / `KanbanColumn` / `KanbanCard`: the bare names now belong to `@object-ui/plugin-kanban` (objectui#6172, closing the cross-package half of objectu… (objectui `2c71482ea`) -- **minor** — Retire `ComponentInput.inputType` — the fifth and last key objectui#5905 named (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-31, option B). (objectui `1ec291c0d`) -- **minor** — `@object-ui/core` publishes `resolveRecordSourceObjectName`, the ONE reader for "which object is this block bound to" (objectui#7627). (objectui `b041b9c0c`) -- **minor** — **Published TS surface narrowed:** `DashboardComponentSchema` no longer declares the dashboard-root `title` member (objectui#7623). (objectui `5d0876c5c`) -- **minor** — **BREAKING** — BREAKING (`@object-ui/components`): the chart primitives — `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle` and the `Char… (objectui `7bf244bea`) -- **minor** — ListView: fold `data={{ provider: 'object', object }}` onto `objectName`, and read the author's view kind from `specType` / `type` (objectui#7477 — step 6 of #2890, released by th… (objectui `00d2fa682`) -- **minor** — Retire the dashboard-**root** `title` read across all five surfaces (objectui#7509, maintainer ruling 2026-09-04, decision batch #29, option C, under ADR-0049). (objectui `1cca678ba`) -- **minor** — **BREAKING** — Re-home the breakpoint layout vocabulary and delete the two dead responsive implementations (objectui#7580, maintainer ruling 2026-09-04, option A). (objectui `e62c44e7e`) -- **minor** — `@object-ui/types/zod`: the zod const `StylePropsSchema` is renamed to `ClassNameStylePropsSchema` (objectui#5928). **The old name is gone** — there is no deprecated alias and no… (objectui `24e027e93`) -- **patch** — Fix `extractToc` eating the underscores out of a `SCREAMING_SNAKE` heading, so its `#id` links resolve to the heading they name again (objectui#7667). (objectui `a472b0716`) -- **patch** — Remove `src/ui/toast.tsx`, an unreferenced primitive, and the dependency only it imported (objectui `2f61238b9`) -- **patch** — Fix `extractToc` deleting tag-shaped text that lives INSIDE an inline code span, so its `#id` links resolve to the heading they name again (objectui#7658). (objectui `90c6d090d`) -- **patch** — A record-page URL now names the object the clicked rows actually came from, in `ObjectTree` and `ObjectCalendar` (objectui#7638). (objectui `2ce2612df`) -- **patch** — fix(app-shell): the object-field options editor no longer drops `default` and `visibleWhen` on save (objectui `97c3e1972`) - -⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 11 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(scripts): check-doc-links resolves the #fragment, not just the file (objectui#7644) (#7657) (objectui `f7cf7e8a9`) -- _(no changeset)_ docs(plugin-chatbot): document chatbot-floating's seven declared inputs keys (objectui#7594) (#7656) (objectui `8e501cb97`) -- _(no changeset)_ docs(agents): record the never-approve seat rule beside the governed never-list (#7630) (objectui `2e99852ca`) -- _(no changeset)_ refactor(examples): drop the inert root `title` from six catalog dashboards (#7634) (objectui `46cde8264`) -- _(no changeset)_ docs(check-skill-examples): drop the stale zero-jsonc-fences claim (#7631) (objectui `0b24d7f85`) -- _(no changeset)_ docs(governed-guard): replace the retired sha pin with the ruled approval-record predicate (#7616) (objectui `11edab88f`) -- _(no changeset)_ docs(skills): split multi-document JSON fences, drop the `...` elisions, mark every parsing fence (#7608) (objectui `89d6adf37`) -- _(no changeset)_ fix(scripts): judge spec citations at member granularity, and stop the header teaching a retired filter (objectui#7513) (#7617) (objectui `d28d87bf4`) -- _(no changeset)_ fix(governed-guard): an authorised approval record satisfies the queue leg on any commit (#7606) (objectui `0d8fd7ce3`) -- _(no changeset)_ chore(deps): Bump fumadocs-core from 16.14.4 to 16.15.4 (#7059) (objectui `1bae75bb8`) -- _(no changeset)_ docs(claude-md): collapse the two AGENTS.md excerpts to rule + hook + pointer (#7600) (objectui `c70ebaaeb`) - - - -objectui range: `00d3f09c500c...a472b07167a3` diff --git a/.changeset/contained-failure-visibility.md b/.changeset/contained-failure-visibility.md deleted file mode 100644 index c749b5ffd5..0000000000 --- a/.changeset/contained-failure-visibility.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -A contained per-iteration failure is now visible at run level, attributed to its iteration, and bound to its row. - -`loop { body: [ try_catch { try, catch } ] }` is the containment spelling for a per-iteration failure that must not end the sweep (there is deliberately no `loop.config.onIterationError` key). Containment already worked — the failure was caught, the loop went on and the run completed — but nothing said what it had contained: a sweep that lost two rows out of five reported `status=completed selected=5 acted=9 skipped=0` and was indistinguishable from one that lost none. The failure was in the step log and in `nodes[].failures`; no run-level number carried it, the failing step named no row, and `$error` bound no row identity. - -Four changes populate the contract `@objectstack/spec` already declares: - -- **`FlowRunSummary.failed`** — `summarizeRun` now folds `failed = Σ nodes[].failures` over the per-node array it publishes, so the run-level count can never disagree with the breakdown it summarizes. It counts every node execution that failed, contained or fatal; on a run that completed, all of them were contained. -- **`failed=N` on the run summary line** — `formatRunSummaryLine` prints the token whenever the count is present, `failed=0` included. That is the opposite of the `unmeasured` rule beside it and deliberate: `unmeasured` qualifies `acted`, while `failed` answers a question a completed run's line otherwise cannot be asked at all. Read `failed=0` precisely: **no node execution of this run failed**. It is the node fold and only that, so a `subflow` child's own contained failures stay on the child's summary rather than rolling up the way `acted` does — see #15617, where the declaration's two paragraphs are being reconciled. -- **Iteration through `try_catch`** — a step that ran in a `try` or `catch` region inside a loop body now carries the enclosing loop's `iteration`, with `regionKind` still `try` / `catch`. The step says which region ran it *and* which row it ran for. (`parallel` branch tagging was unchanged by *this* change; the entry below retires the `iteration` overload it left standing.) -- **`$error` binds the row** — the value bound to `errorVariable` (default `$error`) is the declared `TryCatchErrorValue`: `nodeId` and `message` as before, plus `iteration` and the loop's current `item` when the failure happened inside a loop body. A `subflow` / `map` child run has its own variable scope and therefore binds neither, so a parent's row identity never leaks into a child's `$error`. - -**`failed` absent means "not tracked", never `0`.** Runs recorded before this change keep it absent — no migration and no default, the same convention `unmeasured` carries. Defaulting it to zero would tell an operator "nothing failed" about a run nobody measured. Absent, the summary line prints no `failed=` token at all; present-and-zero prints `failed=0`. The count rides in the persisted `summary_json`, including on a summary compacted past the size cap, where the per-node `failures` it folds are exactly what gets dropped. diff --git a/.changeset/core-authrefusal-field-removed.md b/.changeset/core-authrefusal-field-removed.md deleted file mode 100644 index 595cffbc26..0000000000 --- a/.changeset/core-authrefusal-field-removed.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/core": minor ---- - -fix(core): `ResolvedAuthzContext.authRefusal` is removed — a published member nothing ever read (#14273) - -**BREAKING** published-type narrowing, shipped as `minor` under the repo's -launch-window convention for breaking changes. `ResolvedAuthzContext` — the -envelope `resolveAuthzContext` answers, exported from `@objectstack/core`'s -root entry — loses its optional `authRefusal?: { reason; message }` member. -Maintainer ruling 2026-09-02 (option A, ADR-0049 enforce-or-remove), -re-affirmed 2026-09-03 as A1 with the carriers a published narrowing owes -once the type was measured as public API: the member was written by the two -posture-conditional API-key refusals (`organization_required` at admission, -`organization_membership_ended` after grants) since #8287 and read by nothing -— zero runtime readers across every transport and consumer in the repo for -its whole life; only test assertions ever looked at it. - -What changes: - -- `ResolvedAuthzContext` no longer declares `authRefusal`. Code that reads - `ctx.authRefusal` stops compiling (`TS2339`); at runtime the property was - already absent from every resolved context except the two refused ones. -- The two refusals themselves are UNCHANGED: they still fire, still fail - closed (no `userId`, empty grants), and every transport still answers the - generic anonymous `401 UNAUTHENTICATED`. No status code, body or header - moves — a holder of someone else's key learns nothing, exactly as before. -- The refusal REASON is observable on exactly one surface, and it is not the - envelope: the server-side `[security] API key refused (reason) ...` `warn` - line at the decision point (#15256 / 2A), which names the key row id, - principal and organization for the operator. The pins that kept the two - reasons distinguishable through the field now read that line. -- `ApiKeyRefusalReason` and `ApiKeyAdmission` are unchanged — the reason - vocabulary still exists; it just no longer has a copy on the resolved - context. - -**Migration.** A consumer that read `ctx.authRefusal` deletes the read; there -is no replacement on the envelope, by design — disclosing the reason to a -caller (option B) was ruled out as a security-boundary question, and the -recorded fallback if a reader ever appears is an audit-side outlet (option C), -never the wire. Fail-closed handling keys on the absent `userId`, as every -in-repo transport already did. An operator who needs the reason reads the -server log line. - - diff --git a/.changeset/core-plugin-startup-duration-name.md b/.changeset/core-plugin-startup-duration-name.md deleted file mode 100644 index f5e82fccd1..0000000000 --- a/.changeset/core-plugin-startup-duration-name.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/core": minor ---- - -Plugin startup elapsed time is now reported as `durationMs` — the unit-bearing name the spec contract for the same result declares. `startTime`, which never held a start time, is deprecated and still populated. - -`PluginStartupResult.startTime` (`packages/core/src/plugin-loader.ts`) has always been assigned `Date.now() - startTime`, an elapsed duration, on both the success and the failure path. The name therefore asserts the opposite of the value: a reader who correctly takes `startTime` for an instant and writes `Date.now() - result.startTime` gets an age near the epoch rather than a wait. That is the one failure mode a unit convention cannot rescue — an ambiguous name makes someone stop and check, this one lets them proceed confidently wrong. - -This is not a naming preference but a divergence between what is declared and what is enforced. `packages/spec/src/kernel/startup-orchestrator.zod.ts` declares `durationMs: z.number().min(0)` — "Time taken to start the plugin in milliseconds" — for the same measure on the same result, the outcome of starting one plugin; the bare `duration` spelling is retired there with a `retiredKey()` tombstone whose prescription is "Rename the key to `durationMs`", because a duration-shaped number carries its unit in its key name, never only in describe prose. The contract surface was already correct and `packages/core` had drifted away from it. The same computation already has an honest name twelve lines above the defect in the same file: `PluginLoadResult.loadTime` carries the identical `Date.now() - startTime` under a name that does not lie. - -Three sites move, and every one of them is additive — nothing is removed, so no consumer has to change anything on this release: - -- `PluginStartupResult` gains `durationMs?: number`. `startTime?: number` stays, still carrying the same value, marked `@deprecated` with a doc comment that states plainly it is elapsed milliseconds and not an instant. -- `ObjectKernel.getPluginStartupDurations()` is added; `getPluginMetrics()` becomes a `@deprecated` delegating alias returning the same map. -- The private `pluginStartTimes` map is renamed `pluginStartupDurations` (private; no reader outside `kernel.ts` in this repo or in the pinned `objectui` sibling). - -Migration, where you want it: read `result.durationMs` where you read `result.startTime`, and `kernel.getPluginStartupDurations()` where you called `kernel.getPluginMetrics()`. The values are identical, so the change can be made at leisure; both old spellings keep working until they are removed. - -ADR-0087 disposition: no migration-ledger entry, and none is required. Nothing is retired by this release — the old member and the old method both remain, populated and callable, which is ADR-0087's L1 outcome (the old shape keeps loading while the fleet moves) rather than a retirement. There is also nothing for `objectstack migrate meta` to rewrite: `packages/core/src/plugin-loader.ts#PluginStartupResult` is a runtime TypeScript interface with no Zod schema, no `packages/spec` declaration and no stored representation — the `PluginStartupResult` in `packages/spec/src/kernel/startup-orchestrator.zod.ts` is a separate, differently-shaped declaration that this change does not touch, and that schema's own `duration` tombstone entry (`packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts`) records that core's interface is not a reader of it. Core simply does not adopt the retired spelling. When the deprecated spellings are removed, that removal is the change that carries the ledger disposition. diff --git a/.changeset/core-private-keys-pin-extension-boundary.md b/.changeset/core-private-keys-pin-extension-boundary.md deleted file mode 100644 index 1f35a21fb9..0000000000 --- a/.changeset/core-private-keys-pin-extension-boundary.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/core": patch ---- - -fix(core): narrow the operation-private-keys pin's scanner to `.ts`, so it judges exactly the population turbo re-runs it for (#15090) - -`packages/core/src/security/operation-private-keys.pin.test.ts` filtered its -candidate set with `/\.tsx?$/` — `.ts` **and** `.tsx` — while this package's -declared radius in the cross-package declaration table is a `packages/**` -subtree glob ending in `.ts`. So the pin judged a population **strictly wider** -than the one either scoping layer of `check:cross-package-test-inputs` knows -about: Layer A never unions this package into the test shard when a `.tsx` file -changes, and Layer B never moves the `test` task's cache hash for one. A `.tsx` -file under `packages/` declaring its own `OPERATION_PRIVATE_KEY_PREFIX` or -`withoutOperationPrivateKeys` was therefore scanned by the pin and invisible to -CI's scoping — landing on `main` with every PR green and then reddening whichever -unrelated PR next touched a `.ts` file. That is the #7802 shape the declaration -table exists to close, one extension wide. - -Repaired by narrowing the **scanner**, not by widening the **glob** — and that -asymmetry is measured rather than assumed. On `b548e438d`, adding a `.tsx` glob -to this package's roster entry and re-deriving `check:cross-package-test-inputs`' -watch hints flips the dispatch-gates self-test case *"nor a .tsx test file inside -it"* from true to false, with the added glob itself as the covering hint. That -case is a live specimen for "a test class the hint route cannot reach", so the -red is real and re-pointing it is a decision in another lane, not a fixup. - -What the boundary costs, measured on the pin's own surface (tracked **plus** -untracked, ignored paths excluded) at `b548e438d`: **5408** `.ts` files scanned, -8 of them mentioning a guarded symbol; **8** `.tsx` files excluded, **0** of them -mentioning either symbol. The loss is empty today — and that reading is no longer -transcribed and trusted. A new case re-measures it on every run: it asserts the -excluded `.tsx` population is non-empty (so the boundary is an exclusion and not -an empty tree describing itself), that the filter really drops those files, and -that none of them declares either symbol. Ablation, with the restore proven by -blob hash rather than by exit code: re-widening the scanner reddens it while the -offender assertion stays green — which is precisely the failure mode, since a -wider scanner reads as coverage CI never runs — and planting a `.tsx` -redeclaration reddens it with a message that says the choice is a second-gate -trade, not a one-line widening. - -The correspondence between scanner and glob is now stated at **both** ends: the -pin's header and the declaration table's entry for this package. No published -surface moves — the only source file edited is a test. diff --git a/.changeset/core-time-zone-domain-repoint.md b/.changeset/core-time-zone-domain-repoint.md deleted file mode 100644 index ccfbd34775..0000000000 --- a/.changeset/core-time-zone-domain-repoint.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/core': patch ---- - -refactor(core): the authz context's time-zone probe is now the shared value-domain predicate, not a third copy of it - -`resolve-authz-context.ts` carried a module-private `isValidTimeZone` — the -`Intl.DateTimeFormat` probe, re-stated. It was the third copy of one -definition, alongside `@objectstack/spec/shared`'s `isValueDomainMember` and -`service-settings`' own re-statement. `coerceTimeZone` now calls -`isValueDomainMember('iana_time_zone', …)` and the copy is gone. - -**No behavioural change, measured rather than asserted.** The two predicates -were run over a shared 4,058-input corpus — the zones -`Intl.supportedValuesOf('timeZone')` omits (`UTC`, `Asia/Kolkata`, -`Europe/Kyiv`, `Asia/Ho_Chi_Minh`, `US/Eastern`, `GMT`), every member of that -enumeration plus its case- and space-padded variants, refusals, `Etc/` and -offset spellings, legacy aliases, and fuzz — with **zero disagreements**, and -the same zero at the `coerceTimeZone` level. The call site's own -pre-processing (trim, stringify a non-string, refuse blank) is unchanged. - -What this buys is drift resistance, not a fix: core's time-zone acceptance now -sits under the shared pins, so a future "modernisation" to -`Intl.supportedValuesOf('timeZone')` — which would silently narrow what the -authz context accepts, since that enumeration omits this platform's own -default `UTC` — turns a test red instead of shipping. diff --git a/.changeset/cron-dialect-row-names-croner.md b/.changeset/cron-dialect-row-names-croner.md deleted file mode 100644 index 722418f529..0000000000 --- a/.changeset/cron-dialect-row-names-croner.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The Expression Protocol dialect table no longer names `cron-parser` as the `cron` engine. That package is not a dependency of any ObjectStack package; the row shipped to authors through the generated reference page (`content/docs/references/shared/expression.mdx`) and pointed them at the wrong library for field counts, alias vocabulary and second-field semantics. - -The row now says what the code does: no cron syntax is judged at parse time; `croner` evaluates a cron expression only when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → `CronJobAdapter`, where an invalid pattern is refused); every other cron-typed slot is parsed and reaches no engine; and `@objectstack/formula`'s registered `cron` engine has no caller outside that package. Documentation only — no schema, accept set or behaviour changes. diff --git a/.changeset/cube-registry-docblock-measured-15019.md b/.changeset/cube-registry-docblock-measured-15019.md deleted file mode 100644 index 29ec181826..0000000000 --- a/.changeset/cube-registry-docblock-measured-15019.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -`CubeRegistry`'s documentation now describes what the class actually does. Four claims it shipped were measured false against the built package; no behaviour changes, and the corrected text ships in `dist/index.d.ts`, where consumers read it. - -The class docblock said cubes reach the registry "from two sources: manifest definitions, and object schema inference". Neither half held. Two sources were missing — a compiled dataset's Cube (ADR-0021), registered under the dataset's name by `queryDataset`, and the ad-hoc Cube `ensureCube` / `inferCubeFromQuery` mints from the members a query references. And object schema inference is `inferFromObject`, which no path in this repository calls: its only in-tree caller is a unit test. The list now names the three sources that do write to the registry, and points at the method for the fourth door instead of advertising it as delivered. - -`inferFromObject`'s own "heuristic rules" list was wrong in three of five bullets. Driving the built package: - -- `number` / `currency` / `percent` fields mint one `sum` and one `avg` measure each — not the documented `sum`, `avg`, `min`, `max`. No `min` or `max` measure exists. -- `boolean` fields become a `boolean` dimension and nothing else. The documented "`count` measure (count where true)" is not minted. -- Every field becomes a dimension. The documented "all non-computed fields" implies an exclusion the code does not have, on a parameter that carries no such flag. - -The two accurate bullets (a default `count` measure, and `date` / `datetime` fields becoming `time` dimensions granulated day/week/month/quarter/year) are kept and stated in the form the run produced. - -The method's docblock now also records what it is: a published method with no in-repo caller, still callable by consumers through the package entry (`CubeRegistry`) or `AnalyticsService.cubeRegistry`, whose output does reach the wire because `getMeta()` serves its labels as `CubeMeta` titles. diff --git a/.changeset/dashboard-gap-author-vocabulary.md b/.changeset/dashboard-gap-author-vocabulary.md deleted file mode 100644 index 2b8464be53..0000000000 --- a/.changeset/dashboard-gap-author-vocabulary.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/platform-objects": patch ---- - -fix(spec): the dashboard `gap` field no longer describes itself to app authors in Tailwind vocabulary - -`ui/dashboard`'s `gap` key told app authors its value in the vocabulary of a CSS -library they never chose and cannot act on. **Two** independent producer strings -carried that wording, and they feed two independent customer-facing surfaces: - -- `dashboardForm`'s `helpText` — `Grid gap (Tailwind units)` — rendered verbatim in - the Studio property panel, which is spec-driven and feeds this form straight into - the generic form renderer. -- `DashboardSchema.gap`'s `.describe()` — `Grid gap in Tailwind spacing units` — - rendered as this field's row in the published reference page - `content/docs/references/ui/dashboard.mdx`. The reference corpus renders - `.describe()`, never `helpText`. - -Both now read **Space between widgets, in steps of 0.25rem (4 = 1rem)**: what the -author decides, plus the magnitude, stated in a CSS unit instead of a framework's -scale. The magnitude had to survive the rewrite rather than be dropped with the -framework name — the number is a spacing step, so `4` means `1rem` and not `4px`, -and an author who lost that would come away knowing less than before. - -The step size is stated as measured rather than inferred: the dashboard renderer -sets the grid gap as an inline style computed from this key, so every accepted -value is linear and one step is exactly `0.25rem`. "Tailwind units" was doubly -wrong — it named an implementation dependency, and it named one the consumer of -this key does not have. - -**No schema change.** `gap` stays `z.number().int().min(0).optional()` and accepts -exactly what it accepted before; nothing is added to or removed from any public -surface. `columns` is deliberately untouched on both of its producer lines — -`12` is an author-visible fact about the grid being laid out, not a framework -detail — and this is one field's two strings, not a sweep for framework words. - -The `en` metadata-forms translation bundle is a mechanical copy of the form source, -so it is regenerated to match. Translated locales are not touched: regeneration -fills gaps only and never overwrites an existing leaf. diff --git a/.changeset/dashboard-route-app-segment.md b/.changeset/dashboard-route-app-segment.md deleted file mode 100644 index ccaf5d1b02..0000000000 --- a/.changeset/dashboard-route-app-segment.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`dashboard-action-route-unresolved` now resolves the `apps/NAME` head of a dashboard header action's `url` target against `stack.apps`, and reports every unresolved `/` segment in the path rather than stopping at the first one it recognizes. - -Before this, `URL_COLLECTION_TO_STACK_KEY` had no `apps` entry, so an `actionUrl` like `/apps/no_such_app_nope/crm_lead` was never checked at all — a dashboard button pointing at an app that does not exist passed lint clean. Worse, once a bad app name was combined with a second bad segment later in the same path (e.g. `/apps/no_such_app_nope/dashboard/no_such_dashboard_nope`), the old loop returned at the FIRST recognized segment and reported only that one — so a bad app name plus a bad dashboard name reported only the dashboard, never the app. - -**Behavior change on paths that used to pass clean:** the loop no longer stops scanning a path the moment it recognizes one collection segment, resolved or not. A path like `/dashboards/exec/views/bad_view` — where `exec` is a real dashboard but `bad_view` names no view — used to report nothing (the loop returned as soon as `dashboards/exec` resolved, never reaching `views/bad_view`); it now reports one warning on the `views/bad_view` segment. Any stack with a dashboard `url` action whose path recognizes a valid collection segment followed later by an unresolved one will see a NEW warning here that did not fire before. This is intentional — it is the same false-affordance category the rule already exists to catch — but it is a real, visible change to what a clean `lint` run reports on such stacks, not a pure addition. diff --git a/.changeset/data-driver-aggregate-declared.md b/.changeset/data-driver-aggregate-declared.md deleted file mode 100644 index 1563cf344c..0000000000 --- a/.changeset/data-driver-aggregate-declared.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`IDataDriver` now declares `aggregate?` — the one engine-reached driver verb that had no signature to match against. - -The engine has always dispatched native aggregation by presence (`typeof driver.aggregate === 'function'`) and called `driver.aggregate(object, query, options)`, but the interface never spelled the member, so a custom driver's `aggregate` was checked in neither direction: swapped arguments or a non-row result compiled clean and surfaced only after the engine's `having` filter silently matched nothing. The member is declared optional, matching the presence test — a driver without native aggregation omits it and stays conformant, served by the `find()` + in-memory fallback. - -Additive: every in-repo driver already satisfies the declared signature (`(object: string, query: DriverQuery, options?: DriverOptions) => Promise[]>`); a wider parameter union or a looser return type stays assignable. What is newly refused is a wrong argument order or a non-array result. No `DriverCapabilities` bit is added — presence remains the capability test, as `data/driver.zod.ts` rules. diff --git a/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md b/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md deleted file mode 100644 index c66ebc58f2..0000000000 --- a/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478) - - - -**BREAKING** — eight published duration keys are renamed and tombstoned. Shipped -as `minor` under the repo's launch-window convention for breaking changes; the -hand-migration prescriptions are registered under protocol major 18. Maintainer -ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in -the key NAME, never only in its `.describe()` prose, and grandfathers no existing -offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6 -(#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6 -(#15679) cleared `system/`. This card clears the remainder, and is the first -where the gate itself reads **`zero offenders`** and exits `0`. - -⚠️ That is green **for the gate's currently declared population** -(`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has -already measured an offender outside this subtree, so the gate is expected to go -red again by design. This changeset does not claim #14478 is finished. - -## FROM → TO - -| key | replacement | unit | -|:--|:--|:--| -| `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds | -| `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds | -| `ConnectorTrigger.interval` | `intervalSeconds` | seconds | -| `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | -| `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | -| `TursoConfig.timeout` | `timeoutMs` | milliseconds | -| `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds | -| `ConversationAnalytics.duration` | `durationSeconds` | seconds | - -**Every value is unchanged** — only key names move. The two keys that carried a -default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to -60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare -none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything -under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and -`TursoConfig.timeoutMs` still refuse a zero or negative integer, and -`ConversationAnalytics.durationSeconds` still refuses a negative length. Every old -spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site -(input type `never`) and fails the parse with the rename prescription rather than -a bare unrecognized-key error. - -`dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval` -— were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming -the tombstone would have prescribed a key the shape refuses, which is the one -failure this rename could have introduced silently; a pin asserts all three. - -## ⚠️ `dashboard.refreshInterval` crosses a repository boundary - -This is the only rename in the whole stack whose consumer is in **another -repository**, so its reader could not move in this PR the way every other reader -in this card did. objectui's dashboard renderer reads the key, multiplies by -1000 to drive a `setInterval`, and republishes it as an authoring input the -console offers. Those sites move in a follow-up objectui card, sequenced behind -a release that actually ships this rename. - -Until that lands the renderer sees an absent key and simply does not start its -refresh timer — a dashboard still renders, and still refreshes when the user -asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards -and `os migrate meta` correct in the meantime. - -## ⚠️ An eighth key moves that the gate did not list - -`AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()` -named no unit at all, and the predicate judges prose against name. - -It moves anyway because it is not a second key. `persistence: { type: 'auto' }` -resolves to the same Node.js file adapter as `type: 'file'`, and this value is -forwarded to the same `FileSystemPersistenceAdapter` field, in the same -milliseconds, under the same `min(100)` bound. Renaming one arm and not the other -would have left one value with two spellings across sibling arms of one union, -and the driver reading both — the consumer-side dialect Prime Directive #12 -forbids. Its describe now names the unit too, and a pin asserts the refusal on -the arm the gate never listed, so a later reader cannot "restore" the bare -spelling as an over-application of the rule. - -## Dispositions — four D2 conversions, two semantic entries - -Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and -unlike card 4/6 this card's answer is split. - -**D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:` -are each a stack collection whose members are stored whole as `sys_metadata` -rows, so the conversion chain has a seam that sees them: -`dashboard-refresh-interval-to-refresh-interval-seconds`, -`connector-health-and-trigger-durations-unit-in-key` (both connector keys in one -pass, emitting separately), -`memory-persistence-auto-save-interval-to-ms` (both persistence arms) and -`turso-config-timeout-to-timeout-ms`. The two datasource conversions are -driver-aware for the reason `datasource-config-driver-key-aliases` records: a -bare `config.timeout` under another driver is that driver's own key and must not -be touched. - -**Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime -and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument -reached only through `AggregationPipeline.options`. Neither is a stack collection -member or a stored row, so the chain has no seam — the disposition every -runtime-emitted measurement in this stack has taken. - -All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`. - -## A retirement tombstone is no longer read as a secret - -`refusedCredentialKeys` derives a driver's refused inline credentials by finding -`z.never()` keys in its config contract. A `retiredKey()` tombstone is also a -`z.never()`, and until this card no driver contract carried one — so "never ⇒ -credential" held by accident of population rather than by construction. The first -tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a -millisecond budget was a secret: it was redacted off the datasource read path and -dragged a non-credential name into the fallback list every unrecognised driver is -scrubbed by. - -The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()` -itself stamps. The exclusion is deliberately **negative** — skip declared -tombstones — rather than positive (keep only keys marked `format: 'password'`), -even though every credential slot in every builtin contract does carry that -marker today: under-redacting is the dangerous direction, so a future credential -key whose author forgets the marker is still scrubbed, and only a key that has -explicitly declared itself retired may drop out. Both directions are pinned. - -## Keys deliberately left alone - -`TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs` -already carried their unit — they are the same-shape neighbours that made the -bare `timeout` and `monitoringWindow` collisions visible, and pins assert they -did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every -number on `ConversationAnalytics` other than the duration is a count of messages, -tokens or events: a count has no unit to carry. The turso schema shipped by -`@objectstack/driver-turso` is a separate declaration outside this gate's -declared population and is not touched here; card 6/6 owns it, so the two -declarations disagree by design until that lands. diff --git a/.changeset/dataset-measure-format-date-styles.md b/.changeset/dataset-measure-format-date-styles.md deleted file mode 100644 index 9a1c025715..0000000000 --- a/.changeset/dataset-measure-format-date-styles.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`DatasetMeasureSchema.format` now documents what a DATE-valued measure can and cannot say, and the numeral-pattern examples no longer stand as the whole story. - -The field was silent about date measures while advertising `e.g. "$0,0.00", "0.0%"` — the pattern grammar a date measure is precisely unable to read. An author with a `min` / `max` over a date field read that line, wrote `format: 'YYYY-MM-DD'`, parsed clean, and got the locale default. - -The statement is carried by a `.describe()` where there was none, so it reaches the published surfaces an author actually reads: the generated JSON Schema (`json-schema/ui/Dataset.json`, `DatasetMeasure.json`) and the reference table in `content/docs/references/ui/dataset.mdx`, whose Description cell for `format` had been rendering the silence as a blank. The docblock above it carries the longer measured record. - -What it now says, measured rather than assumed against the objectui pin this repo builds against: a numeral pattern applies to a numeric measure; a date-valued measure never reads a date PATTERN — a date-only value reads `format` as a display STYLE (`short`, `relative`), and a datetime value ignores `format` altogether. - -Nothing accepts or rejects differently: `format` remains `z.string().optional()` and no measure is refused. Documentation over a published schema (objectui#7178 ruled A). diff --git a/.changeset/datasource-admin-tenancy-posture.md b/.changeset/datasource-admin-tenancy-posture.md deleted file mode 100644 index 455ff108e2..0000000000 --- a/.changeset/datasource-admin-tenancy-posture.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-datasource': patch ---- - -The datasource admin routes derive the tenancy posture before resolving the caller - -`requireDatasourceAdmin` resolved the request with `resolveAuthzContext({ ql, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran on this family, and an API key stamped with an organization its owner had left was admitted; the routes then gated it on `authz.systemPermissions` alone. Because this family gates on system capabilities rather than on organization-scoped rows, the consequence was an admitted principal rather than a cross-organization row read. - -The posture is now read off the kernel's `tenancy` service and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Patch rather than minor: no accept set widens, and a declared guard returns to enforced. diff --git a/.changeset/decision-predicate-envelope-refused.md b/.changeset/decision-predicate-envelope-refused.md deleted file mode 100644 index 2d29f4e39b..0000000000 --- a/.changeset/decision-predicate-envelope-refused.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/lint": minor ---- - -A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. - -A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate. - -- `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge. -- `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`. - -**String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved. - -An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map. diff --git a/.changeset/default-value-literal-gate-prefers-rename.md b/.changeset/default-value-literal-gate-prefers-rename.md deleted file mode 100644 index 563e0c9a6c..0000000000 --- a/.changeset/default-value-literal-gate-prefers-rename.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The authoring-time `defaultValue` gate now prescribes the key rename an author actually made, instead of a type error about a member they never wrote. - -`checkLiteralDefaultValue` — the shared core of the field gate (`FieldSchema.defaultValue`) and the action-param gate (`ActionParamSchema.defaultValue`) — read a value-contract rejection positionally, `result.error.issues[0]`. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a default whose keys were **renamed** the actionable message sorted last and was discarded. An `address` default authored as `{ street: 5, postal_code: '98101' }` answered `Invalid input: expected string, received number`, and a `location` default authored as the legacy `{ latitude, longitude }` pair answered `Invalid input: expected number, received undefined` — while `AddressValueSchema` and `LocationValueSchema` had each built the rename prescription and thrown it away. Which of the two the author got depended on whether some unrelated member happened to also be wrong: nobody chose that, and nobody could see it. - -The gate now prefers the undeclared-key issue when the rejection carries one. `LiteralDefaultValueVerdict.detail` keeps its name, its type and its documented meaning — "the 'why' a refusal carries verbatim"; what changes is which of several already-reachable messages it carries. - -⛔ No verdict moves. Exactly the same defaults are accepted and refused, on the same evidence — only the refusal text changes. - -Scoped by measurement rather than inherited: the sixteen classes `valueSchemaFor(def, 'stored')` covers were swept again on this function, at both arities. Only `location` and `address` can emit `unrecognized_keys` at all, because only they are backed by a key-closed object schema — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half of the rejection. diff --git a/.changeset/delete-data-request-schema-provenance.md b/.changeset/delete-data-request-schema-provenance.md deleted file mode 100644 index 7c1bccf9d5..0000000000 --- a/.changeset/delete-data-request-schema-provenance.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Record, on `DeleteDataRequestSchema` itself, what it is for and why the DELETE data door carries no `requestSchema` for it. - -The schema is the request contract of `DataProtocol.deleteData()`, consumed statically through the `DeleteDataRequest` type alias and parsed at runtime nowhere — a grep that finds "exported, documented, zero `safeParse` call sites" is reading the wrong surface, and had already filed it once as a gap. Its docblock now says so; records that the absence of a `requestSchema` on `DELETE /api/v1/data/:object/:id` is a pinned decision (#3899 — the catalog entry states it in place of the key, and `plugin-rest-api.schema-refs.test.ts` goes red if one is added, because the route reads no body); and points at the compile-time check (#15866) under which a field added to the schema as required reddens the door at build instead of being silently unsent. - -Documentation only: no shape, `.describe()` text, or export changes. `@objectstack/spec` ships the new text in its published type declarations and in the source file it publishes directly via its `src/**/*.zod.ts` entry. diff --git a/.changeset/dev-mcp-connect-hint-origin.md b/.changeset/dev-mcp-connect-hint-origin.md deleted file mode 100644 index 631f7e5583..0000000000 --- a/.changeset/dev-mcp-connect-hint-origin.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os dev`'s MCP connect hint is built from the origin the deployment is REACHABLE on, not from the socket the serve child bound. - -A dev boot printed two MCP addresses. The ready banner's `➜ MCP:` row goes through `resolveAuthBaseUrl` — `OS_AUTH_URL` → legacy `BETTER_AUTH_URL` → `OS_BASE_URL` → `http://localhost:` — while the `🤖 MCP server — connect a coding agent` block below it derived its base from the child's `objectstack:listening` `url`, which is the bound socket by construction. `OS_AUTH_URL` never entered that expression, so anything sitting in front of the app split the two apart: measured on `objectstack dev -p 4001` under `OS_AUTH_URL=https://localhost:4443` behind a TLS reverse proxy, the banner said `https://localhost:4443/…` and the block said `http://localhost:4001/…` in the same output. - -That block's `Connect` line is a command the reader pastes, so the wrong origin was not cosmetic: `claude mcp add` registered an entry against an address discovery never advertises and, behind the proxy, nothing can reach — and the two rows disagreeing made the correct one look like the typo. - -- **One resolver, not two.** The hint now calls the same `resolveAuthBaseUrl` the banner's call site does, with the port the child ACTUALLY bound. The precedence chain is not restated anywhere in `dev`. -- **The ordinary local boot is unchanged, by the resolver's own tail.** With none of the three variables set the chain answers `http://localhost:` — including dev's auto-shift (`3000` busy → `3001`) and an ephemeral port — so the local case needs no second fallback and cannot be broken by a canonical origin being hardcoded in front of it. -- **An unusable base URL now prints no block at all.** When the chain yields nothing parseable — a set-but-empty `OS_AUTH_URL=`, which does not fall through to the rest of the chain, or a value with no scheme — the banner's rule is to print paths with no origin and name the variable that fixes it. A `claude mcp add` line has no paths-only form, so the block is omitted instead of reprinting, on the same screen, the exact address the banner just refused to print. - -`resolveAuthBaseUrl` itself is untouched, including its set-but-empty behaviour; the `Endpoint` / `Skill` / `Connect` wording is unchanged. diff --git a/.changeset/diagnostics-untyped-sweep-organization-forwarding.md b/.changeset/diagnostics-untyped-sweep-organization-forwarding.md deleted file mode 100644 index 259062b790..0000000000 --- a/.changeset/diagnostics-untyped-sweep-organization-forwarding.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -An organization-scoped caller's own items now appear in the untyped metadata diagnostics sweep. - -`GET /api/v1/meta/diagnostics` has two arms. The `?type=` arm has stated the caller's organization since #13753; the untyped whole-registry sweep passed none, so the Studio governance summary reported clean tiles over a partition it never read — undercounting relative to the per-type drill-down screen you reach by clicking into it. A summary whose whole job is surfacing problems, and which structurally cannot see a class of them while its own drill-down can, issues a false all-clear. The untyped arm now forwards the caller's organization, so items that organization authored on the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`) are counted in `stats`, `total` and `scannedItems`. - -The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field, status code or contract surface. There is no single type to fold on for a whole-registry sweep, and folding on any one of them would suppress the organization for every type at once; instead `getMetaDiagnostics` reads each swept type through `getMetaItems`, which applies the `allowOrgOverride` read gate to its own request type, so every type is scoped on its own registry flag. A non-overridable type (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into the report. An anonymous or organization-less caller reads exactly what it read before, and the `stats` / `total` / `scannedTypes` arithmetic is unchanged in shape. diff --git a/.changeset/diff-usage-error-to-stderr.md b/.changeset/diff-usage-error-to-stderr.md deleted file mode 100644 index f9345aa844..0000000000 --- a/.changeset/diff-usage-error-to-stderr.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os diff` with no path arguments no longer prints its usage error on stdout — in either face. - -The refusal sat **above** the command's first `if (!flags.json)`, so the face was still undecided when it ran and it fired in **both**. `printError` plus three `console.log` calls — all four writing to stdout — then `process.exit(1)`. Measured on the published entry `bin/run.js` with `NO_COLOR=1` and the streams captured separately, `os diff --json` and bare `os diff` answered byte-identically: exit 1, **141 bytes of prose on stdout, an empty stderr**, and `JSON.parse(stdout)` throwing on the one stream `--json` reserves for the machine. - -The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. The 141 bytes moved intact — stdout 141 → 0, stderr 0 → 141. Nothing else moves: - -- **the exit code is still 1**, so a consumer branching on exit status sees no change at all; -- **the wording is unchanged**, both usage hints included, so a human reading a terminal sees the same four lines; -- **nothing is accepted or rejected differently** — no invocation that worked before fails now. - -⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* on a refusal is an open envelope question, entangled with `os lint --eval --json`'s bare `{ error }` (no `code`, no `httpStatus`), and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today. - -This is the sibling of the `resolveConfigPath` repair, and a genuinely different site: that one is reached through `loadConfig()`, this one is `diff.ts`'s own usage error, raised before any config work happens. The existing pin drives `os diff` with two paths precisely so the run gets *past* this check, so it could not see this path. A new pin (`diff-usage-error-stream.e2e.test.ts`) drives the bare form in both faces, and carries a structural tripwire: across 62 command modules, 27 of which offer `--json`, `diff` was the only one with a stdout write above its guard, and the tripwire goes red if another arrives. diff --git a/.changeset/discovery-subscribable-channel-definition.md b/.changeset/discovery-subscribable-channel-definition.md deleted file mode 100644 index 3fed65a35c..0000000000 --- a/.changeset/discovery-subscribable-channel-definition.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor -"@objectstack/runtime": minor ---- - -`/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. - -**A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. - -**The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. - -**Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". - -What to read instead, per case: - -- deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; -- asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. - -Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. - -New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. diff --git a/.changeset/dispatcher-scope-strip-environments-prefix.md b/.changeset/dispatcher-scope-strip-environments-prefix.md deleted file mode 100644 index 3a0451820f..0000000000 --- a/.changeset/dispatcher-scope-strip-environments-prefix.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -An environment-scoped URL now reaches a dispatcher domain instead of answering 404. - -`HttpDispatcher.dispatch()` reads the scoped-URL prefix in three places — the environment-id hint parser, the OAuth-on-MCP gate, and the scope strip that lets `DomainHandlerRegistry` match the remainder. Only the first had been moved to the ADR-0006 `/environments/` spelling; the other two still matched the retired `/projects/` one. The strip therefore never fired on a real scoped URL, and since the registry matches from the head of the path, every environment-scoped request arriving through the `@objectstack/hono` catch-all — the entry cloud hosts mount, and the only one that hands `dispatch()` a still-scoped path — matched no domain at all: - -``` -GET /api/v1/environments//data/task -> 404 ROUTE_NOT_FOUND (now: reaches /data) -GET /api/v1/environments//health -> 404 ROUTE_NOT_FOUND (now: 200) -GET /api/v1/data/task (control) -> reaches /data, unchanged -``` - -The dispatcher-plugin's own scoped mounts were never affected: they pass a pre-stripped subpath (`${prefix}/environments/:environmentId/automation` dispatches the literal `/automation`), which is why the standalone server showed nothing. - -The OAuth 2.1 gate moved with it. An access token is honoured only on the MCP surface, and that test runs against the still-scoped path — so `/api/v1/environments//mcp` would have reached the MCP domain with its token refused had the strip been repaired alone. - -**If you still emit the old spelling**: replace `/api/v1/projects/:projectId/...` with `/api/v1/environments/:environmentId/...`, as `content/docs/api/environment-routing.mdx` has instructed since ADR-0006 D2. That prefix is no longer stripped, and it was never a working alias in the first place: nothing parses `/projects/`, so stripping it discarded the only place the request named an environment and served it from the host default instead. ADR-0006 D2 retired `project` on the API surface with no aliases, so the repair is one spelling in all three readings rather than a two-prefix alternation. diff --git a/.changeset/dist-freshness-declaration-stamp.md b/.changeset/dist-freshness-declaration-stamp.md deleted file mode 100644 index ae718158c2..0000000000 --- a/.changeset/dist-freshness-declaration-stamp.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`check:api-surface` (and every other gate that reads `packages/spec/dist`) no longer refuses a dist that is exactly current because a source file's mtime moved without its bytes changing. - -The freshness rule shared by four gates and the pre-commit hook compares `dist/**/*.d.ts` mtimes against `src/**/*.ts` mtimes. That is the right primitive — it is the artifact those gates consume, and it sees the hand-edited dist and the toolchain change no content digest can — but it cannot tell a real edit from a rewrite that left the bytes alone. A `git merge` re-checks-out an unchanged source file and bumps its mtime; the build that follows correctly does not run, because turbo's cache hashes content, so it is a cache hit that rewrites nothing and leaves every `dist/` mtime where the previous build left it. The gate then refused a correct dist, and prescribed a full rebuild — minutes, under the shared verify lock — of an artifact that needed none. - -The mtime rule keeps its power to convict and gains one way to be answered. `packages/spec`'s build now records a second stamp beside the existing one, `dist/.build-input-hash-dts`, holding the same build-input digest — but written **only** by a build that actually emitted declarations, so `OS_SKIP_DTS=1` leaves it alone. When that digest equals the sources on disk, the declarations demonstrably describe them and the refusal is cleared. The evidence may only ever **acquit**: a missing, unreadable or mismatched stamp leaves the mtime verdict standing, so nothing that passed before can start failing, and the `OS_SKIP_DTS=1`-on-a-built-tree shape that ruled out `dist/.build-input-hash` for this purpose still fails, because that build never refreshes the new file. - -The refusal message was wrong in the same case and is now driven by what was measured: it names a real content change and prints both digests when the stamp disagrees, says plainly that there is nothing to compare against when no stamp exists, and no longer sends every reader after `OS_SKIP_DTS` regardless of cause. It also notes that a repo-wide `pnpm build` may be a cache hit that rewrites nothing, so the remedy names the package build directly. - -The published tarball gains one 65-byte file next to the stamp it already shipped. diff --git a/.changeset/domain-claim-segment-boundary.md b/.changeset/domain-claim-segment-boundary.md deleted file mode 100644 index 693240aaa1..0000000000 --- a/.changeset/domain-claim-segment-boundary.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -Dispatcher domain routes stop claiming their lexical neighbours: `DomainRoute.match` now defaults to `'segment'`, and the project-membership skip list gained the same boundary. - -Ten shipped routes — `/actions`, `/ai`, `/analytics`, `/automation`, `/data`, `/i18n`, `/meta`, `/notifications`, `/packages`, `/ui` — carried the implicit `'prefix'` default, a bare `path.startsWith(prefix)` with no segment boundary. So `/datax`, `/metaxyz`, `/uifoo`, `/aixx` and `/packagesomething` were each claimed by a domain that does not own them, and a package mounting one of those namespaces later would have been shadowed by a domain that never wanted it. `/auth` was the eleventh member of the family and was repaired on its own; this closes the rest at the seam rather than one route at a time, so the eleventh domain someone adds is boundary-correct without having to remember anything. - -- **The default moved, the mode did not go away.** `match: 'segment'` (the prefix exactly, plus everything under `prefix + '/'`) is the default; `match: 'prefix'` still buys the bare `startsWith` claim for a route that asks for it in writing. One shape genuinely needs it and now declares it: a prefix ending in `'?'` (`/keys?`, `/mcp?`, `/mcp/skill?`), which reproduces the legacy branch's query-string form for adapters that pass the query through in the path. There is no `/` after that `'?'`, so a segment match cannot express it — those three routes match exactly what they always did. -- **What each narrowed claim used to answer, measured per domain rather than assumed.** They were not uniform: `/actionsx`, `/aixx`, `/automationx`, `/metaxyz` and `/packagesomething` answered `401`; `/i18nxx` and `/notificationsx` answered `501`; `/analyticsx` and `/uifoo` fell through unhandled. `/data` was the worst and the reason per-domain measurement was owed — its handler reads the sub-path as an OBJECT NAME, so `GET /datax` answered a **success envelope for a fabricated object** and `GET /datax/foo` **threw** `Record foo not found in x`. Each of the ten now answers the dispatcher's `ROUTE_NOT_FOUND` envelope, which is what they should always have answered. No caller depended on any of these: nothing in the repo builds a dispatch path by concatenating a domain prefix without a separator, and no route-ledger row or SDK method addresses a shape of this kind. -- **The membership skip list, which was the same predicate with a worse consequence.** `enforceProjectMembership` skipped the control plane with `skipPaths.some(p => path.startsWith(p))` and `'/auth'` in the list, so `/authentication/foo` was waved **past the membership check** rather than merely routed somewhere wrong. It was latent — nothing claims `/authentication/*`, so such a request 404s first — and it would have gone live the day any domain claimed a path of that shape. The skip list stops at `'/'`, `'?'` or end-of-string now; the `'?'` form is part of the boundary on purpose, so `/auth?redirect=…` keeps the exemption it has today and the control plane is not newly gated. - -Every domain still claims itself and every path under it, `/auth/me/permissions` included; the registry header comment that described the old rough edges as deliberate no longer describes code that has them. diff --git a/.changeset/dr-backup-schedule-croner-accepted-example.md b/.changeset/dr-backup-schedule-croner-accepted-example.md deleted file mode 100644 index d7e0daa62e..0000000000 --- a/.changeset/dr-backup-schedule-croner-accepted-example.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The `DisasterRecoveryPlan` docblock example no longer teaches a cron dialect the platform's scheduler refuses. - -`DisasterRecoveryPlanSchema`'s `@example` block spelled its six-hourly backup schedule `'0 0/6 * * *'`. A numeric prefix before the step (`0/6`) is Quartz-style stepping. The only cron parser this platform runs is `croner` — reached through `CronJobAdapter`, which hands every scheduled expression to `new Cron(...)` — and it refuses that spelling. Measured against the `croner` 10.0.1 copy installed for `@objectstack/service-job`: - -``` -new Cron('0 0/6 * * *') - -> TypeError: CronPattern: Syntax error, stepping with numeric prefix ('0/6') - is not allowed. Use wildcard (asterisk-slash-step) or range (min-max/step) instead. -``` - -The example now reads `'0 0,6,12,18 * * *'`, which the same parser accepts and which fires at 00:00, 06:00, 12:00 and 18:00 — the instants the old spelling was written to mean. The sibling example `'0 2 * * *'` on the same schema is accepted unchanged; it was the positive control for the measurement, so the refusal above is a reading rather than a broken probe. - -The wildcard-step spelling croner's own error message suggests, and which this schema's tests use, is **not writable in this position**: inside a `/** … */` block comment the step separator closes the comment, and the file stops parsing (measured — esbuild refuses it at the example's own line). The enumerated form is the equivalent that survives a docblock, and both forms were measured to produce identical firing instants. - -Nothing fires differently, because nothing fires at all: `BackupConfig.schedule` is declared-but-unwired and reaches no scheduler, and `CronExpressionInputSchema` judges no cron syntax at parse time by design (`shared/expression.zod.ts`) — so the bad example sat in a position that is deliberately undefended. The accept set of every schema is unchanged by this edit, and no export moves. What changes is what an author copying the example gets: the docblock publishes verbatim into the shipped `dist/system/index.d.ts`, so it is the text an editor shows on hover. diff --git a/.changeset/driver-connect-summary-error-code-constants.md b/.changeset/driver-connect-summary-error-code-constants.md deleted file mode 100644 index 640e5d059d..0000000000 --- a/.changeset/driver-connect-summary-error-code-constants.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -Three more engine refusals publish their error `code` as an importable constant. - -Each of these classes already tells the reader, in its own docblock, that it is *"Identified by `code` rather than `instanceof` so it survives crossing package boundaries"* — and none of them offered anything to import. The only way to FOLLOW that published instruction was to re-spell the wire string in your own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. - -Three new exports from `@objectstack/objectql`, each graded on its own: - -- `DRIVER_CONNECT_CODE` — `DriverConnectError`'s ADR-0112 `code`. Thrown by `ObjectQL.init()` when boot-registered drivers fail to connect, which aborts kernel bootstrap. **Additive widening, `minor`.** -- `DATASOURCE_UNAVAILABLE_CODE` — `DatasourceUnavailableError`'s ADR-0112 `code`. Thrown by `getDriver()` when an object's datasource was declared but has no live driver. **Additive widening, `minor`.** -- `SUMMARY_RECOMPUTE_CODE` — `SummaryRecomputeError`'s ADR-0112 `code`. Thrown by `insert`/`update`/`delete` when parent roll-up summaries fail to recompute *after the triggering records were written*. **Additive widening, `minor`.** - -**The cost these close is already shipped, not hypothetical.** Three first-party packages in this repo match these refusals by `code` today and therefore carry a second spelling of the string: `packages/rest/src/error-response.ts` (datasource-unavailable), `packages/rest/src/import-runner.ts` and `packages/metadata-protocol/src/seed-loader.ts` (summary-recompute — both to implement the documented "the records WERE written, treat it as a warning" recovery). They keep working unchanged; they can now import the constant instead of authoring the string. - -**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary, which is what these docblocks have been telling readers to do. - -**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; every refusal throws the same `code` and the same message as before. Consumers that spell the strings themselves keep working unchanged — this adds affordances, it removes nothing. - -**All three classes were already exported and stay exported.** The constants join them on the batteries barrel; like every other `*_CODE` in this package they are deliberately not added to the lean `core.ts` entry, even though `DriverConnectError` and `DatasourceUnavailableError` themselves are published there. That asymmetry is #16260's subject for the whole family and is not decided here. diff --git a/.changeset/driver-memory-array-comparand-refusal-date-equality.md b/.changeset/driver-memory-array-comparand-refusal-date-equality.md deleted file mode 100644 index 87962b7072..0000000000 --- a/.changeset/driver-memory-array-comparand-refusal-date-equality.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -fix(driver-memory)!: an array comparand is refused instead of compared by reference, and a `Date` comparand is compared by time value - - - -`checkCondition` routed both `Date` and `Array` into `value == condition`, under a comment two lines above calling that "exact match" — and a comment seven lines below giving `==`'s reasons, all three of which are about primitives ("undefined/null mismatch", "string/number coercion", "convenient in weakly typed queries"). Between two objects `==` does no conversion at all: it compares REFERENCES. So the arm was neither exact nor a match, and the two adjacent comments described something the code could not do. - -**BREAKING** accept-set narrowing on a published driver, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`): a purely additive widening takes at least `minor`, and a narrowing is not the additive bucket, so `patch` is refused here — the public surface really does move. The two halves take DIFFERENT dispositions, and the difference is the contract's rather than this driver's: - -**An ARRAY comparand is refused.** `@objectstack/spec`'s comparand door names an array outside `$in`/`$nin`/`$between` as a position it deliberately does not rule, leaving it "to the layers that already answer it"; `ACCEPTED_FILTER_COMPARAND_TYPES` has no array member, and `driver-sql` refuses one with its own message. This driver now answers the same way, in the ADR-0112 envelope its every other filter refusal speaks (`code: 'INVALID_FILTER'`, `status: 400`), with a message naming the field, the received shape, the accepted set and the operators that DO take a list. The refusal is raised from `assertFilterConditionShape` — the one gate every face of this package runs before it evaluates anything — so the live query path, the reference matcher and the analytics face cannot disagree about it. - -Why refused rather than made to work, measured on one row `{ tags: ['a','b'] }` with the filter `{ tags: ['a','b'] }` before this change: the live query path returned the row (mingo deep-equals arrays) and the reference matcher returned none (`==` compared references). One filter, one package, two answers, neither reported — the same shape as this package's `{ field: {} }`, malformed-`$between` and non-boolean-`$null` refusals. Converging UP to deep equality was available and is not what landed: the contract declines to rule the cell, and every sibling that has answered it declines the semantics. - -**A `Date` comparand is EVALUATED, not refused** — the opposite disposition, because the contract does rule this one. `Date` is a member of `ACCEPTED_FILTER_COMPARAND_TYPES`, and `FILTER_COMPARAND_TYPE_CASES` requires that a Date comparand "must pass the door and execute everywhere". It is now compared by time value, arm for arm with `@objectstack/formula`'s `looseEq` — the sibling record-at-a-time matcher this face's conformance suites are held against — which also settles the case that actually reaches a stored row: a declared `datetime` is canonicalised to ISO TEXT on write (ADR-0053 D-B1), so a `Date` comparand meets a STRING, and `==` stringified the Date to `"Wed Jan 01 2026 …"`, which no ISO value equals. `$eq` / `$ne` take the same equality, so one predicate no longer answers two ways depending on which spelling the author used. An Invalid Date has no time value and so equals nothing, itself included. - -**What is deliberately NOT changed**, and is now pinned so a later edit cannot take it away by accident: a SCALAR comparand against a stored ARRAY. `{ tags: 'a' }` against a row storing `['a','b']` still answers false, and `{ tags: 'a,b' }` against that row still answers true, because `==` stringifies the stored array to `"a,b"`. That is a third bad direction of the same operator, but it is on the VALUE side, and the comparand door judges comparands. It is recorded, not repaired. The list operators (`$in` / `$nin` / `$between`), for which an array is the declared comparand, are untouched; so is the text family, whose comparand disposition this package's refusal gate had already recorded as deliberately fail-closed. diff --git a/.changeset/driver-memory-auto-save-interval-ms.md b/.changeset/driver-memory-auto-save-interval-ms.md deleted file mode 100644 index aa551961ff..0000000000 --- a/.changeset/driver-memory-auto-save-interval-ms.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -feat(driver-memory)!: the file-persistence auto-save interval names its unit (#15680, ruling B on #14478) - - - -**BREAKING** — `InMemoryDriverOptions.persistence.autoSaveInterval` and -`FileSystemPersistenceAdapter`'s `autoSaveInterval` constructor option are both -renamed to **`autoSaveIntervalMs`**, following the `@objectstack/spec` rename of -the authored keys on both persistence arms. - -Same value, same milliseconds, same 2000 default, same `setInterval` cadence. The -option was always milliseconds — it is passed straight to `setInterval` — and the -spec's `min(100)` bound is what made the bare name dangerous rather than untidy: -100 reads as a plausible number of seconds, so an author who guessed the unit -wrong cleared the bound, was refused nowhere, and saved a thousand times more -often than intended. - -Both persistence arms move together: `type: 'auto'` resolves to this same file -adapter and forwards the same field, so this package reads exactly one spelling -rather than two. - -```diff -- new InMemoryDriver({ persistence: { type: 'file', autoSaveInterval: 5000 } }) -+ new InMemoryDriver({ persistence: { type: 'file', autoSaveIntervalMs: 5000 } }) -``` diff --git a/.changeset/driver-memory-find-findone-create-honest-types.md b/.changeset/driver-memory-find-findone-create-honest-types.md deleted file mode 100644 index dd41c9d4c0..0000000000 --- a/.changeset/driver-memory-find-findone-create-honest-types.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/driver-memory': minor ---- - -fix(driver-memory): `find()`, `findOne()` and `create()` publish their declared types (#14435) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the same shape #13878 landed on `update()` / `upsert()` one door over, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). - -`IDataDriver` has always declared `Promise[]>`, `Promise | null>` and `Promise>` on these three doors. The emitted `.d.ts` published `Promise`, `Promise` and `Promise>`: the return types of `find` and `findOne` were INFERRED through the backing store's `any[]` rows (`private db: Record` to `getTable()`), and `create` carried an explicit annotation that itself spelled `Record`. They are now declared as the contract declares them. - -What this asks of a consumer holding a concrete `InMemoryDriver`: a caller that reads fields off a `findOne()` result narrows the `null` arm first — the arm the driver has always been able to answer with (`results[0] || null`) and that no caller was ever asked to handle; and a caller that leaned on `any` to read a member off a `find()` row or a `create()` result now types it, since the rows are `Record`. A consumer whose receiver is typed as `IDataDriver` sees no change at all — that declaration already said this. - -The parameters are deliberately untouched: `create(data: Record)` stays as it is, because narrowing an INPUT would be a second, unrelated break, and method parameters compare bivariantly against the contract's `Record`. No runtime behaviour changes; the store keeps its `any[]` rows, which the card measured to cascade if re-typed. - - diff --git a/.changeset/driver-memory-notcontains-non-string-value.md b/.changeset/driver-memory-notcontains-non-string-value.md deleted file mode 100644 index 8c67de7e7e..0000000000 --- a/.changeset/driver-memory-notcontains-non-string-value.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/driver-memory": patch ---- - -fix(driver-memory): the reference matcher's `$notContains` arm answers the predicate, not a type test, for a stored non-string value - -`match()` used to answer `{ n: { $notContains: '5' } }` with NO for `{ n: 5 }` — the arm read `typeof value !== 'string' || value.includes(target)`, so a number failed `$contains` (correct) AND its negation (wrong: for the very reason a number cannot contain the substring, it does not contain it). This package's own live mingo path admitted the row, so one filter answered two ways depending on which face was asked; on this face the failure mode was silently dropped rows. - -The arm now answers what `FILTER_TEXT_CASES`' new `score` rows declare on every face (maintainer ruling 2026-09-05 on the contract card): a stored value that is not a string never satisfies a positive text operator and always satisfies `$notContains`. The no-value cells keep their #13166 answer; nothing else in the matcher moved. diff --git a/.changeset/driver-mongodb-test-tsc-program.md b/.changeset/driver-mongodb-test-tsc-program.md deleted file mode 100644 index 79d24ac974..0000000000 --- a/.changeset/driver-mongodb-test-tsc-program.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/driver-mongodb": patch ---- - -fix(driver-mongodb): put the test layer in front of tsc, so the package's own typecheck reports a PASS and not a NUMBER (#14917) - -`packages/drivers/driver-mongodb`'s `tsconfig.json` excluded `**/*.test.ts`, and -its `typecheck` script is `tsc --noEmit` against that very config. Measured at -`6ed4b811af` with the dependency closure built: that program admits **0** of the -package's 30 `src/**/*.test.ts` files while all **10** of its non-test `src/**` -files ARE there, so `pnpm --filter @objectstack/driver-mongodb typecheck` -exiting 0 was a true sentence carrying no information about any test file. - -The filing's headline — that a compile-time `Equals` / `IsAny` pin here is -"checked by nothing" — is **false**, and the correction on the card is right: a -second program does compile these files. `check-type-check-coverage.mjs`'s -`remeasureProject` drops only the test glob and compares the result against its -`TEST_DEBT` ledger. Confirmed here by ablation rather than argued: a -deliberately false `Equals` pin added to `mongodb-driver.test.ts` takes that -program from 10 errors to 11, above the ledger's recorded 10, which reddens it. -The pins were never phantoms. What was true is narrower, and is what this change -closes: the only program reading this layer was a **debt ratchet** — an -instrument that reports a number and fails when the number moves, not a gate -that reports a pass. - -Gives the package the #5286 sibling shape (`packages/rest`, `runtime`, -`objectql`, `core`): a `tsconfig.test.json` with module semantics only — -`esnext` / `bundler` / `lib: ES2022`, matching how vitest actually executes -these files — strictness inherited and untouched, named by the `typecheck` -script via `check:test-typecheck`. - -Measured: **10** errors under the ratchet's shape (matching its recorded number, -and its recorded composition `TS1309 x7, TS2550 x3`, class for class), and **0** -under the split. All 10 were config-tier in full — 7 `TS1309` (`await` at module -scope in a program NodeNext compiles as CJS, because this package has no `"type": -"module"`) and 3 `TS2550` (`Array.prototype.at` against a `lib` older than -es2022). Neither class says anything about a test, and nothing was exposed -behind them: there was no unresolved-import cascade here to collapse, so there -is no `+n` term. `noUnusedLocals` / `noUnusedParameters` are live for this -package (unlike `driver-turso`, which switches both off) and neither fires. - -The `TEST_DEBT` entry (10 errors) is **deleted**, not lowered — the graduation -this ratchet's invariant requires. No `test-typecheck-debt.json` is added: -residue is 0, so none is owed (#5286, maintainer-only to open). That leaves all -30 files unledgered, so any error any one of them gains is red on arrival. - -`check:type-source-resolution` went red from onboarding the new program (the -documented onboarding-limb case, #11490): a registry entry is added rather than -`paths`, with its numbers stated in place — 123 tsc programs / 309 pairs before, -124 / 310 after. The single new pair is `@objectstack/objectql`, a devDependency -that no non-test file in `src/` imports. - -No runtime code changes: not one test file and not one source file is edited, so -no shipped behaviour moves — the suite reports the same 552 passed / 147 skipped -across 30 files as before. The `patch` level reflects the published -`package.json` gaining `typecheck` / `check:test-typecheck` scripts and a `tsx` -devDependency. diff --git a/.changeset/driver-raw-statement-declared-fault.md b/.changeset/driver-raw-statement-declared-fault.md deleted file mode 100644 index 114c945748..0000000000 --- a/.changeset/driver-raw-statement-declared-fault.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/driver-sql": patch -"@objectstack/driver-turso": patch ---- - -`SqlDriver.execute()` — the raw-SQL path the analytics compilers run on — now declares a backend refusal the way the typed read exits (`find` / `count` / `aggregate`) have since #8931: `code: DATABASE_ERROR`, `status: 500`, a composed message that carries none of the dialect's words, and the dialect error whole under a non-enumerable `cause`. `TursoDriver` in remote mode — the one transport that hands the engine's text back with no statement in front of it — declares through the same terminal, so both transports leave the driver with one envelope. **Graded `patch`** on AGENTS.md's changeset rule ("A bug fix in a released package takes a `patch` changeset"; breaking is what removes or renames something an author can write — a spec key, an export, a config field — and nothing here does: `execute()` stays `Promise` of `any`, and `code` / `status` were untyped before) and on the precedent of the identical change on the typed read exits, #8931 via PR #9273, which shipped `@objectstack/driver-sql: patch`. - -**The defect this closes (#16019, folding in the envelope half of #16028).** `no such function: translate` — what SQLite answers when a compiler emits a function the dialect lacks — left `execute()` as knex's own error: `code: 'SQLITE_ERROR'`, no `status`, message ` - no such function: translate`. Undeclared, it fell to the HTTP doors' phrasing heuristic (`looksLikeInternalErrorLeak`), which recognises `no such column:` and not `no such function:`, so whether the caller saw the engine's text depended on which limb the message happened to match: through knex it was withheld by accident (the statement prefix starts with `select`), through the Turso remote transport it was withheld by a different accident (`SQLITE_ERROR:` in front), and a bare `Error('no such function: translate')` reached the body verbatim. Maintainer ruling 2026-09-06 (decision batch #57, option 3): the substring list is not grown; the driver declares its own fault and the doors classify on the declaration. The heuristic stays as the last-resort fallback for an error that arrives with no declaration. - -**What moves on the wire — three doors, each because a declared fault is relayed where an undeclared one was re-labelled.** - -- `POST /api/v1/analytics/dataset/query`: a driver fault on the raw path answers `500 {"code":"DATABASE_ERROR","error":"Internal server error"}` — the declared-fault relay, the same answer the `/data` door and `/analytics/query` already give a declared 5xx — where it was `500 {"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` when the phrasing heuristic happened to fire and the raw engine text when it did not. Status unchanged; the code is now the producer's, exactly as the typed read exits' faults have answered at this door since PR #9273. -- The same door, a dataset over a backing table that is NOT present, on the native-SQL strategy (the strategy every deployment whose data engine exposes `execute()` runs): `500 DATABASE_ERROR` where it was `200 {"rows":[],"fields":[],"totals":[]}` plus a `warn`. `queryDataset`'s missing-source degrade sits behind its declared-envelope re-throw (#5717 defence B: a declared envelope is re-thrown untouched, whatever it says), so a driver-raised missing table no longer reaches it — the answer the ObjectQL-aggregate strategy has given since #9273, now on both strategies. The degrade still applies to an undeclared producer (an embedder's own `executeRawSql`, the framework's not-registered signals). -- `POST /api/v1/packages/publish` and `DELETE /api/v1/packages/:id`: a raw-exec driver fault under `sys_packages` answers `500 {"code":"DATABASE_ERROR"}` with the composed sentence as its message — `PackageService.publish` / `delete` re-throw a throw that declares an HTTP answer (`declaresHttpAnswer`, whose docblock already says a declared 5xx is re-thrown too) and the door's `sendThrownError` relays it — where it was `500 PACKAGE_PUBLISH_FAILED` / `500 PACKAGE_DELETE_FAILED` from the swallowing branch. Same status band, no dialect text on the wire either way; the ledgered `code` on those two doors moves. - -**What a consumer of `execute()` sees.** `error.message` is the composed sentence; `error.code` is `DATABASE_ERROR` where it was the backend's errno; `error.status` is `500` where it was absent. The backend's error object — its errno, its diagnostic, and on the dialects that inline them the bound literals — is on `error.cause` (non-enumerable, so it does not serialise), and the driver writes it, with the statement, to its warn log before composing. Cause-following predicates are unaffected: `isMissingTableError(err, readObject)` still classifies a missing table raised on this path. An error that already declares a `status` is passed through untouched, never double-wrapped. A caller that read the dialect's text off `error.message` (a migration preflight recording it as its `detail`, say) now reads the composed sentence there and finds the dialect text on `cause` and in the log; the in-repo sites of that class are tracked as #16657 (read `cause` there). diff --git a/.changeset/driver-sql-all-null-sum-folds-to-zero.md b/.changeset/driver-sql-all-null-sum-folds-to-zero.md deleted file mode 100644 index 1b89dcf2db..0000000000 --- a/.changeset/driver-sql-all-null-sum-folds-to-zero.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/driver-turso": minor -"@objectstack/spec": minor ---- - -`SqlDriver.aggregate` answers `0` — not `null` — for a `sum` over a group whose aggregand is NULL in every row, matching the engine's in-memory aggregate tier and the identity `emptyGroupValueFor` already declares (#15546; maintainer ruling 2026-09-07, option A: a non-empty group whose aggregand is absent and an empty group are the SAME case for `sum`, and the SQL face is the one that moves). - -SQL `SUM` skips NULLs and answers NULL once it has skipped everything, so on every dialect this driver targets (measured on better-sqlite3, live PostgreSQL 16.13 and live MySQL 8.0.46) a grouped list view with a `sum` summary on a nullable number or currency column rendered a BLANK total for a group whose column was empty in every row — while the same view on a deployment whose query took the engine's in-memory path rendered `0`. Which path answered was decided by a driver capability bit the caller never sees. The fold is part of the driver's aggregate presentation (`foldEmptyAggregateAnswers`): the compiled statement is unchanged (no `COALESCE`), the answer is the JS number `0` on every dialect, and `avg`/`min`/`max` — which have no identity over nothing — still answer `null`. The identity is read from `emptyGroupValueFor` rather than restated, so the two faces cannot drift apart on it again. - -`@objectstack/driver-turso`: the REMOTE transport's `aggregate` carries the same fold (`RemoteTransport.foldEmptyAggregateAnswers`). `TursoDriver` picks the remote compiler or the local `SqlDriver` one from `url`, so without it the same driver would have answered the all-NULL `sum` as `0` locally and `null` remotely — one query, two answers, decided by a connection string, the seam the shared conformance table exists to close. Measured `null` on the enrolled remote face before the fold. - -`@objectstack/spec`: the aggregate-vocabulary conformance fixture gains a NULLABLE numeric column. `AggregationRow.amount` (`number | null`) is NULL in every row of the `east` group and in two of the four `west` rows, and `AGGREGATION_CASES` gains the three cases that pin the ruled answer on every enrolled face — `sum(amount)` grouped by region (`east` 0 / `west` 40), its `count(amount)` reachability control (`east` 0 / `west` 2, which is what proves the nulls were stored as nulls), and the ungrouped partial-null control (40). A harness that runs the table MUST declare `amount` as a nullable numeric column and seed its nulls AS nulls, exactly as it already must for `stage`; a `0` written in place of a null turns the cell green for the wrong reason. diff --git a/.changeset/driver-sql-canonical-iso-read-door.md b/.changeset/driver-sql-canonical-iso-read-door.md deleted file mode 100644 index 18f0c04e68..0000000000 --- a/.changeset/driver-sql-canonical-iso-read-door.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -The record read doors present the builtin audit stamps (`created_at`, `updated_at`) and every declared `Field.datetime` column as the canonical instant text `YYYY-MM-DDTHH:MM:SS.sssZ` on EVERY dialect — Postgres and MySQL now included, exactly as SQLite always has (ADR-0053 addendum D-F1..D-F3, #13973). - -**Consumer-visible change, Postgres and MySQL only.** An in-process consumer reading such a column off a `find()` / `findOne()` row, off the row `create()` / `update()` / `upsert()` / `bulkCreate()` / `bulkUpdate()` return, or out of `aggregate()` (`min` / `max`, a raw temporal group key) or `distinct()`, receives a `string` where it received a JS `Date`. The wire is unchanged: `JSON.stringify` already serialised that `Date` as the same ISO text, so REST, MCP and SDK callers see nothing move. A consumer that called a `Date` method directly on the field (`.getTime()`, `.toISOString()`, `.getFullYear()`) now fails loudly with a `TypeError` instead of silently working on one dialect; the sweep behind this change found none in the repository's non-test sources. A consumer that compared, sorted, keyed or formatted the value as text — the shape eight production-driver defects had (#13382, #13993–#13999) — is now correct by construction on every dialect. - -- **Where the fold happens.** At the driver's own read boundary (`formatOutput` for rows, `presentReadValue` for the aggregate/distinct doors). The `pg` and `mysql2` client parsers are untouched: a `Date` is still what the client materialises, and a raw knex read still hands it back. Only the driver's read doors changed. -- **The builtin audit columns gain an `aggregate()` / `distinct()` arm on every dialect.** `max(updated_at)` and `distinct('created_at')` had no read presentation at all before — on SQLite they even missed ADR-0074's legacy-row repair — and now present exactly what `find()` presents. -- **An Invalid `Date` is the one shape the fold hands through unchanged** (#14078: a MySQL zero `DATETIME`; a Postgres year past 275760). It has no canonical text; the fold never throws on it, and the consumer-side guards #14078 landed absorb it as before. - -The per-site canonicalisations landed for #13993–#13999 and #14078 stay correct and become no-ops on driver rows; nothing is removed here. diff --git a/.changeset/driver-sql-object-def-param-keys.md b/.changeset/driver-sql-object-def-param-keys.md deleted file mode 100644 index 3078cca521..0000000000 --- a/.changeset/driver-sql-object-def-param-keys.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -`SqlDriver`'s object-definition parameters now DECLARE every key they read. `initObjects` accepts `lifecycle`, and the whole rotation chain — `rotateShards`, `ensureRotation`, `ensureShardTable` — accepts `tenancy` and `indexes`, spelled as a **fresh object literal** rather than only as a value bound to a variable first. - -The driver read those keys off caller objects all along, through `(obj as any).`, while the parameter's own inline type listed none of them. That is refused or accepted depending only on where the object is spelled: TypeScript's excess-property check fires on a fresh literal and not on one hoisted to a variable, so the same call compiles in one shape and is `TS2353` in the other. The loud outcome is the harmless one. The bad one is an author — or an AI reading the signature — concluding the key is not accepted and DROPPING it, at which point a declared UNIQUE is never synced and an ADR-0057 rotation policy is never armed, with nothing anywhere saying so. - -This is the third instance of one class, not a third coincidence: `tenancy` (#4311) and `indexes` (#16570) were the first two, each fixed one key at a time. The class is now held by a gate — `scripts/check-object-def-param-keys.mjs` — that reads parameter lists as an AST and covers the shape no in-file check could see: a subclass in another published package overriding one of these methods with a narrower literal. - -- **What widened.** `rotateShards(objectDef)` gains `tenancy?: any` and `indexes?: any[]`; `ensureRotation(…, obj, …)` gains the same two; `ensureShardTable(…, obj)` gains `indexes?: any[]`; `initObjects(objects)` gains `lifecycle?: any`. All three rotation links carry the keys, not just the leaf that reads them — declaring them only on the leaf would leave the two links above still narrowing the same value in flight, so a fresh literal handed to the public entry point would still have been refused. -- **What did NOT widen, deliberately.** The accept set still has a boundary: a misspelling (`indexs`, `tenancyy`, `lifecycl`) on a fresh literal is still `TS2353`, pinned by `@ts-expect-error` in `src/sql-driver-16711-object-def-param-keys.test.ts`. A "fix" that relaxed these parameters to `any`, or gave them an index signature, would have turned every other assertion green while deleting the entire layer of protection. -- **Four `as any` casts deleted**, including the residual one in `detectManagedDrift`, whose parameter had declared `indexes` all along. Behaviour is unchanged in every case — the keys were already being read. diff --git a/.changeset/driver-sql-shadow-plain-unique-duplicates.md b/.changeset/driver-sql-shadow-plain-unique-duplicates.md deleted file mode 100644 index cc580381f4..0000000000 --- a/.changeset/driver-sql-shadow-plain-unique-duplicates.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -MySQL: a plain unique index over existing duplicate rows no longer takes the boot down when the index has to be carried by a hash shadow. - -`syncDeclaredIndexes` handles a declared unique that the database refuses in one `catch`, and that `catch` has two arms: the DIRECT one, and the hash-shadow one MySQL takes when a key part is wider than the 768-char utf8mb4 ceiling. #14902 taught the direct arm that a uniqueness violation over existing rows is a durability degradation rather than a fatal — log it, name the conflicting rows and the remedy, let the boot continue. The shadow arm kept the older guard, which also required a NULL-safe organization key part, so a PLAIN unique (`tenancy: { enabled: false }`, or an explicit `unique: 'global'`) matched neither branch. - -Measured on live MySQL 8.0.46: the boot died carrying `ER_BLOB_KEY_WITHOUT_LENGTH` — a refusal about an unkeyable TEXT column, telling the operator to declare a `maxLength` the field already declared — while the real cause was two duplicate rows it never mentioned. It named no rows and no remedy. - -The two arms now agree, and they say different things because they mean different things. The NULL-safe arm keeps its wording (existing rows violate the NULL-safe key, duplicating what the previous void constraint admitted); the plain arm gets the direct arm's reviewed sentence, because neither of those clauses is true of a plain unique — nothing admitted the rows, and there is no NULL-safe key. Widening the guard alone would have shipped a factually false durability log, which is worse than the throw it replaces. - -`os migrate plan` already reported this operation as `destructive` with the row report and is unchanged. diff --git a/.changeset/driver-sql-text-operator-non-text-column.md b/.changeset/driver-sql-text-operator-non-text-column.md deleted file mode 100644 index 723cd1d38e..0000000000 --- a/.changeset/driver-sql-text-operator-non-text-column.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -A text operator over a column whose declared type stores no text (`Field.number` and its numeric siblings, `Field.boolean`) now compiles to the contract's declared answer on every dialect, instead of a dialect accident. - -Before: `{ score: { $contains: '5' } }` over a numeric column compiled `col GLOB '*5*'` on SQLite and coerced the REAL in its storage class's spelling (`5` as `'5.0'`, so `$endsWith: '0'` matched every row), `col LIKE $1 ESCAPE $2` on Postgres and was refused at query time with SQLSTATE 42883 (`operator does not exist: real ~~ text` — a 500 for a filter the spec accepts), and `CAST(col AS BINARY) LIKE ?` on MySQL. - -Now (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05): the positive operators (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) compile to `1 = 0` and `$notContains` to `1 = 1` — the same row set as every JS face, decided from the declared type at compile time because the stored value is not visible until run time. Postgres: a 500 becomes a result. The gate reads the `numericFields` / `booleanFields` registries `initObjects` and `registerExternalObject` already fill; a table this driver was never told about keeps the `LIKE` / `GLOB` it always compiled, every comparand refusal still runs first, and the constants compose with the NULL-safe rules (`$notContains` admits a NULL row already) and the `$not` rewrite. Temporal columns are untouched: their stored value IS text on SQLite, so the contract declares nothing for them. - -`driver-sqlite-wasm` and `driver-turso`'s local transport inherit this compiler. diff --git a/.changeset/driver-sql-update-declared-null.md b/.changeset/driver-sql-update-declared-null.md deleted file mode 100644 index 65b737e404..0000000000 --- a/.changeset/driver-sql-update-declared-null.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -feat(driver-sql): `update()` publishes its honest type — the contract's `Record | null`, not `any` (#14438) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 used for the same door on `@objectstack/driver-memory`). `SqlDriver.update()` was written out with an explicit `Promise` while it has always answered a missing id with `null` (`formatOutput(...) || null` on the un-rotated path, `null` once every rotation shard has been probed). `IDataDriver.update()` declares `Promise | null>`, and an explicit `any` satisfies that structurally — so the emitted `.d.ts` read `Promise` and no caller holding a `SqlDriver`, or a `SqliteWasmDriver` (which inherits the door unchanged), was ever asked to narrow. It is now declared as the contract declares it, and the protected rotation-path producer `rotatedUpdateById()` carries the same type. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members now types them. No runtime behaviour changes. - -`@objectstack/driver-sqlite-wasm` re-declares no `update` member of its own (measured on its emitted `.d.ts`), so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides the door and carries its own entry. - - diff --git a/.changeset/driver-sqlite-wasm-inherits-object-def-keys.md b/.changeset/driver-sqlite-wasm-inherits-object-def-keys.md deleted file mode 100644 index eb3e399e6f..0000000000 --- a/.changeset/driver-sqlite-wasm-inherits-object-def-keys.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/driver-sqlite-wasm": minor ---- - -`SqliteWasmDriver.initObjects` accepts `tenancy`, `indexes` and `lifecycle` in a **fresh object literal**, inherited from the widened `SqlDriver` — and that inheritance is now asserted rather than assumed. - -This package overrides neither `initObjects` nor `registerObjectMetadata`, so its published `.d.ts` re-declares none of them and the door it exposes is `SqlDriver`'s, imported from `@objectstack/driver-sql`. Measured on the built declarations: zero re-declarations of `initObjects`, `registerObjectMetadata`, `rotateShards`, `ensureShardTable` or `registerManagedObjectMetadata`. That is the opposite direction of the defect the sibling packages carried — `TursoDriver` overrode `initObjects` with a narrower literal and shadowed a base-class fix for five weeks — and it is recorded here because a consumer reading only this package's changelog would otherwise never learn its accept set moved. - -`src/sqlite-wasm-16711-inherited-object-def-keys.test.ts` pins the inheritance inside this package's own tsc program: the inherited parameter is not `any`, each key is present on the element type, a fresh literal carrying them compiles and is read at run time, and a misspelling is still `TS2353`. It goes red both ways — if the base narrows again, and if a future override here re-declares the door more narrowly. diff --git a/.changeset/driver-turso-config-timeout-ms.md b/.changeset/driver-turso-config-timeout-ms.md deleted file mode 100644 index d1a80fe5b9..0000000000 --- a/.changeset/driver-turso-config-timeout-ms.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -feat(driver-turso)!: the published connection config names its timeout's unit (#15682, ruling B on #14478) - - - - -**BREAKING** — `TursoConfigSchema`'s `timeout` is renamed to **`timeoutMs`**. The -value is unchanged: the same milliseconds, the same `min(0)` bound, the same -optionality. - -`@objectstack/spec`'s own turso contract renamed the same authored key in -#15680. This package publishes a parallel schema for the same connection config -— the Spec / Studio metadata a host reads to expose Turso configuration UI — so -until now the two declarations of one setting disagreed on its spelling. They -agree again. - -The unit was never in the key name, only in the describe prose, while -`sync.intervalSeconds` — the same shape, three keys above — already spelled its -own. One published config carrying both conventions is what made the bare name -dangerous rather than untidy: an author who has just written -`intervalSeconds: 30` has no reason to read `timeout: 30` as milliseconds, and -nothing in the schema, the type or the parse would have told them otherwise. - -The old spelling is not dropped in silence. `TursoConfigSchema` is a plain -`z.object`, so a bare deletion would have STRIPPED `timeout` and parsed -successfully. The key stays declared as a tombstone instead: `tsc` refuses it on -anything typed `TursoConfig`, and a value that reaches the parse raises a -message naming `timeoutMs` rather than a generic unrecognised-key error. - -```diff -- TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeout: 30000 }) -+ TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeoutMs: 30000 }) -``` - -`TursoDriverConfig` — this package's TypeScript constructor option, a separate -declaration — keeps its `timeout` spelling and is untouched here. diff --git a/.changeset/driver-turso-inert-config-keys.md b/.changeset/driver-turso-inert-config-keys.md deleted file mode 100644 index 15dd9ebac2..0000000000 --- a/.changeset/driver-turso-inert-config-keys.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/driver-turso": minor -"@objectstack/spec": minor ---- - -feat(driver-turso)!: `timeout` bounds remote operations; `localPath` and `wasm` leave the published config schema (#16024, ADR-0049 enforce-or-remove) - - - -Three keys on this package's published Turso configuration were declared with a -describe promising behaviour that no code delivered — ADR-0049's -declared-but-unenforced shape, sitting beside `concurrency`, which was declared -the same way and IS forwarded. The maintainer ruled per key: forward `timeout`; -remove `localPath` and `wasm`. Not a rename for any of the three — an inert key -with a better name is what ADR-0049 exists to prevent. - -**`TursoDriverConfig.timeout` now does what its docblock has always said.** It -never reached `@libsql/client`. It still does not reach that client's own -`Config.timeout`, and deliberately: measured against `@libsql/client@0.17.4`, -that option is the busy timeout for lock contention on local `file:` databases -("remote clients ignore it"), so forwarding to it would have left remote mode -exactly as inert as before. Instead: - -- **Remote mode over HTTP** (`libsql://`, `https://`, `http://`): the driver - hands the client a `fetch` that aborts every request once the window elapses, - and the operation fails as `TIMEOUT` / 504 (the ADR-0112 envelope) instead of - hanging on a stalled endpoint. `wss://` / `ws://` URLs ride the WebSocket - transport, which exposes no such seam in this client version — they are not - bounded, and the docblock says so. -- **Replica mode**: `sync()` — the one remote operation on that arm — rejects - with the same envelope when it has not completed within the window. The native - binding's sync is not cancelled, only no longer awaited. -- `0` or unset means no bound, as the published schema already documented. - -A datasource authors this as `config.timeoutMs`; the datasource seam maps it -onto the driver's `timeout`, so a `timeoutMs` that used to be silently dropped -now bounds the connection it describes. - -**BREAKING** — `TursoConfigSchema` refuses `localPath` and `wasm`. Neither was -read by any code: the replica arm names its local file via `url` (forwarding -`localPath` would have created a second way to say the same thing), and nothing -selects a WASM build of libSQL (forwarding `wasm` would have meant building -one). The shape is a plain `z.object`, so a bare deletion would have stripped -both keys in silence; they stay declared as `z.never()` tombstones instead — -`tsc` refuses them on anything typed `TursoConfig`, and a value reaching the -parse raises the prescription below rather than a generic unrecognised-key -error. The same treatment this package's `timeout` → `timeoutMs` rename took. - -## Migration - -| Wrote | Write instead | -| --- | --- | -| `localPath: './replica.db'` beside `url: 'file:./replica.db'` | delete `localPath` — `url` names the replica's local file, `syncUrl` the remote primary; a path that differed from `url` belongs in `url` | -| `wasm: true` | delete `wasm` — no WASM build was ever selected; a runtime that cannot load native bindings uses the remote arm (`libsql://` / `https://`), which needs none | - -`@objectstack/spec`'s own turso contract never declared either key, so no stack -source or stored datasource row that passed the spec door can carry them; the -ADR-0087 ledger records the removal as the D3 entry -`driver-turso-config-local-path-wasm-retired` (no D2 conversion — there is no -lossless rewrite for a value that never did anything), which is the -`@objectstack/spec` `minor` here — the entry is a new member of the published migration -registry (`packages/spec/src/migrations/registry.ts`), an additive widening of that package's -surface, and the act sets the floor. diff --git a/.changeset/driver-turso-init-objects-declares-base-keys.md b/.changeset/driver-turso-init-objects-declares-base-keys.md deleted file mode 100644 index 184e7dd2d2..0000000000 --- a/.changeset/driver-turso-init-objects-declares-base-keys.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -`TursoDriver.initObjects` now declares every key `SqlDriver.initObjects` declares — `tenancy`, `indexes` and `lifecycle` — so a caller of this package can spell them in a **fresh object literal** instead of hoisting the object to a variable to get past the type. - -`TursoDriver` OVERRIDES `initObjects`, and an override does not inherit the base's parameter type. Its own literal read `Array<{ name: string; fields?: Record }>`, which is what every consumer of `@objectstack/driver-turso` saw — so when #4311 declared `tenancy` on the base in August, that fix did not exist from outside this package, and stayed invisible for five weeks with nothing red anywhere. #16570's `indexes` fix would have escaped by the identical route. - -The type face was the only thing refusing the keys. The remote arm forwards the whole object through as `schema`, and `registerRemoteFieldMetadata` reads `tenancy` straight back off it, so the runtime carried both keys the entire time. `tenancy.enabled: false` is the key that decides whether a UNIQUE partitions globally or per organization — an author who hit the refusal and dropped it silently got the other answer. - -- `registerRemoteFieldMetadata(obj)` declares `tenancy?: any` and reads it directly; its `(obj as any).tenancy` cast is gone. -- The boundary is intact: a misspelling on a fresh literal is still `TS2353`, pinned in `src/turso-driver-16711-init-objects-param.test.ts`. -- `scripts/check-object-def-param-keys.mjs` now fails the build if this override — or any other subclass override in the workspace — declares fewer keys than the method it shadows, or erases the base's shape with an opaque type or an index signature. diff --git a/.changeset/driver-turso-remote-text-operator-non-text-column.md b/.changeset/driver-turso-remote-text-operator-non-text-column.md deleted file mode 100644 index 34882ade6d..0000000000 --- a/.changeset/driver-turso-remote-text-operator-non-text-column.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -The remote transport compiles a text operator over a declared numeric or boolean column to the contract's declared answer, in step with the local transport. - -`RemoteTransport.buildWhereSQL` compiles filters independently of `SqlDriver` and keeps no schema, so a text operator over a `Field.number` used to compile `"col" GLOB ?` and coerce the REAL in the storage class's spelling (`5` as `'5.0'`). `TursoDriver` now hands the transport its declared-type rule (`setNonTextColumnResolver`, the same shape as the temporal `setFilterColumnSql` rule), answered from the registries `registerRemoteFieldMetadata` already fills at schema sync — so a positive text operator over such a column compiles to `1 = 0` and `$notContains` to `1 = 1` on BOTH transports (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05), instead of a dialect accident. A transport nobody handed the rule to compiles exactly as before, and every comparand refusal still runs ahead of the constant. diff --git a/.changeset/driver-turso-supplied-client-timeout-refusal.md b/.changeset/driver-turso-supplied-client-timeout-refusal.md deleted file mode 100644 index 45bdeec1c7..0000000000 --- a/.changeset/driver-turso-supplied-client-timeout-refusal.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -fix(driver-turso)!: `timeout` beside a pre-configured `client` in remote mode is refused at construction instead of being accepted and never delivered (ADR-0049 enforce-or-remove) - - - -`TursoDriverConfig.timeout` bounds remote operations over HTTP by installing a `fetch` that aborts at the window — and it installs it in exactly one place, while the driver is CREATING its `@libsql/client`. A pre-configured `TursoDriverConfig.client` arrives with its transport already built, and both remote sites that consume it (`connect()` and the lazy connect factory the transport self-heals through) skip the builder entirely. So on that one composition the window reached nothing: the driver constructed, connected, and ran every request unbounded, while `timeout`'s contract promised "every request the client's HTTP transport makes" and `client`'s said nothing about the key ceasing to apply. - -**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside a supplied `client` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and a message that names both keys, the window, the mode and both ways out: - -``` -`TursoDriverConfig.timeout` (30000 ms) is set beside `TursoDriverConfig.client` in -remote mode, and on that pair it bounds nothing: the window is the `fetch` this -driver hands @libsql/client while CREATING the remote client, and a pre-configured -client is already built — its transport is not the driver's to replace … Either drop -`client` and let the driver create the remote client, where every request IS bounded -and a stalled endpoint fails as TIMEOUT / 504, or keep `client` and omit `timeout`, -building the bound into that client yourself when you call `createClient({ fetch })`. -Replica mode is unaffected: there `sync()` is bounded whatever client is in use. -``` - -**Who can reach this, measured on this tree.** The datasource seam cannot: `buildTursoDriverConfig` emits nine keys (`url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, `sync`, `timeout`, `mode`, `schemaMode`) and `client` is not among them — it is a live object, not authorable metadata, and the published `turso` schema documents its absence deliberately. So no datasource, environment variable or `sys_metadata` row can produce this pair; only code calling `new TursoDriver(...)` / `createTursoDriver(...)` directly. Across the 138 construction sites in this repository, the only one pairing the two keys outside the new pin file is a replica-arm test fixture, which stays accepted. Whether any out-of-repo host composes them is NOT measured and is not claimed to be zero. - -**What stays accepted — the refusal is no wider than the gap**, pinned by controls: - -- a supplied `client` with no `timeout`, and an explicit `client: undefined`, which the `??` at both sites treats as absent; -- `timeout` with no `client` — the client the driver builds IS bounded; -- `timeout: 0` beside a client, the documented "no bound", which asks for nothing; -- the whole REPLICA arm, where `sync()` is bounded by the driver around the awaited promise whatever client is in use, so the key is not inert there and the pair is still accepted. - -**What is deliberately NOT done**: wrapping or re-creating the caller's client so the window rides after all. A client handed in for custom caching, connection pooling or testing is the caller's object, and replacing its transport because `timeout` is set would discard the configuration it was built to carry, behind the author's back — the same reason a `wss://` url is not silently re-routed over HTTP. - -**What an affected author does.** The refusal text says which two: drop `client` and let the driver create the remote client, which bounds every request; or keep `client` and drop `timeout`, building the bound into that client where it is created, since `@libsql/client` reads its `fetch` at creation. Which of the two is wanted is authoring intent, and the choice is made in place at the driver config. diff --git a/.changeset/driver-turso-update-declared-null.md b/.changeset/driver-turso-update-declared-null.md deleted file mode 100644 index 28cea18aae..0000000000 --- a/.changeset/driver-turso-update-declared-null.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -feat(driver-turso): the `update()` override publishes its honest type — `Record | null`, not `any` (#14438) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` overrides `update()` rather than inheriting it, 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. Both of its branches already answered the contract's type: the local branch forwards to `SqlDriver.update()` (narrowed alongside, #14438) and the remote branch passes `RemoteTransport.update()`'s `Record | null` (#14428) through the generic `formatRemoteRow`. The override now declares what it answers. A caller that read fields off the result through the `any` now narrows the `null` arm first. No runtime behaviour changes. - - diff --git a/.changeset/driver-turso-uppercase-ws-scheme-timeout-refusal.md b/.changeset/driver-turso-uppercase-ws-scheme-timeout-refusal.md deleted file mode 100644 index 671c3c0bf2..0000000000 --- a/.changeset/driver-turso-uppercase-ws-scheme-timeout-refusal.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -fix(driver-turso)!: `timeout` beside an UPPERCASE `WSS://` / `WS://` url in forced remote mode is refused at construction, closing the last corner of the same gap (ADR-0049 enforce-or-remove) - - - -The refusal that closed `timeout` beside a `wss://` / `ws://` url matched the two schemes **literally**, so one composition still constructed with a window that reaches nothing: - -```ts -new TursoDriver({ url: 'WSS://db.example.turso.io', mode: 'remote', timeout: 30000 }) -``` - -Reading `@libsql/client`'s routing switch alone says that cannot happen — the switch really does match the literal lowercase (`lib-esm/node.js`: `config.scheme === "wss" || config.scheme === "ws"`). But the switch never sees the url as the author spelled it. The node entry is `_createClient(expandConfig(config, true))`, and `expandConfig` has already lowercased the scheme by then — `@libsql/core@0.17.4`, `lib-esm/config.js`: `const originalUriScheme = uri.scheme.toLowerCase();`. Executed against that version: `expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'`, and `'Ws://127.0.0.1:8080'` → `'ws'`. So an uppercase `WSS://` url does reach the WebSocket client, which takes no `fetch` and no timeout option of its own — the driver constructed, connected, and ran unbounded. - -**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside an uppercase-or-mixed-case `wss://` / `ws://` `url` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and **the same message the lowercase spelling already produced**, echoing the scheme in the caller's own casing so an operator can grep their config for what they actually typed. - -**The explicit `mode: 'remote'` is load-bearing.** Without it an uppercase url falls through `TursoDriver.detectMode` to `'local'` — behaviour that predates the refusal entirely and is **unchanged here**. Only the window predicate folds case; the mode detector is deliberately left case-sensitive, and the code says so at the predicate, because folding it there too would delete that fall-through: a mode-detection change on a published driver, which must be argued on its own rather than slipped in as a tidy-up. - -**What stays accepted — the refusal is no wider than the gap**, pinned by controls: - -- an uppercase url with **no** explicit `mode` still detects as `'local'`, with or without a `timeout`; -- the uppercase WebSocket url with no `timeout`, or with `timeout: 0` (the documented "no bound"); -- `https://` / `HTTPS://` / `LIBSQL://` / `HTTP://` remote urls **with** a window — the HTTP arm is bounded, so every casing of every HTTP-side scheme keeps the key; -- the existing lowercase refusals, unchanged in code, message and envelope. - -**What an affected author does.** Unchanged from the lowercase case, and the refusal text says it: keep the window and spell the url `libsql://` or `https://` (bounded — the client resolves `libsql://` to HTTPS), or drop the window and run the WebSocket remote unbounded, as it always did. - -Blast radius, measured on this tree: no in-repo deployment, example, test or doc pairs an uppercase remote scheme with a window; the host boot path (`OS_DATABASE_URL`) forwards only `url` and `authToken`, and the datasource seam's `buildTursoDriverConfig` normalises no casing either — so the pair is reachable in principle from both and is not observed in this repository. Whether any out-of-repo deployment spells a Turso url with an uppercase scheme is NOT measured and is not claimed to be zero. diff --git a/.changeset/driver-turso-ws-timeout-refusal.md b/.changeset/driver-turso-ws-timeout-refusal.md deleted file mode 100644 index 874d4a17b6..0000000000 --- a/.changeset/driver-turso-ws-timeout-refusal.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/driver-turso": minor ---- - -fix(driver-turso)!: `timeout` beside a `wss://` / `ws://` url is refused at construction instead of being accepted and never delivered (ADR-0049 enforce-or-remove) - - - -`TursoDriverConfig.timeout` bounds remote operations over HTTP (`libsql://`, `https://`, `http://` — the driver hands `@libsql/client` a `fetch` that aborts at the window) and bounds `sync()` on the replica arm. A remote url spelled `wss://` / `ws://` rides the client's WebSocket transport, which — measured against `@libsql/client@0.17.4` / `@libsql/hrana-client@0.10.0` — takes no `fetch` and no timeout option of its own, so on that one scheme the window reached nothing: the configuration constructed, connected, and ran unbounded, with the gap stated only in a docblock. - -**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside a `wss://` or `ws://` `url` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and a message that names the key, the scheme it met, and both ways out: - -``` -`TursoDriverConfig.timeout` (30000 ms) is set beside a `wss://` url, and on that -scheme it bounds nothing: a `wss://` url rides @libsql/client's WebSocket -transport, which takes no fetch and no timeout option … Either omit `timeout` -and run this remote unbounded, or keep it and spell the url `libsql://` or -`https://` — the client resolves `libsql://` to HTTPS — where every request IS -bounded and a stalled endpoint fails as TIMEOUT / 504. -``` - -A datasource authors the window as `config.timeoutMs`; the datasource seam maps it onto the driver's `timeout`, so a `timeoutMs` beside a WebSocket url now fails the datasource's connect by name instead of quietly running unbounded. Both loaders (`@objectstack/runtime`'s host factory and the open-core datasource factory) reach this refusal through the same constructor. - -**What stays accepted — the refusal is no wider than the gap**, pinned by controls: - -- a `wss://` / `ws://` url with no `timeout`, or with `timeout: 0` (the documented "no bound"); -- `libsql://`, `https://` and `http://` urls WITH a window — the HTTP arm is bounded; -- the replica arm with any url scheme — `sync()` is bounded there, so the key is not inert. - -**What is deliberately NOT done**: routing a `wss://` url over HTTP because `timeout` is set. That would change the wire transport behind the author's back and is a contract decision, not a driver's; the refusal changes no wire behaviour. - -**What an affected author does.** The refusal text itself says which two: keep the window and spell the url as `libsql://` or `https://` (bounded — `libsql://` resolves to HTTPS), or drop the window and run the WebSocket remote unbounded, as it always did. Which of the two is wanted is authoring intent, and the choice is made at the datasource or driver config, in place. - -Blast radius, measured on this tree: no in-repo deployment, example or doc pairs a WebSocket url with a window, and the host boot path (`OS_DATABASE_URL`) forwards only `url` and `authToken`, so an env-configured deployment cannot carry `timeout` at all. diff --git a/.changeset/duplicate-record-error-developer-message-wire-spelling.md b/.changeset/duplicate-record-error-developer-message-wire-spelling.md deleted file mode 100644 index 06f899585a..0000000000 --- a/.changeset/duplicate-record-error-developer-message-wire-spelling.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): `DuplicateRecordError.developerMessage` names the wire spelling a client branches on (#14723) - -The envelope's `developerMessage` — the remedy sentence addressed to the -application author — told its reader to "branch on `code === 'DUPLICATE_RECORD'`", -which is the engine's THROWN identity and holds only for an in-process caller of -`engine.insert` / `engine.update`. Every REST route reports the same refusal as -`UNIQUE_VIOLATION`, and since #14723 the per-row reports of the batch and import -surfaces do too, so the sentence was a platform contradicting itself on the one -line an author is most likely to copy. It now says both halves: over the HTTP -API branch on `code === 'UNIQUE_VIOLATION'` on every route, whole-request and -per-row alike; inside the engine the thrown class carries `DUPLICATE_RECORD`. -The class's own docblock says the same. Nothing else about the envelope moves: -`code`, `status`, `cause`, `field`, `object` and the user-facing `message` are -byte-identical, and every pin on the engine's thrown code holds. diff --git a/.changeset/duplicate-source-must-be-a-base.md b/.changeset/duplicate-source-must-be-a-base.md deleted file mode 100644 index 12d539121c..0000000000 --- a/.changeset/duplicate-source-must-be-a-base.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": minor ---- - -`POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy. - -Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why. - -`copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.* - -- **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use. -- **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead. - -⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing. - -Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question. diff --git a/.changeset/duration-unit-in-key-name.md b/.changeset/duration-unit-in-key-name.md deleted file mode 100644 index 275d26b793..0000000000 --- a/.changeset/duration-unit-in-key-name.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout` → `timeoutMs`, `MetadataManagerConfig.cache.databaseLoader.ttl` → `ttlMs` (the outer `cache.ttl` leaves outright under #15624 — nothing read it), tenant `idleTimeout` / `sessionTimeout` → `*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519) - - - -**BREAKING** rename of seven published authorable keys, shipped as `minor` under -the repo's launch-window convention for breaking changes; every rename is -registered under protocol major 18. Maintainer ruling 2026-09-02 on #14478 -(director decision batch #14, verbatim 「14461 你不处理,其他同意」): **ruled B** — -a spec-source gate for duration-shaped number keys **with no grandfathered -baseline**, plus an ADR-0087 conversion of every offender the ruling named, in -one PR, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」. -⛔ No alias, no transition window: each old spelling is a `retiredKey()` -tombstone whose rejection names the new key. - -## The defect - -`kernel/metadata-loader.zod.ts` carried two keys spelled `ttl` fourteen lines -apart: `cache.ttl` in **seconds** (default 3600) and `cache.databaseLoader.ttl` -in **milliseconds** (default 60000). Both descriptions named their unit; the -key names did not. An author who copied the outer number into the inner block -got a 3.6-second cache and no error anywhere — the number was valid, the type -was right, the cache was simply cold. `hook.timeout`, `job.timeout` and -`DriverOptions.timeout` had the same shape (milliseconds, said only in prose) -beside siblings that spell theirs (`backoffMs`, `intervalMs`, the body-level -`timeoutMs`). The two tenant keys were worse for the reader who matters most: -`.describe()` is what `content/docs/references/**` publishes and the JSDoc above -a key is not, so `idleTimeout` / `sessionTimeout` said "in seconds" in a source -comment and published a bare `300` / `3600` to the reference page (#14519). - -## FROM → TO - -| schema | before | after | value | -|:--|:--|:--|:--| -| `HookSchema` (`hooks[]`) | `timeout` | `timeoutMs` | unchanged (ms) | -| `JobSchema` (`jobs[]`) | `timeout` | `timeoutMs` | unchanged (ms) | -| `DriverOptionsSchema` | `timeout` | `timeoutMs` | unchanged (ms) | -| `MetadataManagerConfigSchema` | `cache.ttl` | *(deleted — its respelling `ttlSeconds` was retired before it shipped, #15624; the outer `cache` block was read by nothing, and the live TTL is `cache.databaseLoader.ttlMs`)* | — | -| `MetadataManagerConfigSchema` | `cache.databaseLoader.ttl` | `cache.databaseLoader.ttlMs` | unchanged (ms, default 60000) | -| `DatabaseLevelIsolationStrategySchema` | `connectionPool.idleTimeout` | `connectionPool.idleTimeoutSeconds` | unchanged (s, default 300) | -| `TenantSecurityPolicySchema` | `accessControl.sessionTimeout` | `accessControl.sessionTimeoutSeconds` | unchanged (s, default 3600) | - -```ts -// before -defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeout: 5000 }); -defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeout: 300000 }); -new MetadataManager({ cache: { ttl: 3600, databaseLoader: { ttl: 60_000 } } }); - -// after — rename the key; the number is unchanged -defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeoutMs: 5000 }); -defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeoutMs: 300000 }); -new MetadataManager({ cache: { databaseLoader: { ttlMs: 60_000 } } }); // the outer `ttl` is deleted, not renamed (#15624) -``` - -**Migration.** Rename each key; no value changes — with one exception: the outer -`MetadataManagerConfig.cache.ttl` is DELETED, not renamed (its respelling `ttlSeconds` -was retired before it shipped, #15624; nothing ever read the outer `cache` block, and -the nested `cache.databaseLoader.ttl → ttlMs` rename above is unchanged). Authoring an old spelling -fails to compile (`tsc`: the input type is `never`) and fails to parse with a -prescription naming the new key. For `hooks[]` / `jobs[]` the rename is a -mechanical D2 conversion (`hook-timeout-to-timeout-ms`, -`job-timeout-to-timeout-ms`, retired from the load path): run -`os migrate meta --from 17` to list the edits for existing sources and apply -them by hand; stored `sys_metadata` rows are rehydrated through the same chain. -The other five keys have no stack seam (runtime config, a per-call options -argument, cloud tenancy config) and carry a semantic entry each. The -`JobScheduleOptions` contract key that carries `job.timeoutMs` to the scheduler -is renamed in lockstep (`timeout` → `timeoutMs`), as is `DatabaseLoaderOptions.cache.ttl` → `ttlMs` in `@objectstack/metadata`. - -## The gate - -`pnpm --filter @objectstack/spec check:duration-unit-keys` -(`packages/spec/scripts/check-duration-unit-keys.ts`, wired into `lint.yml`): -a property whose value is a `z.number()` / `z.int()` / `z.coerce.number()` -chain and whose `.describe()` names a time unit must carry that unit as a token -of its key name (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`, and the -knex-inherited `Millis`), and the token must agree with the prose — `ttlMs` -described "in seconds" is refused too. A `{ value, unit }` pair is recognised -by its sibling `unit` key; duration literals are strings and outside the -population. Calendar positions ("day of the month") and rates ("requests per -second") are skipped. There is no baseline and no `gen:`; a red is a rename -under an ADR-0087 conversion or a describe to fix. diff --git a/.changeset/eighty-poems-visit.md b/.changeset/eighty-poems-visit.md deleted file mode 100644 index 13f3fbaa2c..0000000000 --- a/.changeset/eighty-poems-visit.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -The boot-time action-governance audit now reaches a SCOPED `metadata` service. - -`ObjectQLPlugin.runGovernanceInventory` acquired its metadata plane with -`ctx.getService('metadata')`, which reads only the two synchronous service maps. -A composition that registers `metadata` with `ServiceLifecycle.SCOPED` mints its -instances into `PluginLoader.scopedServices` instead, so the call threw -`Service 'metadata' is async - use await` before `loadMany`, `loadManyKeyed`, -`loadDiagnosed` or `load` could run, the plugin swallowed the throw into "no -metadata plane at all", and the ADR-0110 D5 inventory reported that scope's -`action` declarations as absent — silently, because an empty declaration set is -indistinguishable from a plane that holds nothing. On such a kernel a handler the -router dispatches was reported as "registered handler with NO declaration … -REFUSED at dispatch". - -The plane is now resolved in the router's own order — `getServiceScoped('metadata', -environmentId)` first, then the synchronous lookup — so the audit holds the same -instance `HttpDispatcher.resolveService` hands the router. Statically registered -planes (every shipped composition today) resolve to the same object as before. diff --git a/.changeset/email-template-shared-read-decoration-strip.md b/.changeset/email-template-shared-read-decoration-strip.md deleted file mode 100644 index b94a340065..0000000000 --- a/.changeset/email-template-shared-read-decoration-strip.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-email": patch ---- - -`plugin-email` strips read decorations with the shared list, not a blanket underscore sweep. - -`readEffectiveTemplate` — the layered read a `DELETE /meta/email_template/:name` runs to restore the packaged baseline an overlay was hiding — removed decorations with a module-local copy of `stripReadDecorations` that dropped **every** key beginning with `_`. The shared list it drifted from, `METADATA_READ_DECORATIONS` in `@objectstack/spec/kernel`, is exactly `['_diagnostics', '_draft']`, and its module header names the ADR-0010 protection envelope (`_lock`, `_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`) as deliberately **not** a member: it is envelope state the write path legitimately carries, and the closed metadata schemas allowlist it so a served document keeps its provenance on re-parse. - -The private copy justified its sweep on the claim that `EmailTemplateDefinitionSchema` "declares no underscore key". That is false — `email-template.zod.ts` spreads `MetadataProtectionFields` into its `strictObject`, so every envelope key is declared and parses clean. The copy was removing keys the schema was deliberately widened to accept, and the list lives in `spec` precisely so a producer and its consumers cannot drift like this. - -The path now calls the shared helper, matching the other read-back-envelope consumers (the dataset query in `rest-server.ts`, the cold-boot flow bind in `service-automation`, `saveMetaItem`'s verbatim persist, and the route-level seed apply). Two behavioural consequences: - -- An underscore key that is neither a decoration nor declared is no longer swallowed before validation. The closed schemas exist to reject exactly that (protocol 17), and the rejection is now reported on the write's own response through the mutation projector, instead of the reset quietly succeeding against a body the schema would have refused. -- The ADR-0010 envelope survives the strip. It still does not reach `sys_email_template`: `upsertDeclaredEmailTemplate` projects the parsed template through `mapTemplateToRow`, a closed column list, and the object declares no underscore column — so no stored row changes shape. There is deliberately no second, envelope-stripping pass beside the shared one; spelling one would re-create the drift this fixes, one layer up. diff --git a/.changeset/empty-generator-refusal-separator.md b/.changeset/empty-generator-refusal-separator.md deleted file mode 100644 index 07419538b6..0000000000 --- a/.changeset/empty-generator-refusal-separator.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os lint --eval --generator ""` no longer prints a double space in its refusal. - -`bundle-require` composes its own refusal as ` is not a valid JS file`, so an -empty filepath contributes no characters and that fragment arrives with a leading space — -which landed against the space in our own `": "` separator: - -``` -Failed to load generator "": is not a valid JS file # before, both faces -Failed to load generator "": is not a valid JS file # after -``` - -The composed message now drops leading spaces from the detail, so the separator carries -exactly one. The empty string still answers through the same door an unresolvable path -answers through — same `catch`, same exit code 1, same one-key `{error}` document on the -`--json` face — and every refusal whose detail does not open with a space is byte-identical, -the unresolvable-path case included. diff --git a/.changeset/enforce-plugin-schema-at-kernel-use.md b/.changeset/enforce-plugin-schema-at-kernel-use.md deleted file mode 100644 index b0dc7008f9..0000000000 --- a/.changeset/enforce-plugin-schema-at-kernel-use.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/core": minor ---- - -`kernel.use()` now enforces the declared plugin contract. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at load instead of being stored and mounted. - -**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`). **This refuses input the runtime accepted before**, which is also why it is not a `patch`: `PluginSchema` had zero runtime callers, so every constraint it declared beyond `name`, `init` and semver was a declaration with nothing behind it. The sharpest reading of that gap, one input and two answers: `defineStack` accepted `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it — and only one of those answers was on the path a real plugin takes. Maintainer ruling of 2026-09-06 (ADR-0049 enforce-or-remove): the protocol is the baseline, the runtime aligns to it. - -**Exactly what is newly refused: all EIGHT declared keys, not three.** The schema declares nine optional keys; the loader excludes `version` (below), so enforcement reaches these eight, each refused with the offending key named in the message: - -- **`id`** — a non-string, or the empty string (`z.string().min(1)`). -- **`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. -- **`description`** — a non-string. -- **`author`** — a non-string. An object such as `{ name: 'x' }` is refused; the declared type is a plain string. -- **`homepage`** — a non-string, or a string that is not a URL. - -**`null` is refused on every one of the eight.** These keys are `.optional()`, which admits absence and `undefined` — never an explicit `null`. A plugin object that spells "no value" as `null` on any of the eight loaded before and is refused now. - -**What a refusal looks like.** It travels the loader's existing plugin-load error path — no new error channel — carrying the stable code `PLUGIN_CONTRACT_VIOLATION` at the head of the message and on the error's `code` property, and naming the plugin plus the first violated key: - -``` -PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared -plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|… -``` - -A wrong `type` is therefore diagnosable at boot rather than at route mount. The code is a **boot refusal**, not wire vocabulary: it is raised before any HTTP boundary exists, and no door answers with it. - -**What is STILL ACCEPTED — the door is not narrowed past those eight keys.** Measured on this tree, not assumed: - -- **Unknown keys still pass.** `PluginSchema` is a plain `z.object` with **no `.strict()`** — the strip posture — and the parse output is discarded, so a valid plugin carrying four keys the schema never declares loads, and is stored as the very object that was passed in with all of its keys intact. A plugin is refused for what it says about a **declared** key, never for saying something extra. -- **A version-less plugin still loads**, exactly as before. -- **A plugin declaring no `type` still loads and still stores no `type`**: `PluginSchema`'s `.default('standard')` is **not** written back. -- **A class-based plugin keeps its identity, its prototype and its prototype methods.** The plugin object is validated, never replaced: `safeParse` is read for `success` and its output discarded, because a copy destroys the prototype chain of class-based plugins — the reason `PluginLoader.toPluginMetadata` is a cast. That survival is pinned by test, not asserted in prose. -- **`version` is excluded from this enforcement entirely**, so `1.0.0-alpha.1` and `1.0.0+20230101` still load. The schema spells `version` as `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion` implements the full grammar and accepts them — deliberately, pinned by `plugin-loader.test.ts`. Enforcing the narrower spelling would retire that capability silently, so the loader's check remains authoritative for `version`. Reconciling the two spellings is spec work, tracked separately. - -**Blast radius, measured rather than assumed.** Every in-repo plugin object declares a `type` inside the closed set (`standard` ×62, `server` ×2, `driver` ×2, `objectql`, `app`), and the repo contains no producer of `slug` or `homepage` on a plugin object at all — so no in-repo plugin changes behaviour. Externally authored plugins are the population this reaches, and they are exactly the population that never met the compile-time `Plugin.type` union either. - - diff --git a/.changeset/engine-refusals-stamp-httpstatus.md b/.changeset/engine-refusals-stamp-httpstatus.md deleted file mode 100644 index bd173d555f..0000000000 --- a/.changeset/engine-refusals-stamp-httpstatus.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -Engine refusals now declare their HTTP status under both spellings: `httpStatus` beside the existing `status`, same number, at every producer in the package. - -`status` is unchanged and stays. It is what every HTTP door in this repo reads — `resolveThrownHttpError` (`@objectstack/types`) resolves `.status` then `.statusCode` and knows no other spelling — so nothing about what the REST or dispatcher doors answer changes. - -What changes is what a consumer holding the **thrown** error can read. ADR-0112 D5 records the destination as "the HTTP status lives on the transport and (optionally) `error.httpStatus`", and `httpStatus` is the key the client SDK already stamps on every wire failure. A consumer that caught an engine refusal locally had no status at all: `os migrate summary-nulls --json --recompute-undefined-on-empty customer.nope` emitted `{ error, code: 'INVALID_FIELD' }` with no status field, while the same refusal arriving over the wire carried `httpStatus: 400`. It now carries `httpStatus: 400` on both paths. - -Additive on thrown errors, so no caller that reads `status` needs to change. The 20 producers: the `INVALID_SORT` / `INVALID_FIELD` / `VALIDATION_ERROR` / `INVALID_METADATA` / `DELETE_RESTRICTED` refusals in `engine.ts`, the `INVALID_FILTER` / `INVALID_FIELD` refusals in `filter-comparand-shape.ts`, `resolveRecomputeScope` in `summary-backfill.ts`, and the eight error classes declaring a `readonly status` (`DuplicateRecordError`, `HookUnscopedDataAccessError`, `MultiUpdateHookKeyDivergenceError`, `EmptyCredentialWriteError`, `SystemWriteOrganizationRequiredError`, `NamespaceConflictError`, `ArtifactObjectNameConflictError`, `ObjectOwnershipConflictError`). diff --git a/.changeset/environment-artifact-checksum-coverage-describe.md b/.changeset/environment-artifact-checksum-coverage-describe.md deleted file mode 100644 index 24aa57019f..0000000000 --- a/.changeset/environment-artifact-checksum-coverage-describe.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The environment artifact's `checksum` now states its own coverage boundary, and `grantedPermissions` states that it sits outside the digest by design. - -Describe text only — no key, value schema or accept-set change on `EnvironmentArtifactSchema`, and the digest itself is computed and verified by the control plane, not here. - -- **`checksum`** carried the shared `Sha256DigestSchema` describe ("SHA-256 digest (64 hex chars)"), which says what the value *is* and nothing about what it *covers*. It now has its own field-level describe: the SHA-256 digest of the canonical JSON serialization of the `metadata` block (stable key ordering), computed by the control plane when assembling the GET response — and coverage stops there, so no other key on the envelope is under the digest. The shared `Sha256DigestSchema` describe is unchanged, so every other digest field still inherits it. -- **`grantedPermissions`** gains one sentence group at the end of its describe: it sits beside `metadata`, outside the digest, and integrity of the granted consent set rests on the carrier — the artifact is environment-local and control-plane served (ADR-0003 / cloud ADR-0007) — an accepted boundary of this envelope rather than an oversight. Its five existing clauses (the manifest-`id` keying, the `sys_package_installation` source, the enforcer consumer, absent ≠ `{}`) are unchanged. diff --git a/.changeset/epoch-instant-and-external-vocabulary-exemptions.md b/.changeset/epoch-instant-and-external-vocabulary-exemptions.md deleted file mode 100644 index 33ae5af20c..0000000000 --- a/.changeset/epoch-instant-and-external-vocabulary-exemptions.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: declare the duration rule's two structural exemptions on the schema — a shared `EpochMs` instant and a `.meta({ externalVocabulary })` marker (#15676, ruling B on #14478) - - - -**BREAKING** — four published epoch-instant keys are renamed and tombstoned. -Shipped as `minor` under the repo's launch-window convention for breaking -changes; the hand-migration prescription is registered under protocol major 18. -Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit -in the key NAME, because two sibling keys both spelled `ttl` in different units -are indistinguishable at the authoring site. Ruling B exempts two structural -classes from it, and is explicit about the mechanism: both are **declared on the -schema, never in a gate ledger**. This change lands both declarations and -applies them. - -## 1. Epoch instants — the shared `EpochMs` schema - -`EpochMs` (`@objectstack/spec/shared`) is a `z.number().int()` describing -milliseconds since the Unix epoch. A key whose value IS that schema is an -INSTANT, and the gate recognises it structurally — nothing anywhere names the -exempt keys. - -An instant reads to the rule exactly like an offending duration (a bare name -plus a describe that says "milliseconds"), but renaming it the way the rule -prescribes would resolve the wrong confusion. Measured on this package's own -authorable surface: all 51 distinct keys ending in `Ms` are durations -(`timeoutMs`, `backoffMs`, `latencyMs`, `uptimeMs`) and all 51 distinct keys -ending in `At` are instants (`createdAt`, `expiresAt`, `lastUsedAt`). Spelling -an instant `*Ms` would move it INTO the family the rule exists to separate it -from. So the six instants take `EpochMs`, and the four whose name was bare take -the `*At` convention. - -### FROM → TO - -| Schema | Wrote | Write instead | -| :-- | :-- | :-- | -| `api/WebSocketEvent` | `timestamp` | `occurredAt` | -| `api/SimplePresenceState` | `lastSeen` | `lastSeenAt` | -| `kernel/KernelContext` (and `TenantRuntimeContext`) | `startTime` | `startedAt` | -| `kernel/HealthStatus` | `timestamp` | `checkedAt` | - -```ts -// before -const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startTime: Date.now(), features: {} }; -// after — the value is unchanged; only the key name and the declared schema move -const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startedAt: Date.now(), features: {} }; -``` - -Each old key is tombstoned with `retiredKey()`, so it fails `tsc` at the -construction site and fails the parse with the rename prescription rather than -being silently stripped. `kernel/ServiceMetadata.registeredAt` and -`kernel/ScopeInfo.createdAt` were already correctly named and only change -schema — they are not retirements and need no edit. - -⚠️ `api/PresenceState.lastSeen` (`api/realtime-shared.zod.ts`) is a **different** -key holding an ISO-8601 datetime string. It is untouched; do not rename it with -its neighbour. - -**One tightening.** `WebSocketEvent.timestamp` and `SimplePresenceState.lastSeen` -were declared bare `z.number()`, and `EpochMs` is `z.number().int()`, so a -fractional epoch that used to parse at those two sites is now refused. -`Date.now()` has always satisfied it. The other four already declared `.int()`. - -## 2. External-standard mirrors — `.meta({ externalVocabulary })` - -A key whose name is fixed outside this repo carries -`.meta({ externalVocabulary: '' })`. The marker rides -`z.toJSONSchema` verbatim (the channel `xRef` / `xExpression` already use), the -gate honours it, and **the reference page publishes it**: the description cell -now reads `… in seconds (unit per HTTP Cache-Control \`max-age\` (RFC 9111 §5.2.2.1))`. -Publishing it is what makes the exemption honest — the gate exists because a -bare `maxAge` publishes a naked number to a reader who cannot see the source. - -Eleven keys are marked: the three HTTP `Cache-Control` directives, the two CORS -`Access-Control-Max-Age` config keys, the two S3 presigned-URL `expiresIn` keys, -the three better-auth forwarded options, PostgreSQL's `statement_timeout` and -the DNS record `ttl`. No authorable key is renamed or re-typed by this half. - -⛔ Neither exemption is a pass on lying: a marked key still fails -`name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit -other than milliseconds fails the new `instant-unit-contradicts-schema`. diff --git a/.changeset/es-ja-dashboard-gap-source-parity.md b/.changeset/es-ja-dashboard-gap-source-parity.md deleted file mode 100644 index 9ac96e6184..0000000000 --- a/.changeset/es-ja-dashboard-gap-source-parity.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): the es-ES and ja-JP `dashboard.gap` help text says what its source now says - -`metadataForms.dashboard.fields.gap.helpText` read `Separación de cuadrícula (unidades -Tailwind)` in es-ES and 「グリッド間隔(Tailwind 単位)」 in ja-JP. Both were faithful -translations of the source they were extracted against, `Grid gap (Tailwind units)` — but -that source has since been rewritten to `Space between widgets, in steps of 0.25rem -(4 = 1rem)`, which deliberately drops the CSS framework unit an app author never chose and -cannot act on, and adds the magnitude the author can size a dashboard with. - -Both leaves kept the retired vocabulary and never gained the magnitude, because bundle -merge fills gaps only: a present-but-stale leaf is not a gap, so no amount of -re-extraction corrects it. They now read `Espacio entre widgets, en incrementos de 0.25rem -(4 = 1rem)` and 「ウィジェット間の間隔、0.25rem 刻み(4 = 1rem)」 — the grid framing is gone -exactly as it is upstream, `widgets` / 「ウィジェット」 is the word each bundle already uses -for dashboard widgets, and the conversion is carried so a Spanish- or Japanese-reading -author can size `gap` without reading the English. - -Two leaves. `columns` is unchanged upstream, so `Columnas de cuadrícula (predeterminado -12)` and 「グリッド列(既定 12)」 stay accurate, and the other 13 source-derived prose leaves -of this subtree (5 section descriptions plus 8 further field help texts) were read against -the current English and are accurate in both locales. diff --git a/.changeset/es-ja-metadata-forms-source-parity-nine.md b/.changeset/es-ja-metadata-forms-source-parity-nine.md deleted file mode 100644 index ab04f9b8e2..0000000000 --- a/.changeset/es-ja-metadata-forms-source-parity-nine.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): nine es-ES and ja-JP metadata-form leaves say what their source says - -Nine leaves of `metadataForms` served a superseded English source revision in both es-ES -and ja-JP. Each was a faithful translation of the sentence the source carried when it was -extracted; the English moved afterwards and bundle merge fills gaps only, so a -present-but-stale leaf is never refreshed by re-extraction. - -The nine, by the test the census applies — does the string assert something the source -does not, or drop a distinct concept the source names: - -- `object.fields.fields.trackHistory.helpText` said "keep change history" in both. The - source says `Summarize this field on the record activity timeline` — a different - feature, not a loose translation. -- `object.fields.isSystem.helpText` dropped `defaults sharing to public`. -- `view.fields.filter.helpText` dropped the whole clause after the dash — the shared - visual builder and its field-type-aware operators and value inputs. -- `action.fields.body.helpText` said "JavaScript code to run", losing the L1-expression / - L2-sandboxed-body distinction. It now reads as the sibling leaf - `hook.fields.body.helpText` already renders that same source sentence in both locales. -- `action.sections.advanced.description` asserted bulk operations, which the source does - not name. -- `page.fields.type.helpText` asserted the page-kind enum the source stopped listing and - dropped the "List / Interface binds a source view into a curated surface" sentence. -- `report.sections.basics.description` said "data source" where the source says report - type. -- `report.fields.columns.helpText` said "columns to show in the report", losing both - `Dimension names across` and `matrix only`. -- `email_template.fields.variables.helpText` described a list of variable names; the - source is a JSON shape example, which is language-neutral and is now carried verbatim. - -Values only — the key set is unchanged at 773 leaves, identical across all four bundles. -The recorded-source-hash table is untouched and needs no entry: it records a digest only -while a leaf is still a byte copy of its source, so all nine, being real translations, -carry no entry and are LEGACY-TRUSTED by construction. diff --git a/.changeset/evaluate-condition-shape-refusal.md b/.changeset/evaluate-condition-shape-refusal.md deleted file mode 100644 index fb872e476d..0000000000 --- a/.changeset/evaluate-condition-shape-refusal.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -`evaluateCondition` now refuses a malformed condition shape with the same `STRUCTURAL_CONDITION_SHAPE_REFUSAL` registration already raises — evaluation and registration share one refusal, so a shape that slipped past registration can never surface as a raw `TypeError` or as a silent `false`. - -#15662 closed the reject set at the producer: `registerFlow` refuses a structural condition (`config.condition` on a node, `edge.condition`) that is neither CEL text nor an expression envelope. The evaluator was left saying the opposite thing in a different vocabulary, and that half matters because `evaluateCondition` is a **public method on an exported class** — a plugin reaches it directly regardless of what `registerFlow` admits, and a flow stored before that gate landed replays through it. - -The unguarded read had three arms, all of them now refused by the shared `structuralConditionRefusal` — the same call `registerFlow` makes, not a second hand-written envelope that could drift from it: - -- an envelope whose `source` is present and **not a string** (`{ source: 1 }`, `{ dialect: 'cel', source: 1 }`) reached `.trim()` and threw `TypeError: exprStr.trim is not a function`, naming no flow, no node and no expression; -- a value that is neither text nor envelope-shaped (`42`, `true`, `['a']`, `{}`, `{ dialect: 'cel' }`) was read as an **empty condition** and answered `false` — the "an unauthored branch must not open" rule applied to a value that was very much authored, on the same key a start node's **trigger gate** is read from; -- a malformed envelope carrying a non-predicate dialect (`{ dialect: 'cron', source: 1 }`) answered `false` one statement earlier still, at the dialect check, never reaching the source derivation at all. - -**What still evaluates is unchanged, and is pinned as controls.** Bare CEL text and both envelope spellings evaluate exactly as before; an `ast`-only envelope still answers `false`; a well-formed non-predicate dialect (`{ dialect: 'cron', source: '0 0 * * *' }`) still answers `false` rather than being refused; absent, `null`, empty and whitespace-only conditions are still "not authored", not malformed. A malformed **string** still earns its own verdict — the brace trap or the ADR-0032 §1c CEL fault — never the shape refusal. - -An app whose stored flow carries one of the refused shapes in a node or edge `condition` now fails that run loudly with a message carrying the rule, instead of skipping a branch in silence or faulting unattributed; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. diff --git a/.changeset/evaluated-expression-slot-requires-source.md b/.changeset/evaluated-expression-slot-requires-source.md deleted file mode 100644 index bb7db75b05..0000000000 --- a/.changeset/evaluated-expression-slot-requires-source.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: an evaluated expression slot requires a non-blank `source` — `EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention): on the schemas that type an EVALUATED expression -slot — today the `assignment` node's value envelope, -`AssignmentExpressionValueSchema` — an envelope with no `source` the engine can -evaluate is now **refused at authoring**, where it used to parse, register, -pass `objectstack validate`, and then fault at run time. - -Two spellings of one seam, refused by ONE rule with one message at `source` -(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`): - -```yaml -assignments: - digest: { dialect: cel, ast: { kind: const } } # `ast` only — no engine evaluates it - greeting: { dialect: cel, source: ' ' } # blank after trimming — parses to EOF -``` - -> An expression in an evaluated slot needs a non-blank `source`: the expression -> engine evaluates `source` (the canonical persisted form of phase M9.1) 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: '…' }`. - -- **`ExpressionSchema` is NOT narrowed.** It is the persistence contract — - `source` OR `ast` — and its docblock declares that `ast` becomes required in - build output at phase M9.2. The new export `EvaluatedExpressionSchema` (and - its type `EvaluatedExpression`) is a sibling: the same envelope with `source` - required and non-blank, spelled once and composed by every evaluated slot, so - when AST-only evaluation lands the flip is one edit there rather than a - per-slot unwinding. The rule is worded as "an evaluated slot requires whatever - the engine can actually evaluate"; what that is today is `source`. -- **The notion of blank is the engine's own** — `.trim()`, which - `cel-engine.ts`'s helpers already apply — not a third one beside the shape - rule's `min(1)` and `validateExpression`'s trim. -- **Three doors agree.** `registerFlow` refuses the flow, `objectstack validate` - and the runtime publish gate report a located `error` at the author's own - variable (`config.assignments..source`), and the executor's own shape - pass refuses the same set — all through the spec schema, so none of them - grew a rule of its own. - -**What an author does with a refused envelope.** An assignment value that -carried only `ast` has no evaluable form under M9.1: author its `source`. A -whitespace-only `source` was never an expression: delete the entry, or write -the expression. Every envelope with a non-blank `source` is unchanged, and -nothing is renamed, retired or rewritten — the refusal itself carries the -prescription. - -Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s -silent `false` on an envelope without a `source` — is a behaviour change on a -live path with its own card, and the edge-condition schema that carries that -envelope is narrowed in a follow-up once the in-flight change to -`automation/flow.zod.ts` lands. diff --git a/.changeset/execution-step-metrics-failure-slot.md b/.changeset/execution-step-metrics-failure-slot.md deleted file mode 100644 index a3e81d1ccf..0000000000 --- a/.changeset/execution-step-metrics-failure-slot.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `ExecutionStepMetrics` gains an optional `failures` slot, and `FlowRunSummary.failed` is declared as the fold INCLUDING what a delegating node rolled up from its child (maintainer ruling 2026-09-06 on #15617, spec half) - -Additive. Nothing an author writes is renamed, retired or narrowed; no accept -set shrinks. One optional key is declared on a runtime-produced schema and the -prose of a published contract is reconciled with itself. - -**What was wrong.** `FlowRunSummary` said two things about `failed`. Its -header paragraph declared that a `subflow` node rolls its child run's totals -up into the parent — "this summary answers *what did this run cause*" — while -the field itself declared `failed = Σ nodes[].failures`, a fold over the -parent's own node executions. For a parent that delegates its rows to a -`subflow` (or a `map` item) those give different answers, and the engine could -only satisfy the second one: `ExecutionStepMetrics` carried `selected` / -`acted` / `unmeasuredEffect` and no failure slot, so a child's contained -failures had no path into the parent's fold. Measured on the real engine by -the services seat (#15617): parent `loop { subflow(child) }` → parent -`failed=0` while the five child summaries carried `failed=[0,0,0,0,1]` — -`acted` rolled up, `failed` did not. - -**What this declares.** - -- `ExecutionStepMetrics.failures` (optional, integer ≥ 0): node executions - that failed inside a child run this execution delegated to and went on from - — a `subflow` child or a `map` item whose run COMPLETED while containing - failures, i.e. the child's `summary.failed`, rolled up. It folds into the - delegating node's `nodes[].failures` and so into the run-level `failed` — - the same fold shape `acted` has, but not the same rule at the failed-child - boundary (next bullet). Absent means the step delegated nothing, or its - child tracked no count, or the producer did not track it (every step the - engine emits between this release and the engine half) — never zero. -- It is NOT the step's own outcome. A step that failed is `status: 'failure'` - and counts once through `nodes[].failures`, as before; a child that FAILED - — whether or not it also contained failures before it failed — is - precisely that step failure: its own `failed`, contained and fatal alike, - stays on the child's run row and nothing rides up, so one failure is never - counted twice. This is where the rule parts from `acted`, which does carry - a failed child's writes up to the parent. The control the card measured (a - failing child → parent `failed=1`) keeps counting exactly as today. -- `FlowRunSummary.failed` is declared, at the field, as the fold of - `nodes[].failures` INCLUDING what a delegating node rolled up; the - `FlowRunNodeSummary.failures` describe names the roll-up path, and its - `status` describe states that a delegating node whose child contained - failures reads `success` beside `failures > 0` — status is judged on the - node's own executions. - -**What this does not do yet.** This is the contract half of a two-lane -landing (contract first). No producer populates `failures` in this release: -`subflow-node.ts` and the `map` node roll the child's contained failures into -the slot in the services half, #16314, and only then does a parent's -`failed` start counting them. Until that lands, every `ExecutionStepMetrics` -the engine emits is byte-identical to today's, `failed` is numerically what it -was, and the flow-run reference page keeps the narrowed wording PR #15609 -shipped ("node executions **of this run**") on purpose — it is widened when -both halves are in. - -**Consumers.** A reader of `ExecutionStepMetrics` sees one more optional -number and nothing else changes shape; a consumer that already sums -`nodes[].failures` to cross-check `failed` keeps agreeing with it, because the -fold is unchanged — the roll-up enters the per-node array, not beside it. Two -consequences of that placement are part of the contract from this release, -even though no producer populates the slot yet: on a delegating node -`nodes[].failures` may exceed `runs` (`runs: 5, failures: 15` is a legal -shape — five subflow executions whose children each contained three), and it -is no longer only that node's own failed executions, so a reader that derived -"this node's executions that failed" or a failure RATE from `failures / runs` -must read a delegating node's number as "failures this node caused, its -child's contained ones included". diff --git a/.changeset/export-object-nav-target-exclusivity.md b/.changeset/export-object-nav-target-exclusivity.md deleted file mode 100644 index 7ff2240360..0000000000 --- a/.changeset/export-object-nav-target-exclusivity.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`objectNavTargetExclusivity` — the object-level check on an object navigation item that refuses `filters` combined with `recordId` / `viewName`, and `runAction` combined with `recordId` — is now EXPORTED from `@objectstack/spec/ui`, one function per refinement in the same posture as the `check*` exports. A hand-written mirror of the object nav item chains this very function in its own `superRefine` instead of restating the rule from prose; a restatement is what drifts: objectui's hand-written mirror re-implements neither rule — its `superRefine` checks only `id` / `label`, and the file names no `filters` rule beyond the field's declaration (measured at the pinned `.objectui-sha` and at objectui `origin/main`). - -**What moves for consumers: one new export.** No schema's accept set moves. `NavigationItemSchema` chains the check exactly where it did — its `type: 'object'` branch — and the exported `ObjectNavItemSchema` still does not chain it: which schema mounts the check is a separate question from whether a mirror can, and it is not decided here. The two deliberate asymmetries are unchanged and now pinned: `recordId` + `viewName` stays a tolerated legacy combination, and `runAction` is refused with `recordId` only (it still composes with `filters` / `viewName`). - -**Also corrected, in the same file:** the `filters` docblock stated a complete precedence order (`recordId` → `filters` → `viewName`) a few lines above saying the combination is unrepresentable, and the mirror copied that first half. The docblock now states only what the guard refuses, says in as many words that no precedence order is stated and why, and names the one legacy combination the guard tolerates. The `.describe()` strings — what reaches the generated references — are unchanged. diff --git a/.changeset/expression-source-non-string-refused.md b/.changeset/expression-source-non-string-refused.md deleted file mode 100644 index 083d5c2b03..0000000000 --- a/.changeset/expression-source-non-string-refused.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/formula": minor ---- - -`validateExpression` now refuses a non-string expression `source` through `errors[]`, instead of throwing a raw `TypeError` that wiped out the caller's located reporting. - -`validateExpression(role, input)` accepts `string | { dialect?, source? }`, and read the envelope's `source` unguarded — `if (!source.trim())`. `ExprInput` declares `source?: string`, but every production call site casts, because the value comes out of **metadata**, where a declaration is a claim about stored data and not a guarantee about it. An envelope whose `source` was present and not a string therefore threw `TypeError: source.trim is not a function` out of a validator whose own docblock promises it never throws. - -**The defect was not "it throws" — it was that it threw the wrong kind and bypassed a whole located-reporting contract.** `AutomationEngine.validateFlowExpressions` collects located findings and throws one assembled error naming the flow, the node, the slot and the source (ADR-0032 §1d); `@objectstack/lint`'s stack walk attributes every finding to the hook, sharing rule, action or field it came from. An exception raised *inside* the shared validator skipped both, so the author was handed an internal message naming none of them. Measured before the fix, on a stack whose `hooks[].condition` was `{ source: { nested: 1 } }`: the whole `objectstack validate` run died on `source.trim is not a function`. After: one located `error` reading ``hook 'gate_hook' (lead) condition``. - -The guard sits at `toSource`, the entry `validateExpression` and `inferExpressionType` share — **once**, not in each caller's own `try`/`catch`, which is the tolerant-consumer shape Prime Directive #12 forbids. `validateExpression` returns `ok: false` with one `ExprValidationError` naming what was found and both authorable forms; `inferExpressionType` answers `'unknown'`, its existing "cannot prove a type". - -**No exported symbol or signature moves** — measured by diffing the built `dist/index.d.ts` before and after: 39 exported declarations on both sides, and `validateExpression`'s declaration byte-identical. What changes is behaviour at a published entry, which is why this is `minor` rather than `patch`: an input that previously produced **no verdict at all** now produces a rejection. - -**What does not change.** Absent, `null`, empty and whitespace-only sources still read as "not authored" (`ok: true`), an `{ ast }` envelope carrying no `source` is still admitted (its admission is `ExpressionSchema`'s rule, not this entry's), and a malformed *string* still gets its own diagnostic — the brace trap, the dialect mismatch, the unknown function — never the shape refusal. No input that previously returned `ok: true` now returns `ok: false`, and none that returned `ok: false` now returns `ok: true`. - -A caller that relied on catching the `TypeError` would need to read `result.ok` instead. None does: all nine production call sites (`@objectstack/lint` ×4, its docs gate ×2, `@objectstack/service-automation` ×3) read `.errors`/`.warnings` directly, and the one call site inside a `try` (`@objectstack/mcp`'s `validate_expression` tool) has a handler-level catch that degrades to an error result and declares its `expression` parameter `z.string()`. diff --git a/.changeset/field-reference-non-blank.md b/.changeset/field-reference-non-blank.md deleted file mode 100644 index e928ece5e0..0000000000 --- a/.changeset/field-reference-non-blank.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): `FieldSchema` refuses a WHITESPACE-ONLY `reference` on `lookup` / `master_detail` - -**BREAKING** accept-set narrowing on `FieldSchema`, shipped as `minor` under the -repo's launch-window convention for breaking changes — the same grade the nearest -tightening precedents shipped with, including #13632, the narrowing this one -finishes. - -#13632 closed the declared-but-unenforced gap on `FieldSchema.reference` in 17.3.0, -but spelled its emptiness test as an equality against `''`, so a whitespace-only -target (`reference: ' '`) passed a door whose whole purpose is to name an object. -Measured on the built artifact before this change: absent and `''` were refused, -`' '` and `'\t\n'` were **accepted**, at both the field level (`FieldSchema`) and -the document level (`ObjectSchema`), on `lookup` and `master_detail` alike. - -A blank target names no object either. The declared grammar for an object name is -`/^[a-z_][a-z0-9_]*$/` (`ObjectSchema`'s own `fields` key schema), so no -whitespace-bearing string can ever resolve to one, and all three consequences the -existing refusal message lists hold verbatim for `' '`: the record picker has no -object to query, `$expand` has nothing to resolve, and no relationship index can be -built. It is also the state a cleared target picker emits — `''` and `' '` are one -authoring gesture that was getting opposite verdicts. - -What newly gets rejected: `type: 'lookup'` or `type: 'master_detail'` whose -`reference` is present but consists only of whitespace. It joins absent and `''` -under the same `custom` issue, on the same `reference` path, with the same -prescriptive message — no new message and no new error shape. The notion of blank -is `.trim()`, the same one `EvaluatedExpressionSchema` applies to `source`, not a -third one. - -Everything else is untouched. Trimming is applied to the TEST only, never to the -stored value: a target with surrounding whitespace (`' company '`) is still accepted -and still round-trips byte-identically. A non-string `reference` still answers -`invalid_type` from the base schema, not the custom message — that distinction is -deliberate and pinned. Non-relationship types never carried the requirement, and the -`Field.lookup()` / `Field.masterDetail()` helpers take the target as their first -positional argument, so helper-authored fields cannot produce this shape. - -The measured population of affected authored sources is zero: one repo-wide census -over all tracked files found a single whitespace-only `reference` in the tree, an -objectql test fixture cast past Zod on the documented `registerObject` path that -skips schema validation by design — it does not reach this door, and it is green -after the change. The census and its positive controls are recorded on the PR. -Downstream, objectui's two metadata writers already refuse this shape with -`reference.trim() !== ''`; upstream trimming turns their declared divergence into -contract-following, and that note can now be retired. - - diff --git a/.changeset/field-value-domain-write-path.md b/.changeset/field-value-domain-write-path.md deleted file mode 100644 index 8cbcd8dfa1..0000000000 --- a/.changeset/field-value-domain-write-path.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/spec': minor ---- - -feat(objectql,spec): `Field.valueDomain` binds at the write seam — a non-member is refused with `value_domain` (maintainer ruling 2026-09-02 on #14168, engine half) - -**BREAKING** accept-set narrowing on the ObjectQL record write path, shipped as -`minor` under the repo's launch-window convention for breaking changes. - -The key is **already published, and published unenforced**. The version-packages -cut `8a1bad8b8` (2026-09-04 10:20Z) consumed the spec half's changeset -`field-value-domain-slot.md` and released `@objectstack/spec@17.3.0`, which -declares `Field.valueDomain`, parses it, and refuses it on any type other than -`text` — and never reads it when a record is written. The 17.3.0 liveness ledger -states the gap in its own words: "a non-member WRITTEN to a `text` field -declaring a domain is accepted today". That write is accepted on 17.3.0 and is -refused from this release on. - -**Refused shape**, precisely: a record write that supplies a value for a `text` -field whose definition declares `valueDomain`, where the WRITTEN value is not a -member of the named standard. It fails with the field error code `value_domain`, -carrying `constraint: { valueDomain }` and a message that names the standard in -all four platform locales. Nothing else narrows — a field that declares no -`valueDomain` is untouched, and so is every other field type, because the schema -accepts the key on `text` alone and the validator judges exactly that set. - -**Remedy: write a member of the declared standard.** `iana_time_zone` admits -`UTC` and refuses `Mars/Olympus`; `iso_4217_currency` admits `CHF` and refuses -`chf`; `iso_3166_alpha2` admits `CH` and refuses `ZZ`. Dropping the -`valueDomain` declaration from the field lifts the refusal entirely, for an -author who declared a domain they did not mean. - -**No stored row is touched, and none becomes invalid.** This is the `min` / -`max` / `maxLength` transition-gate class: a value stored before the domain was -declared — or before this release — is never re-read, and it survives an edit of -another field on the same record. An absent or empty value follows the field's -`required` handling, not this check. - - - -- The membership test is the spec's shared `isValueDomainMember` — the same - predicate, over the same closed vocabulary, that a settings specifier's - `valueDomain` uses. A time zone accepted in Settings is the time zone - accepted in a field. -- The two authoring forms (`fieldForm`, `objectForm`) gain a `valueDomain` - control, shown on exactly the types the schema accepts the key on. The - object-form control's choices are derived from the vocabulary, not re-typed. diff --git a/.changeset/filter-operator-member-descriptions.md b/.changeset/filter-operator-member-descriptions.md deleted file mode 100644 index 4bc406be28..0000000000 --- a/.changeset/filter-operator-member-descriptions.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Every filter-operator member now carries a `.describe()`, so the published `data/filter` reference documents all of them instead of a subset. - -The Description column of `content/docs/references/data/filter.mdx` is filled from `prop.description` — the JSON-Schema projection of a Zod `.describe()`. A JSDoc block above a member never reaches that column, so members documented by JSDoc alone rendered with an empty Description cell on a published reference page, including operators whose semantics an earlier correction campaign existed to fix. - -Seven cells on that page were blank and are now filled: `EqualityOperator.$eq` / `$ne`, `StringOperator.$contains` / `$notContains` / `$startsWith` / `$endsWith`, and `QueryFilter.where`. - -The descriptions are prose about behaviour that already ships — no operator semantics, accept-set, export or authorable key moved, and the JSDoc blocks are kept as-is: - -- `$eq` / `$ne` state the default-operator role, the SQL and MongoDB lowerings, the `{ $field }` comparand position, and that `{ "$eq": null }` / `{ "$ne": null }` are the has-no-value / **has-a-value** predicates — the value question, never a key-presence one. -- The four case-sensitive `$contains`-family members state their case contract, their SQL lowering, and the comparand contract they share: `%` and `_` are ordinary characters because the family escapes and anchors the comparand for the caller, which is what separates them from `$like` / `$ilike`. -- `where` states the condition-tree shape plus the two semantics that were ruled rather than inherited — `$not` is NULL-safe, and empty `$and` / `$or` are the boolean identities. - -Each description is now a module-level constant read by **both** copies of its operator — the documentation schema (`EqualityOperator`, `StringOperator`, `RangeOperator`, `SpecialOperator`) and the enforced `FieldOperatorsSchema` — extending the pairing `ORDERING_COMPARAND_DESCRIPTION` and `SET_MEMBER_DESCRIPTION` already gave the ordering and set slots. The two copies now share the text rather than a description of it, so an operator can no longer be documented in one and blank in the other. diff --git a/.changeset/filter-text-non-string-stored-value.md b/.changeset/filter-text-non-string-stored-value.md deleted file mode 100644 index 5632728f89..0000000000 --- a/.changeset/filter-text-non-string-stored-value.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`FILTER_TEXT_CASES` declares what a text operator answers over a stored value that is NOT a string, and the fixture gains its first non-string column. - -Measured before this row existed, one filter over one numeric column answered four ways across the platform: `driver-memory`'s reference matcher said NO to `$contains` and to `$notContains` for the same row; its live mingo path, `formula`, objectql's `having`, `driver-mongodb` and the analytics face type-gated (`$contains` NO, `$notContains` YES); the SQLite family coerced the number to text in its storage class's spelling (REAL renders `5` as `'5.0'`); and live Postgres refused at query time with SQLSTATE 42883 — a 500. - -The maintainer ruled the cell on 2026-09-05 (option A, type-gate): a stored value that is not a string never satisfies a positive text operator (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` — complementarity holds, on every face. Coercion was refused on the measurement; a declared-type door that refuses the filter before any backend runs is deferred to its own decision card, not rejected. - -- `FilterTextRow` is now `{ id, name, score }` — `score` is a NUMBER on every row (a `0` among them), chosen so a coercing backend answers a visibly non-empty set and a truthiness guard drops a row. -- Five new evaluated rows over `score`: the four positive operators the table can carry answer `[]`, `$notContains` answers all nine. (`$like` / `$ilike` follow the same rule and are pinned on the faces that answer them — the table is a driver's enrolment and `driver-mongodb` refuses those two.) -- `NON_TEXT_STORED_VALUE_TYPES` (`field-value.zod.ts`) — the numeric and boolean value classes, i.e. the declared field types whose stored value is never text — is the list the SQL faces classify a column by at compile time, since they cannot read the value. Temporal types are deliberately absent: their stored form is a dialect question (ADR-0053) the row does not decide. - -Every suite that materialises the fixture adds the column (SQL `initObjects` DDL included). diff --git a/.changeset/filter-text-operator-declared-type-door.md b/.changeset/filter-text-operator-declared-type-door.md deleted file mode 100644 index 5a20137cef..0000000000 --- a/.changeset/filter-text-operator-declared-type-door.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661) - - - -**BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny. - -The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares. - -New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes. - -The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set. - -What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant. diff --git a/.changeset/find-afterfind-array-guard.md b/.changeset/find-afterfind-array-guard.md deleted file mode 100644 index b9b8ad18da..0000000000 --- a/.changeset/find-afterfind-array-guard.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/spec": minor ---- - -`ObjectQL.find()` now guarantees the array it declares: an `afterFind` hook that replaces the result container is refused with `FIND_HOOK_RESULT_NOT_ARRAY`. - -`find()` is declared `Promise`, but on the hook path it returned `hookContext.result` with nothing re-checking the value after the `afterFind` dispatch. A handler assigning `ctx.result = { records: [ … ] }` therefore made a read declared to resolve to an array resolve to an envelope instead — silently, with no throw, no diagnostic and no log, while roughly 140 call sites read the answer as an array on the strength of the declaration. - -The engine now refuses that, immediately after the `afterFind` dispatch and ahead of the two consumers that already assume the array (secret-field masking and the `__search` companion strip). The refusal is a named error, `FindHookResultNotArrayError`, carrying the registered ADR-0112 code `FIND_HOOK_RESULT_NOT_ARRAY` and HTTP `500`; its message names the hook event and the object, and `developerMessage` carries the remedy. - -**Shaping stays legal, and nothing about it changes.** A handler may still mutate rows in place, delete keys, filter rows out, or assign a *different array* built from them — `Array.isArray` is the whole predicate, deliberately, so that `ctx.result = ctx.result.map(…)` keeps working. Only the container is protected. - -What to do if this refusal fires: - -- to answer no rows, assign `[]`; -- to refuse the read, `throw` from the handler — the supported way for any hook guard to say no; -- to hand a caller a different structure, build it in the caller, not in the hook. - -`@objectstack/spec` widens by one member: `FIND_HOOK_RESULT_NOT_ARRAY` joins `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the generated `ErrorCode` union — and therefore `ApiErrorSchema.code` — accepts it. Additive: no existing code is removed or renamed. - -Scope: this closes the one `return hookContext.result` site in the engine with a concrete declared shape to violate. `findOne`, `update` and `delete` declare `Promise` and carry no enforceable declaration; that is a separate question about those declarations and is deliberately not answered here. diff --git a/.changeset/first-undeclared-reference-false-negative-contract.md b/.changeset/first-undeclared-reference-false-negative-contract.md deleted file mode 100644 index f34be606c2..0000000000 --- a/.changeset/first-undeclared-reference-false-negative-contract.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/formula": patch -"@objectstack/lint": patch ---- - -`firstUndeclaredReference` now documents the side of its contract it was silent about: it can false-NEGATIVE, and a `null` is "nothing was reported", not "every reference is rooted". - -The existing sentence — "Acts ONLY on cel-js's `Unknown variable: X` fault, so it cannot false-positive on arithmetic/comparison overloads" — is true, and stays. What it never said is what that narrowing costs. cel-js's checker returns exactly ONE error, so when the first one is of another class every undeclared reference behind it in the same source goes unjudged and the helper answers `null` — the same value that means the source is clean. A contract that declares only which error it cannot make reads as making neither. - -No behaviour changes. This is the contract text, and it ships: the amended block is JSDoc on a published export, so it is emitted into `@objectstack/formula`'s `dist/index.d.ts` and `dist/index.d.mts` (measured — the declaration file grew 53.45 KB to 55.99 KB) and is what a consumer reads on hover. - -What the amendment adds, all of it measured rather than reasoned: - -- **The masking is positional, not name-keyed.** The masked name is not the one that triggered the first error, so excluding the trigger's own name does not reach it. `data == 'x' && status == 'q'` answers `null`; the same two names in the other order answer `"status"`. -- **`celEngine.compile()` is not a gate against it.** `compile` type-checks in the permissive environment, where every unlisted name is `dyn`. The strict environment here declares `SCOPE_ROOTS` as `map`, so a root — or an object field sharing one of those names (`data`, `config`, `result`, `item`, `event`, `input`, `user`, …) — used as the operand of an operator with no `map` overload faults HERE and nowhere else. A caller that only reaches the helper on a clean compile is therefore not protected by its own gate. -- **The CEL type-name class is the same shape.** `type == 'grid'` is already pinned as a blind spot in `@objectstack/lint`'s `visibility-bare-identifier` suite, but pinned per NAME; the masking it causes is source-wide. -- **What closing it would take, and why that is not this change.** Widening the regex onto the overload message is the false positive the narrowing buys off (`type(record.x) == string` is legitimate CEL). Reporting past the first error needs a re-check loop over a neutralised source, or a checker entry returning more than one error — cel-js 8.0.0 has neither; its `TypeCheckResult` carries a single `error`. Both change what every consuming rule reports, so the oracle's shape is a design decision. - -`@objectstack/lint` carries a second comment-only correction, to `flow-variable-scope`'s account of the same oracle. Its "known, deliberate blind spot" note bounded the under-report to a flow variable named after a `SCOPE_ROOTS` member; measured, the bound does not hold — such a name in an operand position terminates the discovery loop on iteration 0 and every shadow in that source is lost, whatever it is named. That block sits on an internal function, so unlike the `formula` half it reaches no published declaration file; the entry is here because the package is touched and published. diff --git a/.changeset/flow-action-record-load-signal.md b/.changeset/flow-action-record-load-signal.md deleted file mode 100644 index 3fad885942..0000000000 --- a/.changeset/flow-action-record-load-signal.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": patch ---- - -feat(runtime): a flow action's run context now carries `recordLoadDenied` (#15168) - -The previous release declared `AutomationContext.recordLoadDenied?: true` and -said so plainly: **declared, not yet populated on the flow face.** The -script/body face of both action doors emitted the signal, but -`dispatchFlowAction` handed `automation.execute` a context without it, so a -`runAs: 'system'` flow that guarded on the documented key was inert — never -`true`, never wrong, and indistinguishable from a flow whose caller could read -the row. - -**This release populates it, on both doors in one stroke** — REST -`POST /api/v1/actions/...` and the MCP `run_action` bridge: - -```js -// a runAs:'system' flow, guarding before it acts on the subject row -if (context.recordLoadDenied === true) { /* the invoker cannot read this row */ } -``` - -- **The exact producer shape, unchanged.** The one shared producer - (`loadActionSubjectRecord` → `actionRecordLoadSignal`) already returns - `{ recordLoadDenied?: true }`, and the flow door now spreads it as a - **sibling of `record`** — never a key on the record, and **absent**, never - `false`, when nothing was refused. So a flow reads it exactly as a handler - does, `recordLoadDenied === true`. -- **Both doors, structurally.** `dispatchFlowAction`'s wiring now takes the - load OUTCOME (`subject`) instead of a bare `record`, and derives both the - record and the signal from it. A caller can no longer forward the row while - dropping the verdict that says the caller could not read it — the omission is - a compile error rather than a guard silently inert one door over, which is - the defect the handler-face signal was filed for. -- **Purely additive.** Nothing is refused that was not refused before, no - existing key changes value, and the `recordId` stamp is deliberately kept: - `record.id` still arrives exactly as it did, which is why the flag — and not - `record.id` — is the authorization predicate. Whether the automation engine - *acts* on the key (a flow-level refusal, a step condition) is a separate - decision and is deliberately not part of this change. -- **`@objectstack/spec` (docs only).** The contract's "not yet populated on the - flow face" sentence is retired; no type changes. diff --git a/.changeset/flow-decision-gates-on-nothing.md b/.changeset/flow-decision-gates-on-nothing.md deleted file mode 100644 index b80ccbc15d..0000000000 --- a/.changeset/flow-decision-gates-on-nothing.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`flow-decision-unconditional-branch` now reports the decision that gates on nothing — the shape the rule used to skip. - -A `decision` whose out-edges carry no `condition` and no `isDefault`, and whose node declares no `config.conditions[]`, selects no branch at all: the automation engine's own decision executor reports no branch when `conditions[]` is empty, so traversal considers every out-edge and each successor runs on every pass. The gateway is decoration. The rule could not see that shape, because it was framed as "an unconditional edge undercuts a guarded one" and read zero guarded edges as nothing to undercut — so the strictly worse gateway was the one case that stayed silent, and it is the harder one to notice in review, because the node still says `type: 'decision'`. - -Same rule id, same `warning` tier, with its own message: it names the out-edges that run unconditionally and offers the three fixes (a `condition` per branch plus `isDefault: true` on the fallback, a `config.conditions[]` whose `label` matches an out-edge, or dropping `type: 'decision'` for the node the gateway already behaves as). The mixed shape — one guarded out-edge beside an unconditional one — keeps its existing wording and its single finding. - -Decisions that do declare their routing stay silent, including the two that are easiest to catch by mistake: an ordinary gateway with guarded edges, and a decision that routes by `config.conditions[]` labels alone with bare out-edges. A decision declaring a label no out-edge claims remains the gating `flow-branch-label-unmatched` on its own, with no second finding piled on the same node. diff --git a/.changeset/flow-edge-id-uniqueness.md b/.changeset/flow-edge-id-uniqueness.md deleted file mode 100644 index a69e7ad3c7..0000000000 --- a/.changeset/flow-edge-id-uniqueness.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964) - - - -**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose `edges[]` -carries two edges with the same `id` is now **refused at parse time** — by -`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a -flow through the schema (`objectstack validate`, the runtime publish gate, a -stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under -the repo's launch-window convention for breaking changes. Maintainer ruling -2026-09-05 on #14964 (director decision batch #40, verbatim 「同意」): option -A — an `error`, not a `warning`; no opt-out, no transition window. - -Every reader of an edge id assumes the ids in a flow are unique — a designer, -a BPMN export, a flow diff, any traversal that dedupes by id — and nothing -enforced it. A real duplicate (`id: 'e20'` on two edges of one flow) shipped -through two releases of green CI in a downstream app and was inert only -because the engine keys out-edges by `source`, never by `id`: the collision is -invisible until something keys on ids, and then silently wrong rather than -loudly broken. The id space is hand-authored, so the next author picking a -"free" id from the sequence had no way to know it was taken. - -**What changes** (`packages/spec/src/automation/flow.zod.ts`): a `superRefine` -on the flow's `edges[]`. Each later occurrence of an already-declared id raises -one `custom` issue, anchored at `edges[N].id` of the *later* edge and naming -both positions, so the formatted error points at the edge to renumber: - -```text -✗ edges.7.id: Duplicate edge id `e20` — `edges[7]` reuses the id already declared by `edges[3]`; every edge id in a flow must be unique. Renumber one of them: … -``` - -**What does NOT change:** `edges[].id` keeps its name, type and describe; the -node vocabulary, the edge `type` enum and every other refusal are untouched; -a flow with unique edge ids (or no edges) parses exactly as before. Node ids -are not covered by this change. - -The shape that is refused, and what the author does about it — a two-edge -excerpt, the later edge renumbered: - -```ts -// before — parsed on green, both edges keyed 'e20' -edges: [ - { id: 'e20', source: 'qualify', target: 'convert' }, - { id: 'e20', source: 'convert', target: 'end' }, -] - -// after — refused at parse (edges.1.id: Duplicate edge id `e20` …); renumber the later one: -edges: [ - { id: 'e20', source: 'qualify', target: 'convert' }, - { id: 'e21', source: 'convert', target: 'end' }, -] -``` - -**Remedy.** Renumber the later edge to an id no other edge in that flow -carries; nothing else in the flow needs to move. The census over this -repository found no flow to migrate, so this is a release note, not a -migration: no shipped example, fixture or seed in `packages/**` or -`examples/**` declares a duplicate edge id, and the pinned objectui tree -carries none in its authored flows. The one known downstream instance was -renumbered before this change (hotcrm PR #1571). diff --git a/.changeset/flow-end-node-refused-outcome.md b/.changeset/flow-end-node-refused-outcome.md deleted file mode 100644 index 38741c5de7..0000000000 --- a/.changeset/flow-end-node-refused-outcome.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`. - -Until now every terminal of a flow was "completed". A flow could say *do this* but not *refuse this, and say why, for which record* — the only channel that interpolated per-record text was a `screen` node's `description`, and a message-only screen renders Submit and, on submit, resumes to `end`, whose runner toasts `Flow "…" completed` at a user who was just told "this is refused". Maintainer ruling (2026-09-05, option 2′): the refusal is a first-class outcome of the existing terminal node, not a second node type. - -The contract, declared here first (the engine and runner halves follow in their own packages): - -- **`end` node config** — `EndConfigSchema` (`@objectstack/spec/automation`): `outcome?: 'completed' | 'refused'` (default `completed`) and `message?: string`, a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.). `outcome: 'refused'` without a `message` is refused at parse (a refusal without text is the shape this exists to replace); `message` on a completed end is refused too (nothing would ever render it). The shape is strict: an undeclared key is a parse error naming the intended key. Because `end` is structural (no executor, no descriptor), `FlowNodeSchema` applies the contract itself to every `type: 'end'` node it parses and writes the parsed (defaulted) config back; a node with no `config` is left without one. Every other node type's `config` stays the open, executor-owned slot it was. -- **Run row** — `ExecutionStatus` gains `refused` (appended last: a terminal state distinct from `failed` — a refusal is a successful evaluation that says no; never resumed) and `ExecutionLogSchema` gains `refusalMessage`, the rendered per-record text, set only on a refused run. -- **Result / wire** — `AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'`, and both carry `refusalMessage`; on a refusal `success` is `true` and `successMessage` is absent, so a runner shows the message with Close only — no Submit, no completion toast. - -Additive throughout: nothing renamed or retired, so no ADR-0087 conversion-layer entry (disposition: not-required). Flows that never set `config` on an `end` node parse exactly as before. diff --git a/.changeset/flow-filter-token-unknown.md b/.changeset/flow-filter-token-unknown.md deleted file mode 100644 index e8f1827a1c..0000000000 --- a/.changeset/flow-filter-token-unknown.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -New gating rule `flow-filter-token-unknown`: a `{…}` filter token in a flow node's `config.filter` that NEITHER `{…}` dialect can resolve is now an authoring-time `error`. - -`filter-token-unknown` walks seven presentation collections and not `flows`, so `{TOMORROW()}` in a list view's filter failed the build while the identical string in a flow node's `config.filter` was silent — even though this package's other filter rules (`empty-combinator`, the preset-comparand rules) have reached flows all along. - -The gap was not an oversight to close by adding a root. A flow node's filter is interpolated by the automation template evaluator **before** ObjectQL sees it, and only what that evaluator cannot resolve is handed on. Judging a flow filter against the ObjectQL vocabulary — the obvious one-line fix — reports every legitimate `{record.id}` and `{recordId}`: measured at **7 findings, all 7 false positives**, on this repo's own example apps. So the new rule is a second rule id with the flow dialect as its reference set, and `filter-token-unknown`'s surface list is untouched. - -Reported (`error`): a call to a name in neither table — `{TOMORROW()}`, `{ROUND(x)}`, `{Math.round(x)}`, `{DATEADD(day, -45)}`. The flow template dialect's function vocabulary is closed (`round` / `floor` / `ceil` / `abs` / `min` / `max`, plus the whole-token `NOW()` / `TODAY()` with an optional `± N` day offset), and the evaluator already raises a guard refusal on anything else — so the node cannot run at all, and the build was shipping a flow whose runtime was already decided. This is the same severity axis `flow-template-unknown-field` applies at this exact position. - -Silent, deliberately: `{TODAY() - 45}` and every other whole-token date form; `{$User.Id}`; `{current_user_id}` / `{today}` / `{30_days_ago}` and the rest of the filter placeholders; and every bare or dotted identifier (`{recordId}`, `{record.id}`, `{currentTask.id}`), which addresses the run's variable map — declared flow variables, node outputs, and the trigger record's own fields — and is not decidable from authored metadata. - -Finding delta on this repo's example apps: **0**. Expect a new `error` only where a flow filter calls a function the evaluator would refuse at run time. diff --git a/.changeset/flow-node-id-uniqueness.md b/.changeset/flow-node-id-uniqueness.md deleted file mode 100644 index 16d7a003db..0000000000 --- a/.changeset/flow-node-id-uniqueness.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FlowSchema` refuses a flow whose top-level `nodes[]` declares the same id twice (#15713) - - - -**BREAKING** accept-set narrowing on `FlowSchema` — a flow whose top-level -`nodes[]` carries two nodes with the same `id` is now **refused at parse time** -— by `FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that -validates a flow through the schema (`objectstack validate`, the runtime -publish gate, a stack's `flows[]`) — where it used to parse on green. Shipped -as `minor` under the repo's launch-window convention for breaking changes. The -exact parallel of #14964 (edge ids, maintainer ruling 2026-09-05, option A — -an `error`, not a `warning`; no opt-out, no transition window), applied to the -other hand-authored id space in the same schema. - -Every edge's `source` / `target` names a node by id, and the engine's traversal -picks out-edges by `source` — with two nodes sharing an id, every edge from -that id is ambiguous and whichever node wins is decided by array order, -silently. A designer, a BPMN export and a flow diff key on node ids the same -way they key on edge ids. Only region bodies (`loop` / `try_catch` / `parallel` -sub-graphs) were checked, by `analyzeRegion` at `registerFlow()`; the flow's -own top-level `nodes[]` parsed with the collision intact — measured on -`origin/main` `1f2a02ba` with two lit controls on the same schema instance (a -node missing its `label` → refused at `nodes.1.label`; an unknown key on a node -→ `unrecognized_keys`). - -**What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing -`superRefine` on `FlowSchema` gains a pass over the top-level `nodes[]`, the -same shape as the `edges[]` pass. Each later occurrence of an already-declared -id raises one `custom` issue, anchored at `nodes[N].id` of the *later* node and -naming both positions, so the formatted error points at the node to rename: - -```text -✗ nodes.2.id: Duplicate node id `n` — `nodes[2]` reuses the id already declared by `nodes[1]`; every node id in a flow must be unique. Rename one of them: … -``` - -**What does NOT change:** `nodes[].id` keeps its name, type and describe; the -open node-type vocabulary (ADR-0018), the region rules (`analyzeRegion`) and -every other refusal are untouched; a flow with unique top-level node ids -parses exactly as before. The rule judges the flow's **own top-level** -`nodes[]` only — a region body's nodes remain `analyzeRegion`'s to judge, and -whether a region node may reuse a top-level node id (one id space or two) is a -separate decision (#16134) this change neither takes nor pre-empts. - -The shape that is refused, and what the author does about it — a four-node -excerpt, the later node renamed and its edge re-pointed: - -```ts -// before — parsed on green, two nodes keyed 'n' -nodes: [ - { id: 'start', type: 'start', label: 'Start' }, - { id: 'n', type: 'assignment', label: 'Assign A' }, - { id: 'n', type: 'assignment', label: 'Assign B' }, - { id: 'end', type: 'end', label: 'End' }, -] - -// after — refused at parse (nodes.2.id: Duplicate node id `n` …); rename the later one -// and point the edges that meant it at the new id: -nodes: [ - { id: 'start', type: 'start', label: 'Start' }, - { id: 'n', type: 'assignment', label: 'Assign A' }, - { id: 'n2', type: 'assignment', label: 'Assign B' }, - { id: 'end', type: 'end', label: 'End' }, -] -``` - -**Remedy.** Rename the later node to an id no other top-level node in that -flow carries, then re-point at the new id the edges whose `source` / `target` -meant that node; nothing else in the flow needs to move. The census over this -repository found no flow to migrate, so this is a release note, not a -migration: no shipped example, fixture or seed in `packages/**` or -`examples/**` declares a duplicate top-level node id. diff --git a/.changeset/flow-node-list-non-record.md b/.changeset/flow-node-list-non-record.md deleted file mode 100644 index 046f9e1402..0000000000 --- a/.changeset/flow-node-list-non-record.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`validateStackExpressions` no longer throws on a non-record entry of a flow's `nodes` list. - -An empty item in a YAML `nodes:` list deserialises to `null`, so this is an authorable shape — the same one #15552, #15636 and #15742 closed for stack collections and for `objects[].fields`. Here it crashed the linter instead of producing a finding: `flow.nodes: [null, ...]` threw `Cannot read properties of null (reading 'type')`, which presents to an author as a broken tool rather than as a problem with their metadata. - -Both of the file's inline casts now read through `recordsOf`, the one home of this coercion, instead of asserting that `Array.isArray` proves anything about a list's MEMBERS: - -- The flow walk reads `flow.nodes` through `recordsOf`, and — the half that actually removes the crash — hands that coerced array to `collectFlowGraphs` rather than the raw flow. `collectFlowGraphs` declares its input as already-parsed `FlowNodeParsed[]` and is transparent about members, so passing raw authored metadata was calling it out of contract; coercing only the local variable relocated the throw into `@objectstack/spec` instead of ending it. The producer's contract is unchanged, deliberately: widening it to tolerate malformed members is the wrong direction. -- The per-graph walk reads `graph.nodes` through `recordsOf` in place of an `as unknown as` double cast. A nested region's node list is only `Array.isArray`-checked before it becomes a graph, so that list carries the producer's word about its members and not a check. - -A non-record member is dropped whole and in silence, exactly as the file's sibling field readers already did; a flow standing beside the junk entry is still judged, and a `nodes` list holding a plain string still reports exactly what it reported before. diff --git a/.changeset/flow-one-node-id-space.md b/.changeset/flow-one-node-id-space.md deleted file mode 100644 index d02eb9369e..0000000000 --- a/.changeset/flow-one-node-id-space.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FlowSchema` refuses a region node whose id is already declared elsewhere in the flow — one node-id space across the top-level `nodes[]` and every region body (#16134) - - - -**BREAKING** accept-set narrowing on `FlowSchema` — a flow has **one node-id -space**. A node inside an ADR-0031 region body (`loop.config.body`, -`try_catch.config.try` / `.catch`, each `parallel.config.branches[]`, nested to -any depth the parse walks — up to `MAX_REGION_DEPTH` = 32 levels) whose `id` is -already declared by a top-level node, or by a node in -any other region of the same flow, is now **refused at parse time** — by -`FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a -flow through the schema (`objectstack validate`, the runtime publish gate, a -stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under -the repo's launch-window convention for breaking changes. Maintainer ruling -(director seat, decision batch #61, 2026-09-07, 「同意」): ADR-0031's -"self-contained single-entry / single-exit sub-graph" describes control flow and -variable scope, not id reuse; every reader that flattens a flow may key on the -bare id. The ADR gains one sentence saying so in this same change. - -Before this change uniqueness was enforced **inside** each array — the -top-level `nodes[]` by `FlowSchema` (#15713) and each region body by -`analyzeRegion` at `registerFlow()` — and never **across** them: a loop-body -node could carry the same `id` as a top-level node, or as a node in a sibling -branch, and both rules stayed green. Every edge's `source` / `target` names a -node by id, and the designer canvas, the BPMN export, a flow diff and a -checkpoint's `completedNodeIds` all key on the bare id, so such a collision was -silently wrong wherever a flow is flattened. - -**What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing -`superRefine` pass over `nodes[]` now walks every graph the parse reaches via -`collectFlowGraphs` — the top-level graph first, then each region in document -order, depth first, down to `MAX_REGION_DEPTH` (32) — keeping one map of first -declarations. A later occurrence -raises the same single `custom` issue as before, anchored at the later node's -own `id` (inside the region, e.g. `nodes.1.config.body.nodes.0.id`) and naming -both locations — a top-level index (`nodes[1]`) or a region path -(`loop 'sweep' body → nodes[0]`): - -```text -✗ nodes.1.config.body.nodes.0.id: Duplicate node id `start` — `loop 'n' body → nodes[0]` reuses the id already declared by `nodes[0]`; every node id in a flow must be unique. Rename one of them: … -``` - -One refusal, one message shape, at every depth the parse walks: within -`MAX_REGION_DEPTH` an author never sees two issues for one collision. A region -nested beyond that ceiling is left raw by the parse and stays -`validateControlFlow`'s, in its own line — there `analyzeRegion`'s -`duplicate node id 'X'` is the only refusal of a within-region duplicate (a -cross-region collision past the ceiling is not judged), and the same line -guards `bpmn-mapping`'s raw-region caller, so it is kept on purpose. -`collectFlowGraphs` gains a `path` field beside `scope` — the same location as -a key path — so the issue can be anchored where the author wrote the node; it -also now skips a non-object element in a region its own schema refused (such a -region is left raw for `validateControlFlow` to name), where it used to throw a -`TypeError` from inside that validator. - -**What does NOT change:** `nodes[].id` keeps its name, type and describe; the -open node-type vocabulary (ADR-0018), the region rules (edge integrity, -single-entry / single-exit, acyclicity) and every other refusal are untouched; -a flow whose node ids are unique across the whole flow parses exactly as -before, region nodes included, in authored order. - -The shape that is refused, and what the author does about it — the region node -renamed, and any region edge that meant it re-pointed: - -```ts -// before — parsed on green, `start` declared twice (top level + loop body) -nodes: [ - { id: 'start', type: 'start', label: 'Start' }, - { id: 'sweep', type: 'loop', label: 'Sweep', config: { collection: '{items}', body: { - nodes: [{ id: 'start', type: 'assignment', label: 'First step' }], - } } }, - { id: 'end', type: 'end', label: 'End' }, -] - -// after — refused at parse (nodes.1.config.body.nodes.0.id: Duplicate node id `start` …); -// rename the region node and point the region's edges that meant it at the new id: -nodes: [ - { id: 'start', type: 'start', label: 'Start' }, - { id: 'sweep', type: 'loop', label: 'Sweep', config: { collection: '{items}', body: { - nodes: [{ id: 'sweep_first', type: 'assignment', label: 'First step' }], - } } }, - { id: 'end', type: 'end', label: 'End' }, -] -``` - -**Remedy.** Rename the later node to an id nothing else in that flow carries — -no top-level node, no node in any region — then re-point at the new id the -edges whose `source` / `target` meant it; nothing else in the flow needs to -move. The census over this repository found no flow to migrate, so this is a -release note, not a migration: no shipped example, fixture or seed in -`packages/**` or `examples/**` declares a region node id that collides with a -top-level or another region's node id. diff --git a/.changeset/flow-record-decoupled-from-batch-payload.md b/.changeset/flow-record-decoupled-from-batch-payload.md deleted file mode 100644 index c22cd1cfe5..0000000000 --- a/.changeset/flow-record-decoupled-from-batch-payload.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/trigger-record-change": patch ---- - -fix(trigger-record-change)!: the record handed to a record-change flow no longer aliases the write's payload (#14744) - - - -**BREAKING** for a flow whose `script` node mutates a NESTED value of the -triggering record IN PLACE: that mutation no longer affects the write the flow -was triggered by. Shipped as `patch` — this change moves no public surface (no -exported symbol, no accepted key or value), and under the maintainer's -2026-09-04 rule (decision batch #35, on #15294) a `fix(` that changes no public -surface stays `patch`, with breaking-ness carried by this banner and the -ADR-0087 disposition rather than by the level. Maintainer ruling 2026-09-04 on -#14744 (decision batch #38, verbatim 「同意」), adopting option A. - -**Why.** `buildContext` builds the flow's `record` as a shallow overlay of the -pre-image, the mutation payload and the after-row. The top-level object was -new, so a flow ASSIGNING a top-level key reached nothing — but every nested -value in it was the engine's own object, shared by reference. One of those is -`ctx.input.data`, and on a `multi: true` update ADR-0058 Addendum II D3 hands -every per-row context that same payload object, which is the SET clause of the -single `updateMany`. A registered function doing `record.tags.push(...)` -therefore wrote the SET clause without assigning any key: every dispatch's -contribution landed on EVERY matched row, including values derived from another -row's pre-image, and #14099's key-set refusal could not see it because no key -was assigned. Measured end to end on the memory driver and on -`@objectstack/driver-sql` (#15356). - -**What changes.** Both flow-facing roots — `record` (and the `params` alias of -it) and `previous` — are decoupled from the engine's state before the flow -runs. Arrays, plain objects, `Date`, `RegExp`, `Map` and `Set` are copied; -primitives, functions and other class instances are shared, which is the -documented and pinned boundary. A flow still mutates its roots freely and still -observes its own writes for the rest of the run; those writes simply reach -nothing outside it. `previous` is decoupled in the same stroke because it is the -engine's single pre-image object and the same hook context reaches every other -flow bound to the same write. - -**What does NOT change.** The engine's write shape. ADR-0058 Addendum II D3 -stands untouched: one payload still serves N rows and every per-row context is -still handed that one object. #14099's key-set refusal is untouched and is not -widened — a hook that assigns the same key with per-row values still passes it, -and divergent key sets are still refused whole. Flow metadata with no registered -function reached nothing before this change and reaches nothing after it: -assignment nodes write the run's variable map, and `update_record` issues its own -by-id write. Lookup expansion (`config.expand`) still grafts onto the record the -flow holds. - -**Consumer note.** A flow that relied on an in-place nested mutation to persist -— which on a by-id write did persist, and on a `multi: true` write corrupted -every other matched row — writes the record with the `update_record` node -instead. That node is the supported per-row write and is unaffected by this -change. diff --git a/.changeset/flow-template-leaves-outside-filters.md b/.changeset/flow-template-leaves-outside-filters.md deleted file mode 100644 index 2c654af03e..0000000000 --- a/.changeset/flow-template-leaves-outside-filters.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`flow-template-unknown-field` and `flow-template-lookup-traversal` now reach a `{record.}` template that sits outside a node filter — the `warning` half both rules already declared, and never emitted. - -A `{record.}` token in a filter has always been reported as an `error`: an unresolved token there erases the condition and the CRUD node refuses to run. A token anywhere else — a message body, an http request payload, a created row's field values — is the quiet failure the rules were written for: it renders as an empty string on every run, and nothing reports it at either end — no build-time finding, no run-time error — so a hand-off payload naming a renamed field ships an empty value and the run is recorded as a success. That half was silent. - -The cause was one key, in the shared flow walk rather than in either rule. A rule that scans a node's config recursively has to read a view of it with the nested regions removed, or it reports every finding inside a `loop` / `try_catch` / `parallel` a second time against the container. That view was built by removing every key that holds a region on *any* node type — and `body` is `loop`'s region slot **and** the canonical request-payload key on an `http` node. So `config.body` was deleted from every node's view before any rule read it, and the whole of an http payload was invisible. The view now removes only the slots the node's own type declares, which is exactly the set the walk descended into: nothing is double-reported, and nothing that was never a region is dropped. - -Expect new `warning` findings on flows that publish clean today. Each one names a token that renders empty at run time; `warning` does not change `os validate`'s exit code, so a build that passed still passes. diff --git a/.changeset/gantt-tree-config-close-passthrough.md b/.changeset/gantt-tree-config-close-passthrough.md deleted file mode 100644 index c1305ed428..0000000000 --- a/.changeset/gantt-tree-config-close-passthrough.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `GanttConfigSchema` / `TreeConfigSchema` refuse undeclared keys — both `.passthrough()` windows are closed and the ten gantt members plugin-gantt read through the window are declared (#15469) - - - -**BREAKING** accept-set narrowing on two published authorable config blocks — -`ListView.gantt` (`GanttConfigSchema`) and `ListView.tree` (`TreeConfigSchema`) -in `@objectstack/spec/ui`, reached through every view door (`defineView`, -`objects[].listViews`, the `view` metadata type): an UNDECLARED key inside -either block is now **refused** at parse with the `strictObject` named error -(`unrecognized_keys`; surface named, key echoed, closest declared key -suggested), where it used to pass through silently. Shipped as `minor` under -the repo's launch-window convention for breaking changes. Maintainer ruling -2026-09-05 on #15469 (director decision batch #41 item 2, verbatim 「同意」): -option A for both sites. - -Both blocks were `strictObject(…).passthrough()` — the campaign's own helper -applied and immediately undone, so `colourField` on a gantt block parsed green -and rendered an uncoloured bar while the same typo on a calendar or timeline -block got a named refusal. One `strictObject` applied and then undone is two -contracts on one surface (Prime Directive #12); the renderer-ahead window it -kept open is shut, and a renderer knob is declared in the spec before it is -read. - -**Newly declared on `GanttConfigSchema`** — all optional, types measured from -objectui's `GanttConfigExtensionFields` (`@object-ui/types/zod`) at pin -`a472b07`, each with a describe saying what plugin-gantt does with it: - -- `borderColorField: string` — field carrying a per-task alert stroke color -- `lockField: string` — field marking a row view-only (truthy = locked) -- `objectField: string` — field carrying the row's own object API name (mixed-object trees) -- `summaryExtent: 'children' | 'self'` — how a summary bar's span is computed -- `defaultCollapsedDepth: integer ≥ 0` — auto-collapse nodes at or below this depth -- `dependencyTypes: boolean` — whether the store persists dependency link types -- `timeZone: string` — IANA business time zone the calendar renders in -- `exportFileName: string` — base name for exported PNG / PDF files -- `interactions: { move?, resize?, progress?, link? : boolean }` — per-interaction switches (closed sub-object) -- `timeSegments: { dayStart?: string, bands: [{ key?, label, start, end, color? }], showMidnight?: boolean }` — shift segmentation for the day-mode timeline (closed sub-objects) - -**`TreeConfigSchema` declares nothing new.** plugin-tree's `getTreeConfig` -(objectui `a472b07`) reads exactly the four keys already declared — -`parentField`, `labelField`, `fields`, `defaultExpandedDepth` — from the `tree` -block, so the close refuses only what no renderer ever read. - -**Who is affected (measured, objectstack `f7db8f4fd`):** zero gantt or tree -blocks under `examples/**`, `content/docs/**`, `skills/**` or any package -fixture author one of the ten keys or any undeclared key; objectui's own gantt -fixtures author the ten and keep parsing because the keys are now declared. A -block carrying a key outside the declared set — a misspelling such as -`colourField`, or a renderer knob authored ahead of its declaration — is refused -on upgrade with the key named; fix the spelling, or declare the knob in the spec -first. diff --git a/.changeset/generate-refuses-unparseable-emission.md b/.changeset/generate-refuses-unparseable-emission.md deleted file mode 100644 index a9cd48c1f3..0000000000 --- a/.changeset/generate-refuses-unparseable-emission.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os generate ` no longer exits 0 after writing TypeScript the compiler cannot parse. - -The command ran no name validation of any kind — no `validateProjectName`, no sanitiser — so the name went into a binding position untouched. `os generate object foo.bar` reported success and left two broken files behind: `const foo.bar: Data.ServiceObject = {` in `src/objects/foo.bar.object.ts`, and a matching `export { default as foo.bar } from './foo.bar.object';` appended to the barrel `src/objects/index.ts`. The author learned about it at the next `tsc`, in a file the scaffolder had just told them it created. - -Both emissions are now rendered once, at the single point where the derived identifier is finished, and handed to TypeScript's own parser before anything is written. If either does not parse, the command prints the compiler's own diagnostics for each affected file and exits 1 without touching the filesystem — including under `--dry-run`, where a preview of un-parseable output under exit 0 is the same defect in preview form. One check covers all 14 emission sites across all 7 generators (`object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`) plus the barrel, and a generator added later inherits it. - -- **The criterion is parseability, not a charset.** Nothing is rewritten and no name that already produced parseable output is refused: the accepted set moves only by the names whose emission was already broken. Which names `os generate` should accept — and whether it should normalise the ones it does, the way `os create` derives its identifier — is a separate, open decision. Deriving a legal-looking identifier from a name that should have been refused is the worse of the two failures, so this refuses loudly rather than answering that question by widening tolerance. -- **Asking the compiler is what makes the check correct per emission position.** A rule about identifier characters, or about reserved words, gets this wrong in both directions: `os generate object class` is refused (`const class:` is not a declaration) while `os generate view class` is accepted (that generator emits `const classViews:`), and a name carrying a quote or a comment terminator breaks the emitted file without touching the identifier at all. diff --git a/.changeset/generated-migration-audit-stamp-timestamptz.md b/.changeset/generated-migration-audit-stamp-timestamptz.md deleted file mode 100644 index 8cb6de3857..0000000000 --- a/.changeset/generated-migration-audit-stamp-timestamptz.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os generate migration --format sql` gives every timestamp column the time zone the platform actually stores. - -The SQL format spelled its two audit-stamp columns — and every declared `datetime` field — as bare `TIMESTAMP`. In PostgreSQL that is `timestamp WITHOUT time zone`, while both of the other producers of the same columns yield `timestamptz`. This implements **[ADR-0053](../docs/adr/0053-date-and-datetime-semantics.md) D-B4** (accepted), which governs both sites in one sentence: `Field.datetime` maps to `DATETIME(3)` on MySQL while "Postgres deliberately keeps `timestamptz`", and "the builtin `created_at`/`updated_at` take the same type — the registry declares them `Field.datetime`". So the declared-field row and the audit-stamp rows are one decision rather than two judgement calls, and `driver-sql`'s `createAuditTimestampColumn`, its `createColumn` `datetime` arm and this CLI's TypeScript migration format were already implementing it — the SQL format was the one producer that was not. - -Driven, not compiled: all three producers were run against a live PostgreSQL 16.13 and their columns read back out of `information_schema.columns`. Only the SQL format came back zone-naive, and the consequence is a data defect rather than a cosmetic type difference. A zone-naive column stores the wall clock of whatever session wrote the row and keeps nothing to recover the offset from, and `DEFAULT now()` is folded into that session's `TimeZone` on the way in. Two defaulted rows inserted **six milliseconds apart**, one under `TimeZone='UTC'` and one under `Asia/Tokyo`, were recorded **nine hours apart** in the generated table and 3 ms apart in the driver's own: - -``` -sqlgen (timestamp) a_utc 2026-09-05 22:31:28.309421 -sqlgen (timestamp) b_tokyo 2026-09-06 07:31:28.315458 <- +9h, same instant -tsgen (timestamptz) a_utc 2026-09-05 22:31:28.31332+00 -tsgen (timestamptz) b_tokyo 2026-09-05 22:31:28.316401+00 -``` - -The whole temporal class was enumerated in that same run and `datetime` is its only divergent member: `date` is `DATE` and `time` is `TIME` on all three producers, so neither moves. - -Two things this deliberately does not change. The audit columns' **nullability** stays as it is: the driver leaves both nullable and both generators say `NOT NULL`, nothing fails either way, and the driver's own audit DDL is dialect-branched in a way a Postgres-flavoured generated migration does not reproduce — so which side moves is a ruling, recorded in `generate-builtin-id-column.pin.test.ts` and still open. The `DEFAULT now()` spelling stays too: it is the same instant as the driver's `CURRENT_TIMESTAMP` (both are `transaction_timestamp()`) and only reads differently in the catalog. - -Scope for an existing project: already-generated migration files are checked-in artifacts and are not rewritten, and no deployed column is altered — a table created from an older generated migration keeps `timestamp without time zone` until its owner migrates it. What changes is what the next generated migration says. diff --git a/.changeset/generated-migration-character-column-widths.md b/.changeset/generated-migration-character-column-widths.md deleted file mode 100644 index b8c3f686d8..0000000000 --- a/.changeset/generated-migration-character-column-widths.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os generate migration` now emits the character column `driver-sql` actually creates, in both the TypeScript and the SQL format. - -A `text` field took `VARCHAR(255)` from both generators while the platform creates an unbounded `text` column for it, so a 300-character value the platform stores was refused by every generated table with `value too long for type character varying(255)`. Enumerating the whole character-column family found the same disagreement in eight more places: the SQL format gave `url` and `phone` and `color` widths nothing on the platform has (2048, 50 and 7 against the platform's 255), and neither format read a field's declared `maxLength` at all, so a `maxLength: 400` email was `varchar(400)` on the platform and `varchar(255)` in the migration generated for it. - -All of them now follow the platform's own three answers: the text family is unbounded unless the object KEYS the column — a field declared `unique`, or one an object-level `indexes[]` entry lists, takes `varchar(maxLength)` up to the 768-character key-part ceiling, exactly as the platform builds it, and stays unbounded above that ceiling or with no declared bound, where the declared bound is enforced at the write seam instead — the string family takes its declared `maxLength` verbatim in both directions, and TEXT rather than a clamp when it exceeds what a `varchar` can express, and the remaining string-valued types keep the default width and ignore a declaration, because their stored value is an option code or another row's id rather than the declared string. - -The keyed half was measured after the rest: `{ type: 'text', unique: true, maxLength: 100 }` built `varchar(100)` on the platform and `text` in both generated tables, so a 300-character value the platform REFUSES was accepted by every generated table — the same disagreement as the headline row, pointing the other way. - -This scopes to PostgreSQL, which is the only dialect `os generate migration --format sql` claims. diff --git a/.changeset/generated-migration-id-column-shape.md b/.changeset/generated-migration-id-column-shape.md deleted file mode 100644 index 2d557f03d6..0000000000 --- a/.changeset/generated-migration-id-column-shape.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os generate migration` gives a table's own `id` column the shape the platform actually creates. - -Both migration generators hardcoded the primary key as a UUID — `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()` in the SQL format, `table.uuid('id').primary().defaultTo(db.fn.uuid())` in the TypeScript one (the default format). The platform's SQL driver emits `table.string('id').primary()`, which is knex's `varchar(255)`. A platform id is a string, not a uuid, so on Postgres the generated table refused the platform's very first insert with `22P02 invalid input syntax for type uuid`. - -The quieter half is the `DEFAULT`, and it is why this was worth correcting rather than working around. The driver emits no database-side default at all — its insert path always supplies the id itself — so `gen_random_uuid()` never fired for a platform write, only for an out-of-band one, handing that row a 36-character uuid this platform's id generator would never mint. One table would then hold two incompatible id shapes, with nothing said. - -Both generators now emit the driver's own answer: `"id" VARCHAR(255) PRIMARY KEY` and `table.string('id').primary()`. The correction also closes a contradiction inside the generator file, whose prose already stated that a reference column takes the width of the target's `id` column *because* the driver emits `table.string('id').primary()` — a few hundred lines above the two lines that emitted `uuid`. - -`generate-builtin-id-column.pin.test.ts` reads the width from the driver's own `DEFAULT_STRING_VARCHAR_CHARS` rather than transcribing `255`, so the generators cannot drift away from the driver again without a named failure. diff --git a/.changeset/getmetaitems-organization-id-describe-precision.md b/.changeset/getmetaitems-organization-id-describe-precision.md deleted file mode 100644 index e0c3bdf245..0000000000 --- a/.changeset/getmetaitems-organization-id-describe-precision.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`GetMetaItemsRequestSchema.organizationId` no longer documents itself as always consulted. - -The published `describe()` opened with "Selects the org partition in the ADR-0005 overlay read order" and closed with "Absent = environment-wide read: only env-level overlays apply and no org partition is consulted." Stating only the absent case invites the converse, and an integrator reading it completes it as *present ⇒ consulted* — so a caller who supplies an organization believes it has scoped a read that can in fact be environment-wide. A supplied organization is not consulted on every `getMetaItems` read. - -The corrected text qualifies the promise instead of implying its converse: the parameter selects the org partition **when an org partition applies**, and supplying a value "does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide and only env-level overlays apply." - -Prose only. No key is added, removed or renamed, no export moves, no accept set changes and no runtime behaviour changes — the schema's shape and validation are byte-for-byte what they were. What ships is the JSON-Schema `description` for the existing `organizationId` key and the matching row in the generated API reference, which is why this is user-visible enough to owe an entry and narrow enough to be a patch. - -The three sibling `organizationId` describes on `GetMetaItemRequestSchema`, `GetMetaItemLayeredRequestSchema` and `GetMetaItemCachedRequestSchema` are deliberately left alone. diff --git a/.changeset/great-clouds-repair.md b/.changeset/great-clouds-repair.md deleted file mode 100644 index 082e4063dd..0000000000 --- a/.changeset/great-clouds-repair.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -`GET /auth/me/localization` answers the deployment's resolved `currency` and `timezone` instead of `null` - -The handler read both off the request `ExecutionContext`, citing ADR-0053, but the resolver serving this surface is a hand-rolled envelope that never carried them — so every authenticated caller was answered `currency: null, timezone: null` whatever the `localization` settings said, and the console's regional-formatting seed was fed nulls. All three values now come from one reading of the same `resolveLocalizationContext` cascade the dispatcher's shared assembler uses. `locale` resolution is unchanged. `timezone` now always answers (cascade floor `UTC`); `currency` still answers `null` when the deployment configures none — that value has no floor. diff --git a/.changeset/hash-shadow-null-safe-duplicate-group-tail.md b/.changeset/hash-shadow-null-safe-duplicate-group-tail.md deleted file mode 100644 index 11c0874d87..0000000000 --- a/.changeset/hash-shadow-null-safe-duplicate-group-tail.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -The hash-shadow NULL-safe durability log now counts its overflow duplicate groups in the same words as the other three reports that render the same rows (#16289) - -`formatDuplicateGroups` is module-local in `sql-driver.ts` for one stated reason, quoted from its own docblock: the sites that report a blocked unique "must name the SAME rows in the SAME shape, and a second hand-rolled `.slice(0, 5).join('; ')` is exactly how the plain and the NULL-safe path drifted apart in the first place". Four sites render duplicate groups — the drift entry, the direct arm's plain-unique log, the hash-shadow arm's plain-unique log, and the hash-shadow arm's NULL-safe branch — and the fourth still hand-rolled that exact shape. - -So the drift the helper exists to prevent had already recurred, in the overflow tail: the helper writes `; …and N more group(s)`, the hand-rolled copy wrote `; …and N more`. Two durability logs about the same failure class, emitted from the same `catch`, disagreed on how they say "there are more". - -What an operator sees: when a hash-shadow NULL-safe unique index is blocked by more than five conflicting groups in one table, the boot-time durability line now ends `; …and N more group(s).` instead of `; …and N more.`. The surrounding ` Conflicting group(s): ….` framing, the five groups rendered in full, their `(key) × N rows` spelling and their order are unchanged, and so is every other line. No behaviour, no data effect, no API movement — the five-then-count rendering is now owned in one place for all four sites. diff --git a/.changeset/history-cleanup-failing-run-is-not-silent.md b/.changeset/history-cleanup-failing-run-is-not-silent.md deleted file mode 100644 index 15d38ecd95..0000000000 --- a/.changeset/history-cleanup-failing-run-is-not-silent.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/metadata': patch ---- - -fix(metadata): a `HistoryCleanupManager` run that loses deletes now says so, at both of `start()`'s triggers - -A failing history cleanup was completely silent. Three things composed: every inner `catch` on the delete path is a bare `catch {`, so the error object is discarded; the only `console.error` in `runCleanup()` sits in its OUTER catch, which those inner catches prevent execution from reaching; and `start()` invoked the run as `void this.runCleanup()`, throwing away the `{ deleted, errors }` the run returns — at BOTH call sites, the immediate run and every interval tick. A driver whose deletes failed on every scheduled run therefore produced zero output and no reachable error count, while the history table grew past its retention policy with nothing to find. - -The repair reads the envelope instead of replacing it. `runCleanup()`'s contract, its inner catches and its counting are unchanged: reporting a failure to the CALLER is the third answer AGENTS.md → "Degradation log levels" allows a durability seam, and that same section names a log per failed write as the mirror-image failure. What was missing was a reader — `start()` is where the chain ends, since it returns `void` and an interval tick has no caller at all. Both call sites now go through one shared pass that reads the returned counts and, when a run lost deletes, prints one `error` line naming the consequence (rows past the retention policy are still in the table, nothing retries them, and the system keeps reporting healthy) and where to look. A run that loses nothing stays quiet, and a direct caller of `runCleanup()` sees exactly the same `{ deleted, errors }` as before. diff --git a/.changeset/history-cleanup-utc-retention-cutoff.md b/.changeset/history-cleanup-utc-retention-cutoff.md deleted file mode 100644 index d44ffc6be2..0000000000 --- a/.changeset/history-cleanup-utc-retention-cutoff.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/metadata": patch ---- - -`HistoryCleanupManager` computes its retention cutoff on one calendar, not two. - -Both call sites — the age-based delete in `runCleanup()` and the preview count in `getCleanupStats()` — built the cutoff with `cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays)` and then rendered it with `toISOString()`. `setDate`/`getDate` read and write the **local** calendar; `toISOString()` renders **UTC**. They now use `setUTCDate`/`getUTCDate`, so the arithmetic and the rendering agree. - -`setDate` preserves wall-clock time, so shifting the local calendar back `n` days moves the *instant* by exactly `n × 24h` only while every local day in the window is 24 hours long. When the window straddles a DST transition it is 23 hours (spring-forward) or 25 (fall-back), and the cutoff instant that goes into the `recorded_at: { $lt: … }` **delete** filter is off by the size of that transition — one hour in most zones, thirty minutes on Lord Howe Island. History rows within that slip of the retention boundary were deleted early, or retained too long. - -The exposure is not limited to the two transition days: the window only has to *straddle* a transition, so it grows with `maxAgeDays`. Measured over a 12-zone × 366-day × 48-half-hour sweep of 2026, in `America/New_York` the old spelling produced a wrong cutoff for 0.6% of instants at `maxAgeDays: 1`, 16.4% at 30, 49.7% at 90 and 69.4% at 180. In zones that do not observe DST (`UTC`, `Asia/Shanghai`, `Asia/Kolkata`, `Australia/Perth`) the rate is 0.0% at every `maxAgeDays` — which is why no test had ever gone red on this. - -This does **not** make retention timezone-aware, and does not change what `maxAgeDays` means. The cutoff was already intended to be `now − maxAgeDays × 24h`; it is now that in every zone rather than only in zones without DST. Nothing else in either filter moved: the `organization_id` scoping, the ADR-0009 `executionPinned` exclusion and the `maxVersions` path are untouched. diff --git a/.changeset/history-door-schema-rebind.md b/.changeset/history-door-schema-rebind.md deleted file mode 100644 index 69e67b2eb1..0000000000 --- a/.changeset/history-door-schema-rebind.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/client': minor -'@objectstack/rest': patch ---- - -`client.meta.getHistory` answers the published `HistoryMetaItemResponse` on **both** of its exits, and the route ledger names the schema. - -**BREAKING (types):** the unscoped `client.meta.getHistory` declared a hand-written inline shape whose `actor` member was `string`. The door answers `null` there for every system-initiated write — boot sync, migration, a scheduled job — and the published schema declares it "never a sentinel string", so consumers that resolve the actor against `sys_user` must be able to tell "nobody" from "a user id". Reading `actor` without a null check compiled against a promise the door has never made; it no longer compiles. The same rebind closes the vocabulary of `op` (the ADR-0008 §2.4 change-log verbs, previously a plain `string`). - -Three members the inline shape omitted become reachable in the same move: `version` (the per-`(org,type,name)` lineage counter that `rollbackItem({ toVersion })` pins against), `previousName` (set on `op: "rename"`), and `ref.version`. `ref.org` was declared optional and is now what the producer always writes. - -The scoped twin — `client.environments.use(id).meta.getHistory` — carried no declaration at all: no return annotation, and the SDK's internal unwrap called with no type argument, so the published method resolved to `Promise` and every caller had to narrow by hand against nothing. It is the SAME mount as the unscoped exit, replayed against `/environments/:environmentId`, so it answers a byte-identical body; the two now name one type. Binding only one exit would have relocated that divergence rather than removed it, and the equality of the two declared types is pinned rather than left to review. - -`@objectstack/rest` is `patch`: the route-ledger row for `GET /api/v1/meta/:type/:name/history` now names `HistoryMetaItemResponseSchema`. Data only, in a package-internal module — no route, handler or emitted byte changes. The row could not name the schema before because the declaration (#12005) landed after the row was written. - -No wire byte moves anywhere in this change. `HistoryMetaItemResponseSchema` is a describe-only transcription of what `historyMetaItem` already returned, and the SDK's runtime path is untouched — only what the compiler knows about it. - - diff --git a/.changeset/hono-auth-mount-follows-auth-base-path.md b/.changeset/hono-auth-mount-follows-auth-base-path.md deleted file mode 100644 index 96fd6ab9da..0000000000 --- a/.changeset/hono-auth-mount-follows-auth-base-path.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/hono": minor -"@objectstack/plugin-auth": minor ---- - -`createHonoApp` mounts the auth surface where the auth service actually serves, and refuses a prefix it cannot serve it under. - -The documented embed did not reach better-auth at all. `createHonoApp` mounted `/auth/*` under its own `prefix` (default `/api`) while `AuthPlugin` configures better-auth with `basePath: '/api/v1/auth'`, so the two never intersected. The forwarded request could only 404, that 404 fell through to the terminal dispatcher catch-all, and the caller got a `200` with an empty body. Measured on a real kernel with `AuthPlugin`, driving `createHonoApp({ kernel })` with both defaults untouched: - -``` -POST /api/auth/sign-in/email (valid shape, wrong password) -> 200 {} -GET /api/auth/get-session -> 200 {} -POST /api/auth/sign-up/email -> 200 {} -``` - -A failed sign-in answering `200 {}` is the silent-success shape: a client that reads `res.ok` sends the user into an authenticated view with no session. The same boot now answers, through the same embed: - -``` -POST /api/v1/auth/sign-in/email (wrong password) -> 401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"} -GET /api/v1/auth/get-session -> 200 null -POST /api/v1/auth/delete-user -> 401 {"message":"Unauthorized","code":"UNAUTHORIZED"} -``` - -**Neither default moves.** `prefix` still defaults to `/api` and the auth `basePath` still defaults to `/api/v1/auth`. What changed is which of the two decides the mount: - -- **`@objectstack/hono`** — the `/auth/*` mount is derived from the auth service's configured `basePath`, read at app-construction time, rather than from `prefix`. An auth service that does not expose its base path keeps the previous `${prefix}/auth` mount, so a custom or older auth service is unaffected. -- **`@objectstack/hono`** — a `prefix` the auth base path is not inside now **refuses at construction**, naming both values and every one-line fix that actually constructs: move the app up to the base path's own parent namespace, or configure better-auth down under the prefix (carrying the leading slash the prefix may itself be missing). ⛔ A direction with no working answer is not offered rather than offered wrongly — a single-segment base has no usable parent prefix, because `''` falls back to `/api` and `'/'` mounts every other route of the app under `//`. Previously that composition served auth outside the namespace the host asked for while `${prefix}/auth/*` answered `200 {}`. This is the one behaviour that can stop an app booting: a deployment passing, say, `prefix: '/custom'` alongside the default auth base path was already not serving auth, and now says so instead of failing silently. -- **`@objectstack/plugin-auth`** — `AuthManager.getBasePath()` is new and public: the configured base path in its one normalised spelling (a leading slash added when absent, trailing slashes stripped), which is the spelling an HTTP adapter can mount on. ⛔ **Purely additive — no configured `basePath` changes anything this package does.** better-auth is still handed the configured string verbatim, and the route-ownership walk still normalises its own copy; that copy now reads this accessor instead of repeating the expression. ⛔ It is **not** the string better-auth receives, and it is **not** the single definition of the value. `getAuthIssuer()` and `getMcpResourceUrl()` still derive their own copies and are deliberately unchanged: they are the OAuth `iss` this AS advertises and the RFC 8707 resource identifier a token's `aud` is matched against, both compared by exact string by relying parties, so retiring their copies moves published identifiers and is not a tidy-up that belongs on this card (filed as #16399). Normalising the string handed to better-auth is that same move seen from the other side — it shifts the access-token `iss` off `getAuthIssuer()`, and this manager's own `verifyMcpAccessToken` then rejects every MCP token the deployment mints. Measured on a real `client_credentials` token, and not done. diff --git a/.changeset/hono-auth-mount-owned-404-not-yielded.md b/.changeset/hono-auth-mount-owned-404-not-yielded.md deleted file mode 100644 index cfafa0f524..0000000000 --- a/.changeset/hono-auth-mount-owned-404-not-yielded.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/hono': patch ---- - -The Hono adapter's `/auth/*` mount yields only a 404 that disclaims ownership - -`createHonoApp`'s `${prefix}/auth/*` mount forwards every request under it to the -kernel's `auth` service and, since #4117, hands the request on to the rest of the -chain when that service answers 404 — which is what keeps `/auth/me/permissions` -and `/auth/me/localization` reachable through the gated `dispatch()`. The yield -had only the status to go on, so it could not tell "I do not serve this path" -from "I serve it and the answer is 404". - -Measured on a real boot through this adapter (a real kernel with `AuthPlugin`, -`prefix: '/api/v1'`), `GET /api/v1/auth/delete-user/callback?token=…&callbackURL=…` -answered `404 {"message":"Not found","code":"NOT_FOUND"}` from better-auth and -`200 {}` on the wire. `plugin-auth`'s route ledger carries that route under its -`disabled` disposition precisely because it is published and answers 404, so the -ledger's recorded answer was true of the auth service and false on this adapter's -wire. Nothing had to be composed in for that: the `${prefix}/*` dispatcher -catch-all this same function registers is terminal and answers `200 {}` for paths -under `/auth/`. - -The mount now asks the auth service whether its own router serves the path, via -an optional `ownsRoute(request)` — the seam `AuthManager` grew in the plugin-side -fix for the same defect — and yields only when it does not. Every answer that is -not a literal `true` (no such method, a throw, anything else) means yield, so a -service predating the method behaves exactly as before and a failure to decide -can never cost the ordering-independent surface. - -⛔ The mount is unchanged and still claims `${prefix}/auth/*`; 401/403 were never -yielded and still are not. What narrowed is only which 404 may be handed on. diff --git a/.changeset/hono-me-localization-user-locale.md b/.changeset/hono-me-localization-user-locale.md deleted file mode 100644 index 21d883db39..0000000000 --- a/.changeset/hono-me-localization-user-locale.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/plugin-hono-server": minor ---- - -feat(hono-server): `GET /auth/me/localization` → `locale` is now the signed-in user's language — `sys_user.locale` when set, then the request's `Accept-Language`, then the deployment default (#14788) - -Maintainer ruling 2026-09-03 (option D on #14788): this endpoint is the ONE -read face for "what language is this user", now that `sys_user.locale` is a -user-stated preference (#13881 / #14787) and the never-produced -`SessionUser.language` is retired from the session contract -(`@objectstack/spec`, same release). - -What changed, for an authenticated caller: - -- `locale` resolves **the user's own `sys_user.locale`** first — read under a - system context by the caller's own id and accepted only when it passes the - column's OWN `locale_bcp47_shape` rule as the registry declares it (the - endpoint evaluates that rule; it carries no second locale parser). A - malformed, blank or unverifiable value falls through, it is never served. -- then **the request's `Accept-Language`** preference (`preferredLocaleFromHeader`, - the same parse REST and the runtime dispatcher feed `execCtx.locale` from); -- then **the deployment default** (`resolveLocalizationContext` — the - `localization.locale` settings cascade, floor `en-US`). - -Before, the resolver behind this endpoint assembled no localization at all, so -`locale` was `null` for every authenticated caller; it is now always a string -for an authenticated caller. The response shape is unchanged -(`{ authenticated, currency, locale, timezone }`), `currency` / `timezone` -are untouched, and the unauthenticated answer (`{ authenticated: false }`) is -unchanged. `resolveSignedInUserLocale` is exported for hosts that compose the -current-user endpoints directly. diff --git a/.changeset/hono-toresponse-preserve-response.md b/.changeset/hono-toresponse-preserve-response.md deleted file mode 100644 index 9e4b913b48..0000000000 --- a/.changeset/hono-toresponse-preserve-response.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/hono": patch ---- - -`createHonoApp` no longer discards the status and body of a dispatcher result that is already a `Response` — it hands the object on unchanged. - -`HttpDispatcherResult.result` is declared for direct response objects ("For flexible return types or direct response objects (Response/NextResponse)"), and the runtime really puts one there: the `/auth` domain returns whatever the auth service answered as `{ handled: true, result: response }`. The adapter's `toResponse` had no arm for that. It tested `result.type` for the `redirect` and `stream` descriptors, a `Response` spells neither, and the fall-through was `c.json(res, 200)` — so the real status was replaced by a literal `200` and the real body by `JSON.stringify` of a `Response`, which is `{}` because a `Response` has no own enumerable properties. - -Measured on a real boot through this adapter (a real kernel, the real dispatcher, `prefix: '/api/v1'`), an auth service answering an honest 404 on a path it does not serve: - -``` -GET /api/v1/auth/me/permissions - the door answered : 404 {"message":"Not found","code":"NOT_FOUND"} - the caller read : 200 {} -``` - -A discarded status is not a missing answer, it is a wrong one that reads as success: `res.ok`, `status === 200` and "nothing threw" all report a refusal, a 404 or a 500 as a completed operation, and a fail-closed guard written as `if (!data) return false` does not fire on `{}` because `{}` is truthy. Callers embedding this adapter now see the status and the body the door actually produced, along with its headers, and a non-JSON body arrives byte-identical instead of being re-serialized. - -The check is `instanceof Response` and nothing else: the `redirect` and `stream` descriptor arms, the plain-object rendering after them, and the separate `response` arm all behave exactly as before. diff --git a/.changeset/host-importer-aliased-dual-publish-entry.md b/.changeset/host-importer-aliased-dual-publish-entry.md deleted file mode 100644 index 69d7c1c852..0000000000 --- a/.changeset/host-importer-aliased-dual-publish-entry.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/types": patch ---- - -`createHostImporter` now loads the `import` build of an ALIASED dual-published package, instead of silently keeping its `require` build. - -An alias declaration — `{"dependencies": {"foo": "npm:bar@1"}}` — installs a package whose manifest is named `bar` under the key `foo`. On the path where CommonJS resolution SUCCEEDS, the importer re-decides only the CONDITION (it asks the package which entry an `import()` gets, so the caller's ESM chain and this load share one instance). That re-decision recognised the package root by walking up from the resolved entry until it found a manifest named after the DECLARATION KEY — `foo` — while an aliased install's manifest is named `bar`. The walk therefore never matched, the re-decision produced nothing, and the load fell back to whatever the CommonJS resolver had answered: the `require` condition. - -For an aliased dual publish that left the process holding two live copies of one package — the CommonJS build behind the host importer, the `import` build in the caller's own chain — which is exactly the split the condition re-decision exists to remove: a plugin registry, a singleton kernel, a module-level cache, one copy each. - -The expectation now comes from the host's own declaration (`npm:name@range`, aliased `workspace:name@range`), the same reading the ESM-only fallback finder has used since it learned about aliases. Nothing about the check's strictness moves: an alias naming one package still does not license a directory holding another, and a non-aliased declaration is still verified against its key. Declarations that name a LOCATION rather than a package (`link:`, `file:`) carry no name to expect, so they keep today's behaviour unchanged. - -Measured population for the behaviour change: zero aliased declarations exist across this workspace's 875 dependency declarations, and 867 of 867 installed declarations already match their key — no ordinary, non-aliased install reaches this path. diff --git a/.changeset/i18n-coverage-inline-locale-map-authored.md b/.changeset/i18n-coverage-inline-locale-map-authored.md deleted file mode 100644 index 802808905b..0000000000 --- a/.changeset/i18n-coverage-inline-locale-map-authored.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint` / `os i18n check` stop reporting a written inline locale map as an untranslated string. - -`I18nLabelSchema` authorizes two forms of a display label: a plain string, whose translations live in a bundle, and an **inline locale map** — `{ en: 'Members', 'zh-CN': '成员' }` — written out at the authoring site and picked at render time. Rulings on both forms make the map the one localisation route for props that have no bundle key at all, so a page localised that way is fully localised. - -The coverage walk could not see it. `inlineText()` narrowed a map to `undefined` — the same value an **absent** prop produces — so one diagnostic carried two opposite facts, and the gate reported a prop written out in four languages exactly as it reports a prop nobody wrote: - -- with no bundle entry, the key was dropped from the expected set entirely: neither covered nor missing, invisible in the counts; -- with a bundle entry for one locale, the key came back with no inline evidence, and every locale the **map** held and the bundle did not was reported `missing translation` — about text that was right there in the file. - -An entry now carries a third axis beside `sourceValue` and `inline`: `inlineLocales`, the map the author wrote, verbatim. Coverage reads it per locale — a locale the map carries counts as covered, a locale it omits is reported as a gap, and the default locale is satisfied by the map the way it has always been satisfied by an inline string. The read is deliberately narrower than the renderer's: only the tag-matching limbs of the shared `resolveI18nLabel` rule count, because falling back to `en` or to the untagged `default` entry **is** what an untranslated locale looks like. - -Two things this deliberately does not do. The map is still **never extracted**: no bundle row is scaffolded for it, and no key family is added — a translator working from the locale bundle still will not find these strings, which is the cost of the form and is now stated where an author chooses it (`i18n.zod.ts`, and the extractor's own header). And no key is synthesised from a node's position in the page tree: position-addressed keys would turn a reorder of two sibling components into a silent, all-green swap of their translations. If inline maps are ever to be extracted, the recorded direction is identity first — `component.id` / `section.name` / `tabs item.value` made mandatory and gate-enforced, then the existing `pages..components..` family reused. - -Net effect on a project that authors no inline maps: none. On one that does, the gate starts telling the truth in both directions — the false `missing translation` goes, and a map that genuinely omits a locale is reported for the first time. diff --git a/.changeset/i18n-declared-fallback-chain-rest.md b/.changeset/i18n-declared-fallback-chain-rest.md deleted file mode 100644 index fb056b1e3e..0000000000 --- a/.changeset/i18n-declared-fallback-chain-rest.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): metadata label lookup honours the stack's declared `i18n.fallbackLocale` / `defaultLocale` instead of falling through to the `en` bundle (#14882) - -On a workspace whose labels are authored in `zh-CN` (`defaultLocale: 'zh-CN'`, -`fallbackLocale: 'zh-CN'`) and which ships only a courtesy `en` translation bundle, -`GET /api/v1/meta/object/:name`, the `/meta/:type` list, `GET /api/v1/meta` and the -public-form schema served the ENGLISH bundle labels to a `zh-CN` request (`Entry Sheet` -for an authored `填报单`, `KPI Assessment` for `KPI 考核管理`). The document translators walk -`requested locale → fallback chain → authored label` and default the chain to a literal -`['en']`; every REST seam passed none, so the declared fallback never reached the chain -and `en` was consulted before the authored label. - -Every metadata translation seam now passes `fallbackChain: [i18n.getFallbackLocale()]` — -the locale the i18n service's own `t()` falls back to, which `I18nServicePlugin` receives -from the stack config as `fallbackLocale || defaultLocale || 'en'`. For the workspace -above a `zh-CN` request now resolves `zh-CN → zh-CN → authored label` (the authored -Chinese labels), an `en` request still gets the `en` bundle, and a `zh-CN` bundle, when one -is shipped, still wins over the authored label. - -Feature-detected: an i18n service that does not declare a fallback (the method is -optional on `II18nService`; the core in-memory fallback has none) gets no chain and the -resolver's own default applies exactly as before. A stack declaring `defaultLocale: 'zh-CN'` -with `fallbackLocale: 'en'` is likewise unchanged — the declared `en` is honoured as it -reads. diff --git a/.changeset/i18n-declared-fallback-chain-service.md b/.changeset/i18n-declared-fallback-chain-service.md deleted file mode 100644 index 1979908b5f..0000000000 --- a/.changeset/i18n-declared-fallback-chain-service.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-i18n": minor ---- - -feat(service-i18n): `FileI18nAdapter.getFallbackLocale()` reports the `fallbackLocale` the adapter was constructed with (#14882) - -Implements the new optional `II18nService.getFallbackLocale()`. `I18nServicePlugin` -already receives `fallbackLocale || defaultLocale || 'en'` from the stack's `i18n` -config on both boot paths (`os serve`, the dev plugin); this makes that declaration -readable, so the REST metadata reads pass the document translators the same fallback -locale `t()` itself consults. Returns `undefined` when no `fallbackLocale` was given. diff --git a/.changeset/i18n-declared-fallback-chain-spec.md b/.changeset/i18n-declared-fallback-chain-spec.md deleted file mode 100644 index a030769f8b..0000000000 --- a/.changeset/i18n-declared-fallback-chain-spec.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `II18nService.getFallbackLocale()` — the declared fallback locale is readable, so the metadata-document translators can be handed the chain the deployment declared (#14882) - -`ResolveOptions.fallbackChain` on the `@objectstack/spec/system` label -resolvers (`translateMetadataDocument`, `translateObject`, `translateApp`, -`resolveViewLabel`, …) is the ordered list of locales consulted after the -requested one and BEFORE the authored label. Nothing on `II18nService` -exposed the deployment's declared fallback (`i18n.fallbackLocale`, else -`defaultLocale`), so no serving layer could thread it, and every caller fell -to the resolver's literal `['en']` default. A `zh-CN` workspace that shipped a -courtesy `en` bundle therefore served English bundle text to a `zh-CN` -request ahead of its own authored Chinese labels. - -- New optional contract member `II18nService.getFallbackLocale?(): string | undefined` - — the locale the service's own `t()` consults second. `undefined` (or the - method absent) means nothing was declared, and a serving layer must then - leave the resolver's default in place rather than invent a chain. -- The `fallbackChain` documentation now states who supplies it (the serving - layer, from `getFallbackLocale()`) and that the `['en']` default applies - only when a caller declares no chain at all. The resolver's behaviour for - a caller that passes nothing is unchanged. - -Additive: no existing implementation or caller changes shape. diff --git a/.changeset/i18n-extract-check-dry-run-compares.md b/.changeset/i18n-extract-check-dry-run-compares.md deleted file mode 100644 index 204472beac..0000000000 --- a/.changeset/i18n-extract-check-dry-run-compares.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os i18n extract --check --dry-run` now compares the bundles and reports what it found, instead of exiting 0 having compared nothing. - -`--check` and `--dry-run` are both "write nothing" modes, so the pair reads as the safest spelling to put in CI — and it was the one spelling that measured nothing. The `--dry-run` branch returned before the `--check` block was reached, so the same tree that failed `--check` with `Translation bundles have drifted from the schema` reported success as soon as `--dry-run` was added to the command line. A check that cannot fail is indistinguishable from a check that finds nothing: the pipeline went green and nobody learned the bundles had drifted. - -⚠️ **A pipeline running `--check --dry-run` against drifted bundles starts failing on this release, and that is the repair working.** The failure is not new — the drift it names was already there and the old exit code was wrong about it. The fix is the one `--check` has always printed: regenerate the bundles and commit them. Nothing else about the pair changes, and a tree that is in sync still exits 0, now with the `bundle(s) are in sync with the schema` line it never printed under `--dry-run` before. - -- **What each flag contributes is unchanged.** `--dry-run` still prints the rendered modules to stdout, `--check` still compares them against what is committed in `--out`, and neither writes a file — on any path, including a bundle that is present but out of date, which keeps its bytes. -- **The `--out` advice no longer contradicts the command line it is printed on.** `Dry run — no files written (pass --out= to write)` was printed even to runs that had just passed `--out`, which reads as "your directory was ignored" when it had not been. A run with an `--out` now names the directory it did not write to; a run without one still gets the advice. diff --git a/.changeset/i18n-extract-check-hint-echoes-the-invocation.md b/.changeset/i18n-extract-check-hint-echoes-the-invocation.md deleted file mode 100644 index 5087d00749..0000000000 --- a/.changeset/i18n-extract-check-hint-echoes-the-invocation.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os i18n extract --check` now prints the invocation it was given, minus `--check`, as its "Regenerate and commit" hint — instead of a command assembled from four of the flags. - -The hint used to be built at the print site from the config argument, the emitted locales minus the default one, `--fill` and `--out`. Everything else was absent from the expression, so it was absent from the advice. Driven on the reported invocation against a stack whose `i18n.defaultLocale` is `zh-CN`: - -``` -$ os i18n extract objectstack.config.ts --locales=zh-CN --no-metadata-forms \ - --no-objects-only --filter=kpi_ --out=OUT --check - ✗ missing: ../../../../../tmp/os-i18n-repro/zh-CN.objects.generated.ts - ✗ Translation bundles have drifted from the schema. Regenerate and commit: - os i18n extract objectstack.config.ts --locales= --fill=empty --out=OUT -``` - -`--locales=` came out empty because the only locale asked for was the default one, and the echo dropped the default locale on the grounds that `--locales` always re-adds it; `--no-metadata-forms`, `--no-objects-only` and `--filter=kpi_` were never candidates for the line. Running what it printed wrote 775 keys across two files where the operator's own command writes 2 across one — a `metadata-forms` companion they had explicitly switched off, and an unfiltered key set. The next `--check` then failed again, on `out of date:` instead of `missing:`, and printed the same wrong command. A failure that heals itself in one step became a loop, and the loop was the printed advice. - -The hint is now a deletion rather than an assembly: this run's own argv with the `--check` token removed, shell-quoted so it can be copied, `--` honoured so a positional `--check` is left alone. Nothing enumerates flags, so a flag added to this command later is echoed without anyone remembering this print site. When `--check` is not in the argv the command cannot say what it removed, and prints "re-run the same command without `--check`" rather than guessing. - -Diagnostic paths are also no longer walks. `missing:`, `out of date:` and `Wrote` printed a bare path relative to the working directory, which for an `--out` outside the project produced `../../../../../tmp/i18n-out/zh-CN.objects.generated.ts` for a directory the operator had just typed in full. A path the working directory cannot reach downwards is now printed absolute; an in-tree `--out` — what all nine of this repo's extract configs use — keeps the short relative form it has always had. diff --git a/.changeset/i18n-extract-check-json-compares.md b/.changeset/i18n-extract-check-json-compares.md deleted file mode 100644 index 880ec0c6ea..0000000000 --- a/.changeset/i18n-extract-check-json-compares.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os i18n extract --check --json` now COMPARES. It used to exit 0 having compared nothing, on a tree whose bundles had provably drifted. - -The machine face returned before the comparison ran: `if (flags.json) { … return; }` sat ahead of both the `--check` needs-`--out` guard and the comparison block. Driven on one fixture, two invocations differing only by `--json` — the first exited 1 with `missing: OUT/zh-CN.objects.generated.ts` and `Translation bundles have drifted from the schema`, the second exited 0 with the ordinary extract payload. The first run is the second one's positive control: the drift was really there. Same shape as the `--dry-run` branch repaired one release earlier, and `--json` is if anything the more likely CI spelling of the two, because a pipeline that wants to parse the result reaches for it. - -⚠️ **A pipeline that runs `os i18n extract … --check --json` and was green may now go red, and that is this repair working.** The green was a comparison that never happened; the red is the drift that was already in the tree. The fix is the one the failure names — re-run the same command without `--check` **and without `--json`**, then commit what it writes. Neither of those two flags writes files, and the command the failure prints now has both taken out of it. - -What each invocation now does, with no new member on any published payload: - -- **drift found** — the run ends on this command's existing `{ "error": … }` envelope with exit 1, carrying the same sentence the console face prints, the regenerate-and-commit command included. Deliberately not a new `drift` / `missing` / `stale` payload member: every other way this command can fail already speaks that envelope, and naming the drifted files in the machine payload would widen a published output face. -- **in sync** — unchanged: the ordinary extract payload, exit 0. -- **`--check` with no `--out`** — the refusal is now reachable under `--json` too, in the same `{ "error": … }` envelope with exit 1. It used to exit 0 with a payload, having been asked for a comparison it could not make. -- **`--json` without `--check`** — unchanged in every respect. - -The run leaves through exactly one of those faces, so stdout still parses as exactly one JSON document. - -One more thing moved with it: the command a drifted `--check` prints as its remedy now has `--json` taken out of it as well as `--check`. It used to keep `--json`, so the machine face named a command that emits a payload, writes zero files, and leaves the next run failing with the same advice. diff --git a/.changeset/i18n-extract-companion-accompanies-a-module.md b/.changeset/i18n-extract-companion-accompanies-a-module.md deleted file mode 100644 index a7998a45ec..0000000000 --- a/.changeset/i18n-extract-companion-accompanies-a-module.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os i18n extract --source-hashes` no longer writes a provenance companion with no bundle module beside it, and names the sections it commits from the payloads those modules hold instead of from two literals. - -The command narrows the provenance table to "the sections this run commits" before writing `.source-hashes.generated.ts`. The half that decided WHICH modules were emitted already read the emitted set; the half that named them pushed the string `'objects'` or `'metadataForms'`. - -- **A zero-record orphan is no longer written.** With no module emitted for a locale — a stack whose only surface is apps, under the default `--objects-only` with `--no-metadata-forms` — the committed-section list is empty, `narrowToCommittedSections` returns `{}`, and `{}` is truthy at the emit gate. The run therefore wrote one file holding an empty table, describing nothing, with no bundle module beside it for it to be about. Because `--check` compares the companion by bytes like any other emitted file, that orphan once committed is a file the gate demands forever: deleting it made `--check` report `missing` and exit 1. Such a run now writes nothing, and reports `Generated 0 file(s)`. -- **The section list is derived.** `translationModuleSections(bundle, kind)` sits beside `translationModulePayload` and is switched on the same `kind`, so what a module holds and which sections it commits are one decision rather than two. Under `kind: 'stack'` the module holds every group the stack authors and the caller now names all of them; a group added later needs no edit, and a further aggregate kind fails to compile at that one site rather than silently committing its own name as a section. - -**No provenance record changes in this repository, and none is restored.** The generated tables only ever carry the two sections `collectFilledFromHashes` walks (`objects` and `metadataForms`), so `'objects'` was the right name for both stack sub-tree modes — the old list was correct by coincidence, not by construction. In particular an `apps.*` record is not restored by this change: no such record is built, so none was being filtered out. - -**Already committed an empty companion?** Nothing needs doing and nothing is deleted. `--check` compares only the files a run writes and reports `missing` / `stale` over that set, so a leftover empty companion is in neither category — it is tolerated where it sits, and is inert to the next extract, which reads it back as an empty record set exactly as it would read its absence. Delete it at your convenience. diff --git a/.changeset/i18n-extract-key-count-describes-emitted-bytes.md b/.changeset/i18n-extract-key-count-describes-emitted-bytes.md deleted file mode 100644 index 8f3d3cb6a8..0000000000 --- a/.changeset/i18n-extract-key-count-describes-emitted-bytes.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os i18n extract` reports key counts that describe the bytes it emitted, and its summary is a partition of the skeleton rather than a sum over it. - -`extractTranslations` returned `counts[locale]` as a WALK counter — `count += 1` once per expected entry, unconditionally — and the command spent it as the number of keys in the file it had just written. Under the default `--objects-only` the module holds only the `objects` sub-tree, so the two are different numbers. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: - -``` - Skeleton summary - zh-CN 776 key(s) (of 776 expected) + 773 metadataForms key(s) - Wrote OUT/zh-CN.objects.generated.ts (776 keys) -``` - -The file that run wrote holds **2** leaves. The true split of the 776 is 2 objects + 1 app + 773 metadata-form baseline, so the summary appended a number the 776 already contained and read as 1549 out of 776 — an operator could not derive the truth from it, and the `(776 keys)` described no file the run produced. Both lines now read off the emitted tree: - -``` - Skeleton summary - zh-CN 775 of 776 key(s) emitted objects 2 · metadataForms 773 - Wrote OUT/zh-CN.objects.generated.ts (2 keys) - Wrote OUT/zh-CN.metadata-forms.generated.ts (773 keys) -``` - -**What each number now means.** `ExtractResult.counts[locale]` is a leaf count of `bundles[locale]` — the whole skeleton built for that locale, taken off the tree instead of off the walk that built it. It is explicitly not the size of any one file: which sections of the skeleton become committed modules is the caller's decision. The command therefore takes every count it reports off that module's own payload, selected with `translationModulePayload` — the same function `renderTranslationModule` renders from, so the number and the bytes cannot drift apart, including for a sub-tree mode added later. Nothing subtracts one count from another at a print site: that would repair today's two modes and leave the third wrong in the same way. - -**The summary line's shape changed** from `N key(s) (of N expected) + M metadataForms key(s)` to `E of S key(s) emitted` with a per-module breakdown. `E` is what this run's modules hold together and `S` is what the locale's skeleton holds, so `E ≤ S` always and the gap is exactly the keys a flag excluded — one app label under the default `--objects-only`, and nothing at all under `--no-objects-only`. A module a flag SUPPRESSED is named in the breakdown too, with its size and the words `not emitted` that keep it out of `E`: under `--no-metadata-forms` the row reads `2 of 776 key(s) emitted objects 2 · metadataForms 773 not emitted`, so the operator still sees how big the baseline they switched off is — which the old, double-counting line did tell them. - -**A module with no leaves is no longer written.** The emit gate was `counts[locale] > 0`, a property of the skeleton: on a stack whose only surface is apps, the default `--objects-only` wrote a `.objects.generated.ts` holding `{}` and announced it as 774 keys. The gate is now the module's own leaf count. - -**`--json`**: `counts` is now the leaf count of the `bundles` payload printed beside it, instead of the extractor's skeleton size. The skeleton total is unchanged and still reported, under its own name, as `totalExpected`. - -⚠️ That is **not** the relationship `metadataFormsCounts` has to `metadataForms`, and nothing here changes the latter. `metadataFormsCounts` reports the baseline as BUILT, emitted or not: under `--no-metadata-forms` the payload carries `metadataFormsCounts: { 'zh-CN': 773 }` beside an empty `metadataForms`, deliberately, and a pin holds it there. So the payload carries two count semantics — `counts` is what was emitted, `metadataFormsCounts` is what was built. Both faces are unchanged by this note; it exists because an earlier draft of it claimed a symmetry that does not hold. - -**No committed bundle moves.** All nine extract configs in this repository run under the default `--objects-only` on stacks that do author objects, and every emitted module is byte-for-byte unchanged; `pnpm check:i18n` stays green on the committed tree. What changed is stdout, the `--json` counts, and the emission of a module that would have been empty. - -The regression pin spawns the real CLI in four flag states and compares each printed count against a structural leaf count of the module it wrote, parsed back off disk. That comparison is the thing the defect precluded: a walk counter cannot disagree with the walk, so no assertion over `ExtractResult` could have failed while the printed number was wrong by two orders of magnitude. Its `--json` case drives `--metadata-forms` in both states, because a case that drives one state of a flag cannot see what that flag does — driving it ON only is exactly how the symmetry claim above survived unmeasured into a first draft. diff --git a/.changeset/i18n-extract-metadata-forms-flag-independence.md b/.changeset/i18n-extract-metadata-forms-flag-independence.md deleted file mode 100644 index 2bbbc865e7..0000000000 --- a/.changeset/i18n-extract-metadata-forms-flag-independence.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os i18n extract --no-metadata-forms` is honoured whatever `--objects-only` is set to, and the Studio metadata-form baseline lands in exactly one module. - -The flag gated only the `.metadata-forms.generated.ts` companion. The stack module's renderer had a third mode, `kind: 'full'`, that serialised the WHOLE `TranslationData` — the baseline included — and `--no-objects-only` selected it. So the two flags stopped being independent the moment the second one was passed, in both directions: - -- **`--no-metadata-forms --no-objects-only`** suppressed the companion and wrote the same keys into `.objects.generated.ts` instead. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: the emitted zh-CN module carried **776 leaves, of which 773 were the metadata-form baseline** the flag had just switched off (the stack's own surface is 3). Those 773 are **English** — the default locale is filled from the source labels and the metadata-form registry authors them in English — so a non-English default locale shipped the platform's English Studio strings inside its own application bundle. -- **`--no-objects-only` alone** wrote those 773 keys **twice**, once in each module. - -`--objects-only` picks the stack module's sub-tree; `--metadata-forms` decides whether the baseline is emitted at all, and it is now the only control over it **on both faces**. Both flags keep exactly the meaning their `--help` already gave them, and nothing here picks a winner between them — the overlap was in the emitter, never in the two meanings. - -`'full'` is renamed `'stack'` and omits `metadataForms`, so the module a run writes and the baseline companion beside it are disjoint, and under `'stack'` the two together are everything the extractor built (3 + 773 = 776 on the fixture above — the extractor's own count, none dropped, none duplicated). ⚠️ That is a statement about the PAIR a run emits, not about "three kinds partitioning the leaves": `'objects'` is a sub-selection of `'stack'`, not a sibling of it. - -`--json`, documented as "output JSON instead of writing files", mirrors that file set: `bundles` is the stack module and a `metadataForms` map is the companion, keyed by the locales whose companion would be written and gated by the same predicate. That map is new. It exists because the first cut of this change stopped the fold on the `--json` face as well and left the baseline with no JSON home at all — measured, `--json --no-objects-only` with the flag ON and with `--no-metadata-forms` returned payloads equal in every field but `duration`, so on that face the flag decided nothing, the mirror image of the defect this card reports. `metadataFormsCounts` reports the baseline's size in every run, as before. - -**No bundle in this repository moves.** All nine extract configs run under the default `--objects-only`, whose emitted module, export name and type signature are byte-for-byte unchanged — `pnpm check:i18n` stays green on the committed tree. A stack that DOES pass `--no-objects-only` regenerates a smaller `.objects.generated.ts`: its export keeps its name and narrows from `TranslationData` to `Omit`, and the baseline it used to duplicate is in the companion beside it unless `--no-metadata-forms` says it should not be there at all. - -**What content moves where.** On the file face nothing published loses content: under the default `--objects-only` the output is byte-identical, and under `--no-objects-only` the baseline moves out of the stack module into the companion the same command already writes — unless `--no-metadata-forms` says it should not exist, which is the ask. On the `--json` face the baseline moves from inside `bundles` to its own top-level key, and under `--no-metadata-forms` it is now absent, which it never was before: that face did not honour the flag at all. - -The regression pin spawns the real CLI and takes a group census of the bytes it wrote, and drives `--json` in BOTH flag states. The one-state version of that case could not have failed on the axis that failed here — a pin that exercises only the flag-OFF path can never detect a flag that does nothing. The sibling pin that mirrors the emit rule and checks file NAMES stayed green through all of this: the file set was right in every combination, and only the content was wrong. diff --git a/.changeset/i18n-walk-one-key-one-demand.md b/.changeset/i18n-walk-one-key-one-demand.md deleted file mode 100644 index 6255ad9acc..0000000000 --- a/.changeset/i18n-walk-one-key-one-demand.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint` and `os i18n extract` no longer count one translation key twice. - -A translation key is derived from *where a string is addressed*, not from *which declaration was being read* when the walk reached it — and two declarations can address one bundle slot. `collectExpectedEntries` emitted one entry per declaration, so a key reachable twice became two expected entries. Two families were measured, with different causes: - -- **Two carriers, one action.** The normalized config attaches an object's actions to `obj.actions` *and* to the top-level `actions` list — the same object reference, not a copy — so both action branches emitted `objects.OBJECT._actions.ACTION.*`. This is the family the coverage report shows: 70 of 691 baselined units across `app-todo` (40), `app-showcase` (29) and `app-crm` (1). -- **Two declarations, one form field.** `deleteBehavior` is declared twice in each of the `field` and `object` metadata forms, gated on `visibleWhen` (`lookup` vs `master_detail`); both render into one key. Config-independent — it duplicated six entries on every config, including an empty one. - -Neither is an authoring mistake, and neither is fixable where it originates: both are two correct declarations of one displayed string. So the walker now collapses entries that address the same path, keeping the first emission. - -What that corrects, in both directions: - -- **`os lint`'s i18n findings.** The same missing key was reported twice, byte-identically. `pnpm check:i18n-coverage` ratchets the finding *count* while its report calls the number "untranslated declared strings", so translating one key moved the ratchet by two and the frozen debt was ~11% larger than the work it described. The three coverage baselines are regenerated in this change and fall by exactly 70 (691 to 621): `app-crm` 102 to 101, `app-showcase` 443 to 414, `app-todo` 146 to 106. The ratchet's direction, monotonicity and failure text are unchanged — only the population it counts. -- **`os i18n extract`'s reported counts.** `totalExpected` and the per-locale `counts` counted emissions while the skeleton itself had already collapsed the duplicates on the way in, so extract over-reported what it wrote — 1632 claimed against 1531 keys written on `app-showcase`, 894 against 870 on `app-todo`, 930 against 925 on `app-crm`. Those numbers now match the skeleton. - -No generated bundle changes: every duplicate pair measured carries a byte-identical record, so de-duplication removes copies and never a demand. All nine `translations/*.generated.ts` packages stay in sync. diff --git a/.changeset/import-row-unique-violation-rest.md b/.changeset/import-row-unique-violation-rest.md deleted file mode 100644 index ce6d84a8b5..0000000000 --- a/.changeset/import-row-unique-violation-rest.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest)!: an import ROW report spells a unique-constraint refusal `UNIQUE_VIOLATION` — the same wire code as the whole-request failure on the same route (#14723) - - - -**BREAKING** on the per-row results of the import runner -(`POST /api/v1/data/:object/import` and the import job): a row refused by the -engine's `DuplicateRecordError` envelope now reports `code: 'UNIQUE_VIOLATION'` -where it reported `'DUPLICATE_RECORD'`. Shipped as `minor` under the repo's -launch-window convention for breaking changes. Maintainer ruling 2026-09-03 on -#14723 (verbatim 「同意,然后执行契约复审」), adopting option A: one wire -spelling for a unique-constraint refusal on every route. - -**Why.** `toFailedResult` relayed the thrown error's own `code`, and the engine's -envelope carries the registered `DUPLICATE_RECORD` — while the whole-request -failure on the same import route answered `UNIQUE_VIOLATION` through -`mapDataError`. Two spellings of one condition on one route, which ADR-0112's -one-name-per-concept and the error-code ledger's header both forbid. The -duplication is removed, not declared: no ledger waiver is added. - -**What changes.** The import row derivation applies the whole-request arm's own -predicate — the registered code AND the class name `DuplicateRecordError`, -exported from `error-response.ts` as `isEngineDuplicateRecordEnvelope` and now -shared by the arm and the row report — and reports `UNIQUE_VIOLATION`. A -field-level finding still takes precedence (the envelope carries none), the -row's sentence is unchanged (the platform sentence, sanitised as before; no -driver text), and a producer that merely throws the registered -`DUPLICATE_RECORD` without being the engine's class keeps its own code. - -**What does NOT change.** The whole-request doors (single-record, bulk, import, -metadata, UI) already answered `UNIQUE_VIOLATION` and keep doing so; the arm's -logic is untouched beyond reading the shared predicate. The engine's thrown -identity stays `DUPLICATE_RECORD` in-process. This package's `error-response.ts` -docblock that disclosed the fork under the #14541 contract review now states -the converged rule. - -**Consumer note.** An import client that branched on a row's `code` reading -`DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it -already handles for the whole-request 409. Measured in-repo and in the sibling -repos (hotcrm, objectui, non-test sources): zero consumers branch on either -spelling of a row code. diff --git a/.changeset/inert-deadline-keys-retired.md b/.changeset/inert-deadline-keys-retired.md deleted file mode 100644 index decce162bc..0000000000 --- a/.changeset/inert-deadline-keys-retired.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the fourteen inert deadline keys of the incident-response, training and change-management schemas (#14477, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescriptions are -registered under protocol major 18, where `os migrate meta` users will look). -Maintainer ruling 2026-09-02 on the census card (ruled A: retire per family): -ADR-0049 enforce-or-remove decides it — declared-but-unenforced deadline -surface with zero measured readers comes off. - -Fourteen hour/minute/day-shaped deadline, SLA and duration key sites — twelve -distinct names, because `durationMinutes` and `estimatedMinutes` each occur at -two sites — sat on the exported incident-response, training and -change-management schemas and in the generated reference docs, and **nothing -read them**: the schemas are exported from `@objectstack/spec/system`, mounted -by no stack key, registered as no metadata type, absent from the 2026-06 -liveness ledgers, and the reader census over every package outside -`packages/spec` (tests and changelogs excluded) and over objectui at the -pinned sha returned zero hits for every key. An author could write -`triageDeadlineHours: 4`, `validityDays: 365` or `regulatorDeadlineHours: 72` -and reasonably expect the platform to escalate, expire or notify — it never -did, and it never said so. Six of the keys carried defaults (30 minutes, -1 hour, 2555 days; 365, 30 and 14 days) that were materialized into every -parsed document without ever being consulted. A compliance-shaped deadline -that fails silently is the worst form of the shape ADR-0049 names. - -**What is refused:** authoring any of the keys below, with any value, on the -base schema and through every carrier that nests it (`Incident.responsePhases[]`, -`IncidentResponsePolicy.notificationMatrix`, `TrainingPlan.courses[]`, -`ChangeRequest.impact` / `.rollbackPlan` / `.implementation`). None of the -schemas is `.strict()`, so each key is a `retiredKey()` tombstone rather than a -bare deletion (a deletion would have stripped it in silence): authoring it is a -`tsc` error (`never`) and a parse error carrying the prescription -(`invalid_type` at the path of the key). - -| schema | retired keys | -|:--|:--| -| `IncidentResponsePhase` | `targetHours` | -| `IncidentNotificationRule` | `withinMinutes`, `regulatorDeadlineHours` | -| `IncidentNotificationMatrix` | `escalationTimeoutMinutes` (default 30) | -| `IncidentResponsePolicy` | `triageDeadlineHours` (default 1), `retentionDays` (default 2555) | -| `TrainingCourse` | `durationMinutes`, `validityDays` | -| `TrainingPlan` | `recertificationIntervalDays` (default 365), `gracePeriodDays` (default 30), `reminderDaysBefore` (default 14) | -| `ChangeImpact` | `downtime.durationMinutes` | -| `RollbackPlan` | `steps[].estimatedMinutes` | -| `ChangeRequest` | `implementation.steps[].estimatedMinutes` | - -**What stays, byte-identical:** every other key of the three families with its -default and its (absent) readers, and every export — no def leaves the public -surface. Parsed documents no longer carry the six former defaults. - -**Held, not touched:** the `ESignatureConfig` pair (`expirationDays`, -`reminderDays` in `data/document.zod.ts`) — the ruling left that branch open -pending the e-signature roadmap answer; it stays on the card. - -## FROM → TO - -```ts -// before — parsed green; no engine ever read a single one of these numbers -const policy: IncidentResponsePolicy = { - notificationMatrix: { - rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], - withinMinutes: 15, notifyRegulators: true, regulatorDeadlineHours: 72 }], - escalationTimeoutMinutes: 45, - }, - defaultResponseTeam: 'security_team', - triageDeadlineHours: 2, - retentionDays: 3650, -}; -const course: TrainingCourse = { - id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', - category: 'security_awareness', targetRoles: ['all_employees'], - durationMinutes: 60, validityDays: 365, -}; -const rollback: RollbackPlan = { - description: 'Restore from backup', - steps: [{ order: 1, description: 'Restore backup', estimatedMinutes: 15 }], -}; - -// after — delete the keys; there is no replacement because no incident-response, -// training-management or change-management engine exists to keep a deadline. -// Record retention is the object-level `lifecycle` block (ADR-0057), declared on -// the object that stores the records. -const policy: IncidentResponsePolicy = { - notificationMatrix: { - rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], - notifyRegulators: true }], - }, - defaultResponseTeam: 'security_team', -}; -const course: TrainingCourse = { - id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', - category: 'security_awareness', targetRoles: ['all_employees'], -}; -const rollback: RollbackPlan = { - description: 'Restore from backup', - steps: [{ order: 1, description: 'Restore backup' }], -}; -``` - -One-line fix: delete the key wherever it is authored. There is no -`os migrate meta` edit list for these keys — none of the schemas is a stack -collection member, so the conversion chain has no seam to walk (the -`MetadataPluginConfig.additionalTypes` precedent); the tombstone prescription -and the protocol-18 upgrade guide are the channels. - -The retirement kit: - -- `retiredKey()` tombstones at all fourteen sites (`packages/spec/src/system/ - incident-response.zod.ts`, `training.zod.ts`, `change-management.zod.ts`; - each file's section comment records what the shape was and why no D2 - conversion exists) -- ADR-0087 registration: fourteen `RETIRED_KEYS_BY_MAJOR[18]` entries (the - three nested change-management sites spelled `ChangeImpact:downtime.durationMinutes`, - `RollbackPlan:steps.estimatedMinutes`, `ChangeRequest:implementation.steps.estimatedMinutes`) - and three D3 semantic entries, one per family -- no liveness-ledger row: none of the three families is an enrolled ledger - type, so there is no row to keep or drop -- pin tests (`deadline-keys-retirement.test.ts`): a refusal pin per site - asserting the issue path, code and prescription on the base schema and - through the nesting carriers; the tsc `never` channel; no-materialize pins - for the six former defaults; the ADR-0087 registration; and a tree-scoped - absence pin over every authored source in the repo -- generated baselines and docs follow the schema: `authorable-surface/` gains - eleven `[RETIRED]` rows, `authorable-defaults/` loses six rows, the three - system reference pages are regenerated, and the gitignored `json-schema/` - output is re-emitted on the next build -- `json-schema.manifest/` is unchanged, and correctly so: it ratchets def - *names*, and retiring keys removes no def from the published surface -- `spec-changes.json` and the protocol upgrade guide are unchanged too: both - project the migration chain at the current protocol major (17), so these - protocol-18 registrations reach them at the 18 cut -- zero authored occurrences in this repo's examples, skills and hand-written - docs, and zero hits in objectui at the pinned sha, so no in-repo source - changes ride along beyond the three families' own unit tests diff --git a/.changeset/init-generate-emit-service-object-annotation.md b/.changeset/init-generate-emit-service-object-annotation.md deleted file mode 100644 index d848afea58..0000000000 --- a/.changeset/init-generate-emit-service-object-annotation.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os init -t app`, `os init -t plugin` and `os g object` now emit an object file that compiles. All three wrote `const … : Data.Object`, and `@objectstack/spec/data` exports no member named `Object`, so the first command a new user runs produced a project that failed its own `pnpm typecheck`. - -Measured against the **published** package a real user installs (`npm pack @objectstack/spec@17.3.0`, extracted and linked into a driven emission), not against the workspace: - -``` -error TS2694: Namespace '.../@objectstack/spec/dist/data/index' has no exported member 'Object' -tsc exit 2 -``` - -Identical at TypeScript 5.3.3, 5.8.3 and 6.0.3, so it was never a compiler-version effect. `os create example` type-checked clean on the same tarball in the same run — the failure was specific to these emissions. - -The annotation is now `Data.ServiceObject`. That name was not chosen here — it is what [ADR-0122](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0122-schema-type-alias-naming-convention.md) D1 already ruled: for a schema `XSchema`, the **bare** alias denotes the author state (`z.input`), and it is "the name documentation, examples, skills and AI authoring surfaces use for the thing an author writes". An emitted scaffold is the thing an author writes, so the bare alias is the one it owes. The sibling generators were already on that convention — `UI.View`, `UI.Action`, `UI.Dashboard` and `Automation.Flow` are each the bare alias of their own schema — and only the object emitters had drifted off it. - -**Nothing was added to `@objectstack/spec`**: `ServiceObject` has been exported from `@objectstack/spec/data` throughout. - -The parsed-state alias is not an alternative here. Annotating the same emitted literal `Data.ServiceObjectParsed` fails all three cases with `error TS2740`, because every field literal is then missing the keys the schema supplies by default — which is exactly the author-state/parsed-state distinction ADR-0122 D2 draws. - -`content/docs/deployment/cli.mdx` taught the broken spelling too, and is corrected with them — a reader copying from the docs wrote the same uncompilable line. - -The whole emitter roster was swept rather than the three reported sites: driving every `os init` template and every `os g` generator through `tsc --noEmit` under the tsconfig the scaffolder itself writes, `Data.Object` was the only non-existent member any of them named. In particular `UI.View` and `Automation.Flow` — named alongside `Data.Object` in the docs line and explicitly not swept when this was reported — are genuinely exported, and their generators compile at exit 0. - -Why nothing caught this: both existing scaffold sweeps are runtime pins that load the emitted TypeScript through esbuild, which erases type annotations **without checking them**, so a broken annotation transpiles to byte-identical JavaScript and is invisible to them by construction. The scaffolds parsed, validated and loaded; they simply did not compile. A new pin runs the emitted projects through a real `tsc` program, with a canary that must fail with TS2694 so the harness cannot pass by resolving nothing. diff --git a/.changeset/init-scaffold-lint-script-parity.md b/.changeset/init-scaffold-lint-script-parity.md deleted file mode 100644 index 04aab17bee..0000000000 --- a/.changeset/init-scaffold-lint-script-parity.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os init` / `os create` now write a `lint` script into every scaffolded project, matching what `npx create-objectstack` already emits. - -The two scaffolders had diverged. `npx create-objectstack` copies a template that declares `dev`, `start`, `build`, `validate`, `lint` and `typecheck`, and ships a CI workflow that runs `pnpm validate`, `pnpm lint` and `pnpm typecheck`. The three script maps in `os init` each declared `validate` and no `lint`, so a project scaffolded through `os init` that adopted that workflow — the documented next step — failed its first push with `Command "lint" not found`. - -`objectstack lint` is not a second spelling of `objectstack validate`. Both run the shared authoring-rule engine, but only `lint` reaches the hook-body lowering check, so `hook-body/not-lowerable` — a handler that has silently stopped lowering to a metadata-only body, a change of deployment shape from a refactor that looks like tidying — was unreachable from a project scaffolded this way. - -The new entry sits after `validate` in each map, matching the template's order, and its value is `objectstack lint` on both sides. Existing projects are unaffected; add the script by hand to pick the check up: - -```json -"scripts": { - "validate": "objectstack validate", - "lint": "objectstack lint" -} -``` - -A pin now holds the two scaffolders equal on the scripts the shipped CI workflow runs, derived from that workflow rather than transcribed, so the next divergence is a red test instead of a discovery. diff --git a/.changeset/injected-system-column-labels-localised.md b/.changeset/injected-system-column-labels-localised.md deleted file mode 100644 index 3aff300a37..0000000000 --- a/.changeset/injected-system-column-labels-localised.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The tenant-scope and owning-business-unit system columns now render a localised display name on the `/meta` read exits, as the other platform-injected columns already did. - -`translateObject` carries a built-in label table for the columns the platform injects onto every eligible object, applied while a column still carries its injected English default, so a `zh-CN` / `ja-JP` / `es-ES` request never sees the English label on a custom object that ships no translation entries of its own. The table covered `owner_id`, `created_at`, `created_by`, `updated_at` and `updated_by` but not the two remaining injected columns, `organization_id` (`Organization`) and `owning_business_unit_id` (`Owning Business Unit`), so those two leaked English on every locale. Both rows are added, with the wording the platform bundles already use for the same columns on platform objects. The identity-stable column definitions are untouched, no new authorable key is introduced, and a label a tenant or author customised is still never overridden. diff --git a/.changeset/install-local-admission-tenancy-posture.md b/.changeset/install-local-admission-tenancy-posture.md deleted file mode 100644 index 80a23c5216..0000000000 --- a/.changeset/install-local-admission-tenancy-posture.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/cloud-connection': patch ---- - -Fix: the marketplace install-local routes now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply at these doors. - -Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran here, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. - -The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage and answers 503 rather than admitting the caller. diff --git a/.changeset/invalid-date-total-arm-metadata-protocol.md b/.changeset/invalid-date-total-arm-metadata-protocol.md deleted file mode 100644 index cf35a4c262..0000000000 --- a/.changeset/invalid-date-total-arm-metadata-protocol.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Serve an Invalid `Date` from a driver instead of raising `RangeError` at two metadata read seams. - -`canonicalIsoInstant` (`sys-metadata-repository.ts`) and the `occurredAt` arm inside `auditMetaItem` (`protocol.ts`) both reached `value.toISOString()` for any `Date`. That call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN`, so a single bad row answered **500** on a read path — where the spelling these repairs replaced, `String(value)`, had served a visibly-wrong field the caller could see and report. - -The shape is measured, not hypothetical: mysql2 3.23.1 returns a module constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276 — a range Postgres itself stores. Legacy imports, hand migrations and a MySQL database shared with another application are all ordinary ways such a row arrives. - -Both arms now guard on `Number.isNaN(value.getTime())`, and the terminal value is chosen per call site rather than uniformly: - -- `canonicalIsoInstant` answers `undefined`, so each caller's existing `?? ` chain — the branch an absent column already takes — keeps its meaning. Its consumers are machines, and one forwards into a `z.string().datetime()` field that visible text would fail. -- `auditMetaItem`'s `occurredAt` falls into the `String(...)` arm already beside it, which renders exactly `"Invalid Date"`. `AuditMetaItemResponseSchema.events[].occurredAt` is a required plain `z.string()` read by an operator in Studio's audit tab, so the text satisfies the contract and one bad row no longer blanks the page. - -Neither answer is a blank: a silent empty value is the shape that hides the producer's bug. diff --git a/.changeset/invalid-date-total-arm-metadata.md b/.changeset/invalid-date-total-arm-metadata.md deleted file mode 100644 index c8ca029b27..0000000000 --- a/.changeset/invalid-date-total-arm-metadata.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata': patch ---- - -Serve an Invalid `Date` from a driver instead of raising `RangeError` in `DatabaseLoader.stat`. - -`canonicalIsoInstant` reached `value.toISOString()` for any `Date`, and that call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN`. `stat()` is a hot read path — REST `/meta/*`, ObjectQL plan resolution, runtime overlay merges — so one legacy `sys_metadata` row answered **500** where the spelling this repair replaced had served a visibly-wrong value. - -The shape is measured: mysql2 3.23.1 hands back a constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276, which Postgres itself stores. - -The `Date` arm now guards on `Number.isNaN(value.getTime())` and answers `undefined`, so `stat()`'s own `?? new Date().toISOString()` — the branch an absent column already takes — publishes a parseable `MetadataStats.mtime`. `undefined` rather than visible text is deliberate here: `mtime` is declared `z.string().datetime()`, so the text `"Invalid Date"` would not produce a readable cell, it would produce a zod refusal at the consumer, moving the failure instead of removing it. A blank is excluded for the opposite reason — it hides the producer's bug. diff --git a/.changeset/invalid-date-total-arm-rest.md b/.changeset/invalid-date-total-arm-rest.md deleted file mode 100644 index ca21d3a2a4..0000000000 --- a/.changeset/invalid-date-total-arm-rest.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -Serve an Invalid `Date` from a driver as visible text instead of raising `RangeError` in the import-job DTO and the CSV export. - -`canonicalIsoStamp` and `formatCsvCell` both reached `value.toISOString()` for any `Date`, and that call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN` — so one bad timestamp column answered **500** on `GET /api/v1/data/import/jobs/:jobId` and aborted a CSV export mid-stream. - -The shape is measured: mysql2 3.23.1 returns a module constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276, a range Postgres itself stores. - -Both arms now guard on `Number.isNaN(value.getTime())` and render the visible text `"Invalid Date"` — the rendering the spelling they replaced produced. Both are read by a human, and the declared contracts allow it: the four import-job stamps are plain `z.string()` (not `z.string().datetime()`), and a CSV cell has no schema at all. The operator sees a wrong-looking field they can report, rather than an error naming no row. - -The CSV arm needs its own guard rather than a fall-through, because the branch below it is `JSON.stringify` and `Date.prototype.toJSON` answers `null` for an Invalid `Date` — the silent blank this change exists to avoid. Both CSV paths land on the guarded arm: with field metadata, `formatDate` rejects an Invalid `Date` and passes the value through unchanged. diff --git a/.changeset/isqlite-comment-drift-sweep-round-two.md b/.changeset/isqlite-comment-drift-sweep-round-two.md deleted file mode 100644 index 30e9b2ab1a..0000000000 --- a/.changeset/isqlite-comment-drift-sweep-round-two.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Correct the remaining out-of-package comments that still described -`SqlDriver#formatOutput`'s two timestamp passes as gated on `if (this.isSqlite)`. - -Since ADR-0053 D-F1 (#13973) both passes — the `AUDIT_TIMESTAMP_COLUMNS` pass and the -`normalizeSqliteDatetimeOutput` pass over `datetimeFields` — run on every dialect, so the -record read door presents the builtin audit columns and every declared `Field.datetime` -as canonical ISO-8601-`Z` text on Postgres and MySQL as well as SQLite. Measured on the -tree rather than recalled: in `packages/drivers/driver-sql/src/sql-driver.ts` the -`if (this.isSqlite)` arm inside `formatOutput` opens at line 16965 and closes at 17026, -covering only the JSON codec and the numeric-scalar repair, while the audit-column loop -(17046) and the `normalizeSqliteDatetimeOutput` loop (17061) both sit at the method's top -level, below that closing brace. - -Two of the corrected comments were load-bearing rather than merely stale. The -`service-storage` one drew a conclusion for a live read door from the false premise, and -it also claimed that folding at the driver's read boundary "would reverse the deliberate -`withPostgresCalendarDayAsText` decision" — which is what #13973 ruled and did. The two -`packages/cli` ones attached the wrong reason to a true fact: the holder probe reads -through the raw-SQL seam, so `formatOutput` never runs on that path at all, and the -dialect divergence there survives the ruling for that reason and not because of a gate. - -Comments only — no runtime behaviour, no exported symbol and no public type changes. -`@objectstack/cli` is the one package named here because its per-file build carries the -amended text verbatim into `dist/commands/migrate/duplicates.js` and -`dist/commands/migrate/duplicates.d.ts`, so its published output changes. -`@objectstack/metadata-protocol` is deliberately NOT named: its edits are all in test -files, which are not published. `@objectstack/service-storage` and `@objectstack/metadata` -are deliberately NOT named either: their source edits are JSDoc blocks on the internal -`usableCreatedAt` and `canonicalTimestampText`, and both bundles strip them — measured -absent from `dist/`, with each package's identifier found in the same `dist/` (and the -exported `StrandedOrphanInventoryEngine` docblock present in `dist/index.d.ts`) as the -firing control that the probe works. - -Three carve-outs are preserved rather than flattened: `withPostgresCalendarDayAsText` is -untouched by that ruling (D-F2 — the client library still materialises `timestamptz` / -`DATETIME(3)` as a `Date`); the Invalid `Date` residue still stands (D-F3 — the one `Date` -shape with no canonical text leaves the read door unchanged, so no sentence claims the -read door never hands out a `Date`); and the ruled-B consumer arms stay, with only the -prose explaining why they exist corrected. diff --git a/.changeset/job-service-replay-force.md b/.changeset/job-service-replay-force.md deleted file mode 100644 index 8d8c5aaf91..0000000000 --- a/.changeset/job-service-replay-force.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `IJobService.replay` gains an optional third argument, `options?: JobReplayOptions`, carrying `force: true` (#14766 — the contract half of the #14501 A+a2 ruling) - -Additive: the argument is optional, an existing two-argument `replay(name, data?)` implementation keeps compiling and behaving as before, and omitting it is the pre-#14766 call exactly. `JobReplayOptions` is exported from `@objectstack/spec` (`contracts`), with one member, `force?: boolean`. - -**What the contract now declares** (`packages/spec/src/contracts/job-service.ts`, the `replay` TSDoc), for a scheduled (cron) flow whose tick window takes a `(flow, tick-window)` dispatch claim in `sys_flow_dispatch`: - -- `replay(name, data)` on a window whose claim is **absent or failed** re-runs the window — unchanged behaviour, and every job that never takes a claim is this row; -- `replay(name, data)` on a window whose claim **succeeded** is **refused loudly**: the promise rejects with an ADR-0112 envelope — `code: 'RESOURCE_CONFLICT'` (the standard-catalog member HTTP 409 derives; no new extension code) and `status: 409` — whose message names the window asked for and the claim that refused it. Never a silent no-op; -- `replay(name, data, { force: true })` sends anyway; the duplicate is the operator's, taken knowingly. - -**Declared here, enforced by #14501.** This release changes the contract text and the signature only. The refusal semantics are implemented by the services half (#14501: the `(flow, tick-window)` claim through `sys_flow_dispatch`, and `DbJobAdapter.replay` reading it); until that lands, shipped adapters still accept the third argument and ignore it, re-running the window as before. A third-party `IJobService` implementation that already declares `replay` needs no change to keep compiling; one that wants the once-only guarantee implements the table above. diff --git a/.changeset/kernel-duration-keys-unit-in-key-name.md b/.changeset/kernel-duration-keys-unit-in-key-name.md deleted file mode 100644 index c8053a60c2..0000000000 --- a/.changeset/kernel-duration-keys-unit-in-key-name.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": patch ---- - -feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478) - - - -**BREAKING** — fourteen published `kernel/` duration keys are renamed and -tombstoned. Shipped as `minor` under the repo's launch-window convention for -breaking changes; the hand-migration prescriptions are registered under protocol -major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, -「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit -in the key NAME, never only in its `.describe()` prose, and grandfathers no -existing offender. Stack card 1/6 (#15676) landed the rule's two structural -exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`. -Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**, -and the whole-tree count falls **36 → 22**. - -## FROM → TO - -| key | replacement | unit | -|:--|:--|:--| -| `EventPersistence.retention` | `retentionDays` | days | -| `EventSourcingConfig.retention` | `retentionDays` | days | -| `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds | -| `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds | -| `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds | -| `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds | -| `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds | -| `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days | -| `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours | -| `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds | -| `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds | -| `StartupOptions.timeout` | `timeoutMs` | milliseconds | -| `PluginStartupResult.duration` | `durationMs` | milliseconds | -| `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds | - -**Every value is unchanged** — only key names move, and every default moves with -its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to -365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the -authoring site (input type `never`) and fails the parse with the rename -prescription rather than a bare unrecognized-key error. - -## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace - -**`responseTime` meant two different units on two kernel shapes.** On -`PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a -publisher promises to answer a vulnerability report); on -`PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So -`responseTime: 24` was a day on one shape and a fortieth of a second on the -other, with nothing at the authoring site to tell them apart. They land on -`responseTimeHours` and `responseTimeMs` respectively — do not let one -find-and-replace rewrite both. - -**`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision -was already costing prose: the protocol lifecycle page carried a standing -paragraph whose only job was telling the two apart. `metrics.uptime` becomes -`metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a -separate, unchanged surface and must not be renamed with it. - -A third split worth reading before you migrate: `estimatedDurationSeconds: 120` -is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent -measurements of the same package install carried two different units, and no -parse can catch a value moved between them — both bounds accept any -non-negative integer. - -## Dispositions — five semantic entries, no D2 conversion - -Justified per key rather than defaulted, and this card's answer is uniform: -**none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs -over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`, -`upgrade` or plugin-security root — none of these twelve defs is a stack -collection member or a registered metadata kind stored as a `sys_metadata` row, -so the conversion chain has no seam that would see one. They are host -construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`, -`MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and -runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`, -`StartupOrchestrationResult`, `UpgradePlan`, -`PackageDependencyResolutionResult`). Each therefore carries a **semantic** -entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on -one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling -B prescribes for a key that is not authorable metadata. All fourteen are -registered by exact key in `RETIRED_KEYS_BY_MAJOR`. - -## Keys deliberately left alone - -`EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and -`MultiVersionSupport.rollout.percentage` is a proportion — neither is a -duration, so neither has a unit to carry and both keep their names. -`RuntimeConfig.resourceLimits.timeout` names its unit only in the JSDoc above -the key ("Execution timeout in milliseconds"), a channel -`check:duration-unit-keys` does not read: it reads `.describe()` and -`.meta({ description })`, and this key's describe ("Maximum execution time") -names none. The gate therefore lists it among the duration-shaped keys but -deliberately does not judge it — neither an offender nor an exemption — so it is -outside this rename; that JSDoc-channel gap is filed as #15939. A pin test -asserts the key still parses bare, so a later sweep cannot read the four -security renames as "every timeout on that file". - -## Readers moved in the same PR, at the same magnitude - -`@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() - -startTime`), the kernel and contracts test suites, and the hand-written -`content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now -states the collision the rename removes. - -⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local -`PluginStartupResult` interface — a different type, carrying `startTime` rather -than any duration key. It is not a reader of this schema, it is untouched by -this rename, and the divergence between the two shapes is tracked separately. diff --git a/.changeset/knowledge-refresh-cron-attribution.md b/.changeset/knowledge-refresh-cron-attribution.md deleted file mode 100644 index be55586da5..0000000000 --- a/.changeset/knowledge-refresh-cron-attribution.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`KnowledgeRefreshPolicy.cron` no longer tells authors that the `cron` dialect engine judges their syntax "when the expression is evaluated". Both halves of that sentence were false: nothing evaluates `refresh.cron` — `service-knowledge` reads `refresh.onRecordChange` and never `refresh.cron` — and `@objectstack/formula`'s registered `cron` engine has no caller outside that package, so it was never going to issue that verdict either. The claim shipped to authors through the generated reference page (`content/docs/references/ai/knowledge-source.mdx`), naming both an engine that never sees the value and an event that never happens. - -The docblock, the `.describe()` and the slot's two pin-test comments now say what is true today, matching the wording of the already-corrected Expression Protocol dialect table: cron syntax is not checked at parse time and no engine evaluates this slot — `croner` judges a cron pattern only where a schedule is wired (`CronSchedule.expression`, a different slot) — so the verdict belongs to whatever external scheduler the author hands the value to. Documentation only: no exported symbol, no authorable key and no accept-set movement; the parse behaviour is byte-for-byte unchanged, and the pin that proves `'not a cron'` still normalizes is untouched. diff --git a/.changeset/layer0-verdict-on-operation.md b/.changeset/layer0-verdict-on-operation.md deleted file mode 100644 index dec5415870..0000000000 --- a/.changeset/layer0-verdict-on-operation.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/plugin-security": minor ---- - -feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall - -`BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). - -Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. - -- **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. -- **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. -- **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). - -What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. diff --git a/.changeset/layered-read-org-gate-after-fold.md b/.changeset/layered-read-org-gate-after-fold.md deleted file mode 100644 index 807403f7b9..0000000000 --- a/.changeset/layered-read-org-gate-after-fold.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -`getMetaItemLayered` no longer reports a phantom org-scoped row as a tenant customization. - -`getMetaItemLayered` is the three-layer diagnostic behind Studio's "Code default vs Overlay vs Effective" view, and the third `/meta` read verb in the series `getMetaItems` (plural) and `getMetaItem` (singular) were repaired in. Unlike those two it applied no registry read gate of its own: whatever organization a caller passed was spent on whatever type it passed. On a type the registry declares `allowOrgOverride: false` — everything outside the ADR-0005 tier-A five (`view`, `dashboard`, `report`, `translation`, `email_template`) — a deployment with history can hold pre-#6190 phantom org-scoped rows, which boot hydration deliberately walks past. Read back through this verb they surfaced as `overlay` with `overlayScope: 'org'`: an operator was shown a customization that does not exist, in the one surface built to be authoritative about customizations. - -It was not only displayed. Two doors return that layer **as the response** when it is non-null — the runtime metadata dispatcher and REST `GET /meta/:type/:name/published` — so on those paths the phantom was served as the item. - -The read now resolves its organization through `organizationIdForMetaRead`, the same registry-derived predicate the REST `/meta` doors have applied since #9454 and the twin of the write side's `organizationIdForMetaWrite`. A type with a per-org read channel still resolves the caller's organization and still reports `overlayScope: 'org'`; every other type reads env-wide, which is the partition that actually runs. - -**The gate is bound after the canonical type fold, and that ordering is load-bearing.** In the two sibling verbs the binding already sat below `canonicalizeMetaRequestType`, so the fix there was a substitution. Here it sat above it, and dropping the same expression in place would have gated on the raw `/meta/:type` segment: `declaresOrgOverride` tolerates the manifest plurals but not the URL-only spellings (`translations` and `email_templates` have no manifest key), so a raw segment splits one item across two partitions, addressed by spelling. The repair is therefore a reorder, and it is pinned by a test that fails if the binding moves back above the fold. - -Callers that name no organization — four of the five `plugin-security` invocations, and every import/analytics/auth reader — are unaffected, and a door that already computed the same predicate receives the scope it did before. diff --git a/.changeset/lint-collection-copies-guarded-readers.md b/.changeset/lint-collection-copies-guarded-readers.md deleted file mode 100644 index 5729e5e2d5..0000000000 --- a/.changeset/lint-collection-copies-guarded-readers.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -Fix: a name-keyed `pages:` map no longer passes every source-page lint vacuously. - -`pages` has two authoring carriers — a list, or a map keyed by page name that -`normalizeStackInput` folds into a list before the schema sees it. Four rules -(`validate-jsx-pages`, `validate-page-source-styling`, -`validate-react-page-props`, `validate-react-pages`) read the collection through -a private coercion that answered a map with an empty list, and they run on the -raw `os lint` path where nothing has normalized it yet. On a map-shaped stack -all four therefore returned no findings by never walking a single page: an -empty source, a syntax error, an unparseable component and a Tailwind -`className` were all reported as clean. They now read `collectionEntries`, -which handles both carriers, and a finding on the map carrier is located by the -author's own key (`pages.home.source`) rather than a synthetic index. - -The same change removes the last sixteen private copies of the collection -coercion in this package. Twelve rules — the `function` form, which had already -grown the non-record filter locally in two different spellings — now read -`recordsOf` from `object-graph.ts`. Two behaviour changes fall out, both on -input that was already malformed: an array-typed member of `agents:` / -`skills:` / `tools:` used to survive the looser local filter and draw one -reference-integrity finding at a position nobody authored, and is now dropped; -a member of a name-keyed `validations:` map whose value is not a record is now -carried as `{ name }` rather than discarded, which reaches no check that reads -it. No rule id, message or severity changes, and every finding path on the list -carrier is unchanged. diff --git a/.changeset/lint-eval-empty-generator-refusal.md b/.changeset/lint-eval-empty-generator-refusal.md deleted file mode 100644 index a1847bad48..0000000000 --- a/.changeset/lint-eval-empty-generator-refusal.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -**BREAKING** `os lint --eval --generator ""` now refuses instead of quietly running the offline eval, matching the rule the same flag already follows without `--eval`. - -Eval mode guarded the generator load with a truthiness test, so an empty string fell straight through it: the module was never loaded, no warning was printed, and the `Failed to load generator` message that exists for exactly this failure was never reached. What came out was the ordinary offline report — `Mode: offline`, `5/5 passed · mean 99/100`, exit 0 — to someone who had asked for a live run and read that score as their generator's. - -It was not merely ineffective. Driven against the same command with the flag absent entirely, and with the elapsed-time token normalised, the two runs produced byte-identical stdout, empty stderr and the same exit code on every face the command has, `--json` included. There was no channel on which the difference was visible. The usual way to type it is `--generator "$GEN"` in a script where `GEN` is unset. - -The guard now tests whether the flag was provided rather than whether its value is truthy — the same test `os lint --generator` outside `--eval` has used since it started refusing — so one flag has one rule for "the operator typed it". No new failure shape is introduced: an empty string is a path that names no module, so it answers through the load path an unresolvable path already answered through, with the reason on `error`, exit 1, and on `--json` a single JSON document. No error code is invented for it. - -A scripted invocation that passed an empty `--generator` to `os lint --eval` now exits 1 with the reason, where it previously exited 0 having silently scored the bundled corpus instead. Every other invocation is untouched: `--eval --generator ` still loads the module and scores live output, `--eval` alone still scores the bundled corpus offline, and a plain project lint is unchanged. - - diff --git a/.changeset/lint-eval-generator-load-envelope.md b/.changeset/lint-eval-generator-load-envelope.md deleted file mode 100644 index b9582a48b7..0000000000 --- a/.changeset/lint-eval-generator-load-envelope.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os lint --eval --json` now carries the ADR-0112 error carriers on its generator-load failure, instead of a bare `{error}`. - -Eval mode's `--generator` load failure was the one exit on that mode with a machine face, and it was off-envelope: the `catch` built its human message and discarded the error object, so `code` and `httpStatus` could never reach the payload. A consumer that reads `code` to branch got a real code from the same command's project-lint catch-all and `undefined` from eval mode — the case a consumer is most likely to be caught by, because the face is present and looks answerable. - -The exit now spreads `errorCodeFields(error)`, the same helper the project-lint catch-all spreads, so both failure faces of `os lint` are built from one source rather than two hand-written shapes. - -Nothing is minted. `errorCodeFields` passes a producer's code through and returns nothing otherwise — ADR-0112's ledger stays the authority on who may mint a code — so the exit is polymorphic in exactly the way its sibling already is. Measured on the command's own output, across the reachable load-failure classes: - -- a generator whose top-level evaluation throws a coded failure (an SDK refusal as the module builds its client at import) now answers `{"error": …, "code": "FORBIDDEN", "httpStatus": 403}`; both keys were being dropped; -- a file the generator reads at import that is missing now answers `code: "ENOENT"`, the errno vocabulary already documented for this command, and no invented HTTP status; -- an unresolvable path or a syntax error — esbuild's own build failure, which carries neither key — still answers a bare `{error}`, as does the hand-thrown "module must default-export a function". - -The human (non-`--json`) path, the eval report exit, and offline eval are unchanged. diff --git a/.changeset/lint-eval-json-stderr-silence.md b/.changeset/lint-eval-json-stderr-silence.md deleted file mode 100644 index 3b5b6835cd..0000000000 --- a/.changeset/lint-eval-json-stderr-silence.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint --eval --json` no longer leaks esbuild's own diagnostics to stderr while loading a `--generator` module. - -A `--json` invocation is a machine face, and its stdout document was already well-formed — but the `--generator` load runs through `bundleRequire`, and esbuild's logger writes straight to stderr from inside that call, before anything throws. The `catch` that builds the one-key `{error}` document therefore never got a chance to suppress it, and a caller who asked for JSON got an internal bundler's diagnostic on the human channel alongside it. - -Measured on `bin/run-dev.js` with `NO_COLOR=1`, two runs that both leaked: - -- an unresolvable `--generator` path: exit 1, a well-formed `{error}` on stdout, and `✘ [ERROR] Could not resolve ""` on stderr; -- a generator that bundles and loads *successfully* but makes esbuild warn: exit 0, the full live eval report on stdout, and 340 bytes of `▲ [WARNING] …` on stderr. Nothing throws on this path at all, so no error handling was ever involved. - -The load now passes `esbuildOptions: { logLevel: 'silent' }`, scoped to that one call site and applied only when `--json` is set. - -- **The refusal is unchanged.** `logLevel` governs whether esbuild *prints*; it still throws its `BuildFailure` with `errors` populated, and that text already forms the tail of the `{error}` string on stdout. Both stdout documents above are byte-identical before and after. -- **The human face is untouched**, by construction rather than by restating a default: without `--json` no `esbuildOptions` is passed at all. `os lint --eval --generator ` still prints esbuild's line on stderr exactly as before. -- **What is suppressed beyond the leak itself:** under `--json`, an esbuild *warning* on a generator that loads fine now reaches nothing. A warning is not thrown, so no handler carries it onto stdout. This is inside the defect rather than beyond it — the machine face is not a place for human-channel output — but a `--json` consumer that was reading stderr for bundler warnings will no longer see them. -- The other `bundleRequire` callers in the CLI (`os serve` / `os dev`, config loading, scaffold validation) are not affected and keep their diagnostics. diff --git a/.changeset/lint-eval-throwing-generator-unscorable.md b/.changeset/lint-eval-throwing-generator-unscorable.md deleted file mode 100644 index 83a6bdfb3a..0000000000 --- a/.changeset/lint-eval-throwing-generator-unscorable.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint --eval` no longer scores a failed generation as a perfect one: a generator that throws now counts 0 toward `meanScore` instead of 100. - -The harness has always handled a throwing `--generator` by substituting an empty stack and scoring that. An empty stack is **100 / grade `A` / `valid: true`** — it has nothing wrong with it because it has nothing in it. So a live eval in which every single generation failed reported the best possible headline number: - -``` -os lint --eval --json --generator ./throws.mjs -exit 1 · ok: false · passed: 0 · failed: 5 · meanScore: 100 -every case: score 100 · grade A · valid true · generationError "model unavailable" -``` - -`meanScore` is the first number a human scanning that report reads, and it read perfect precisely when the model under test produced nothing. - -**What was NOT wrong: `passed`.** It carries its own guard (`!generationError && …`), so the failed cases were reported as failed and `ok` was `false` throughout. A reader who cross-read `ok`/`passed` was safe; a reader who checked the mean and moved on got exactly the wrong impression. That is the whole defect, and nothing about `passed`, `ok`, `total`, `failed` or the exit code changes here. - -The repair is the verdict the sibling failure path already used. A generator that *returns* a value nobody can walk was already scored `0 / F / valid: false`, with the reason written into the module: a stack that cannot be walked is not an empty stack, and `valid: true` for one that was never parsed is simply false. A stack that was never produced is not an empty stack either — so both now answer the same: - -```json -{ "id": "invoice_with_line_items", - "generationError": "model unavailable", - "passed": false, - "score": { "score": 0, "grade": "F", "valid": false } } -``` - -and the run above now reports `meanScore: 0`. - -`meanScore`'s denominator is unchanged and is now stated in the payload's own documentation: the mean is over every case **attempted**, so a failed case contributes its 0 and is counted. The alternative — averaging only over cases that could be scored — is a different metric that would report the quality of the generations that arrived while staying silent about how many never did; a `meanScore` that switched denominators without saying so would be a worse defect than the one being fixed. - -No key is added to or removed from the `--json` payload, and nothing a generator can return is newly accepted or rejected: an off-shape stack is still a **scored** case whose schema errors are why it fails, never a generation error. diff --git a/.changeset/lint-eval-unscorable-stack-json-face.md b/.changeset/lint-eval-unscorable-stack-json-face.md deleted file mode 100644 index d1b95ca0f5..0000000000 --- a/.changeset/lint-eval-unscorable-stack-json-face.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os lint --eval --json` reports an unscorable generated stack as a failed case instead of crashing with no JSON at all. - -The eval harness promised totality in writing — *"Never throws — generation failures become failed cases"* — and the promise was false as written. Its `try` wrapped only the call to your `--generator` module; the `scoreMetadata(stack)` call that follows sat outside it. So a generator that **threw** became a failed case, exactly as documented, while a generator that **returned** a value nobody could walk took the whole process down: - -``` -os lint --eval --json --generator ./g.mjs -exit 1 · stdout 0 bytes · stderr " Error: poison getter" -``` - -A caller that asked for `--json` got the framework's human error text on stderr and no document at all to parse. Eval mode dispatches above the project-lint `try`, so the catch-all JSON exit that mode has could never see it either. - -Scoring a stack means walking it, and there are two walks: the normalizer spreads the stack's top level, and the schema parse walks everything below it. A throw from **either** now becomes that case's `generationError` — the same per-case channel a throwing generator already used — so the report exit that was always there emits its JSON, names the cause, and still exits non-zero: - -```json -{ "id": "invoice_with_line_items", - "generationError": "Failed to score the generated stack: poison getter", - "passed": false, - "score": { "score": 0, "grade": "F", "valid": false } } -``` - -Nothing new appears on the `--json` face: no new key, no new payload shape. The failing exit was already reachable for a throwing generator; it is now reachable for a poisonous one too. - -The failed case is scored `0 / F / valid: false` rather than as an empty stack. An empty stack scores 100 / A / valid, and stamping that on a stack nobody could parse would have put a clean-looking verdict next to a failure — the crash replaced by a quiet wrong answer. - -Unchanged: offline mode, and every off-shape stack a generator can return. Bad metadata is still **scored**, with its schema errors as the reason it fails — it is not rerouted into the failure channel. diff --git a/.changeset/lint-field-no-consumers.md b/.changeset/lint-field-no-consumers.md deleted file mode 100644 index b50a7bf6c1..0000000000 --- a/.changeset/lint-field-no-consumers.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -New advisory rule `field-no-consumers` (`validateFieldConsumers`): a field declared on an object that nothing in the stack reads or displays is reported as a `warning` by `os validate`, `os build` and `os lint`. - -Until now such a field was schema-valid and passed every platform check — the declaration was inert and nothing in the toolchain said so. The rule is object-aware (the same field name on two objects gets two verdicts, resolved against the object whose declaration encloses each reference), and it distinguishes consumers from carriers: a view column, form section, page binding, flow node, dataset dimension, widget filter, formula, validation, hook or action is a consumer; a translation label, a seed value, an import-mapping column, a field-level permission grant or a flow that only writes the field is a carrier and never counts. The finding carries the verdict (`carrier-only` with the carrier paths a removal must clean, or `inert`), the roots scanned, and — when the name is also declared elsewhere — the other objects, so a per-object verdict is never mistaken for a name-level one. - -Exempt, each derived from the spec rather than listed by hand: the registry-injected system columns an author re-declared, the record's title field (ADR-0079 `nameField` ladder), and `master_detail` fields (ADR-0035 — cascade delete, `controlled_by_parent` sharing and roll-ups read the relationship by declaration). A stack that declares no consumer root at all (objects only, or objects plus carriers) is not judged: its consumers live in another package. Test fixtures are never scanned. - -Public surface: `validateFieldConsumers`, `FIELD_NO_CONSUMERS`, `FIELD_CONSUMER_ROOTS`, `FIELD_CARRIER_ROOTS`, and the `FieldConsumerFinding` / `FieldConsumerVerdict` / `FieldConsumerSeverity` types. diff --git a/.changeset/lint-flow-template-rules-reach-http-payload.md b/.changeset/lint-flow-template-rules-reach-http-payload.md deleted file mode 100644 index 64b5dbdf3c..0000000000 --- a/.changeset/lint-flow-template-rules-reach-http-payload.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`flow-double-brace-interpolation` and `flow-bare-dollar-reference` now read an `http` node's request payload. Both rules were blind to the whole of `config.body` on every node type — the one key where an uninterpolated token has an outbound consequence. - -The recursive template scan in `lint-flow-patterns.ts` read a region-stripped view of each node's config, and it built that view from the FLAT UNION of every config key that holds a region on *any* node type (`body`, `try`, `catch`, `branches`) rather than from the slots the node in hand actually owns. `body` is `loop`'s region slot **and** the canonical request-payload key on an `http` node, so `config.body` was deleted from every node's view before the scan ever read it. - -That made the two rules silent exactly where they matter most: `http-nodes.ts` interpolates the raw config wholesale, so a double-brace `{{ record.title }}` or a bare `$source.id` written in a payload is never interpolated and ships to the endpoint as literal text. Measured before this change, an `http` node whose `body` carried either token shape — at the top level or nested inside a `try_catch` region — produced zero findings from either rule. - -- **The call site passes its own slots.** `stripRegions(node.config, ownRegionKeys(node.type))`. The remedy was already written in `stripRegions`' own docblock ("Pass the OWNING node's slots, not the flat union") and the sibling call site in `flow-walk.ts` already followed it; this one did not. -- **The trapping default is gone.** `stripRegions`' `regionKeys` parameter is now REQUIRED. The flat union survived as a default only to bound an earlier change, and the cost of leaving it was this defect: the shorter call compiled and quietly asked a different question. A caller that has not decided which set it means now fails to compile instead. -- **The double-count direction is unchanged and pinned.** A token inside a `loop` body is still reported exactly ONCE, against the node that carries it and not also against the container — the reason the strip exists, and the direction that breaks if a repair over-corrects to stripping nothing. - -Both rules keep their existing severity. New findings appear only where a `{{ }}` or bare `$ref.field` sits in a previously-hidden key; measured across `examples/app-showcase`, `app-crm` and `app-todo` (34 flows, `http` payloads inside a `parallel` branch and a `try_catch` try among them), the count is unchanged at zero — those payloads use correct single-brace tokens. diff --git a/.changeset/lint-form-layout-list-binding.md b/.changeset/lint-form-layout-list-binding.md deleted file mode 100644 index 00ce125be8..0000000000 --- a/.changeset/lint-form-layout-list-binding.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`validateFormLayout` now resolves the bound object for a view container's default `form` (and its `formViews.*` entries that declare no binding of their own) when that container names its object only on the `list` block (`list.data.object`, `list.object` or `list.objectName`) and nowhere on the container itself. - -Before this fix, `containerObject` had no way to see a list-only binding, so `objName` stayed `undefined` for every site under such a container — and `form-field-unknown` / `form-section-group-unknown` never fired there, however wrong the section content was. This is the same fallback rung `validate-translatable-sections.ts` already carries for its own sites; it is now shared by both. `absolute-colspan-discouraged` is unaffected by this change — it was never gated on the object binding (it needs only a field's `colSpan`), so it already fired on a list-bound container's form sections before this fix. - -Consequence: a view whose object binding lives only on `list` and whose default `form` (or an unbound `formViews.*` entry) references a nonexistent field or an undeclared `section.group` now gets a `warning` finding it did not get before. A stack with no such dangling reference sees no new output. diff --git a/.changeset/lint-generator-requires-eval.md b/.changeset/lint-generator-requires-eval.md deleted file mode 100644 index f83be20b1a..0000000000 --- a/.changeset/lint-generator-requires-eval.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -**BREAKING** `os lint --generator` now refuses to run without `--eval`, instead of accepting the flag and ignoring it. - -The flag's own description has always ended "Requires --eval.", and nothing checked it. `--generator` is read only by eval mode, so outside `--eval` the flag reached no code at all: `os lint --generator ./gen.mjs` linted the current project, exited 0 with "All checks passed", never loaded the module, and named the flag nowhere on either the human face or `--json`. A path that did not exist was accepted just as readily. Someone who meant to score a live generator got a successful-looking run whose generator was never called, with nothing said. - -The refusal is this command's own, not the argument parser's, so it keeps the shape the command's other failures already have: the reason on `error`, exit 1, and on `--json` a single JSON document with stdout still reserved for the machine. No error code is invented for it. - -A scripted invocation that passed `--generator` outside eval mode now exits 1 with the reason, where it previously exited 0 having silently skipped the generator. Eval mode itself is untouched: `--eval --generator` still loads the module and scores live output, and `--eval` alone still scores the bundled corpus offline. - - diff --git a/.changeset/lint-hook-rules-reach-handler-authored-hooks.md b/.changeset/lint-hook-rules-reach-handler-authored-hooks.md deleted file mode 100644 index 18a9223e30..0000000000 --- a/.changeset/lint-hook-rules-reach-handler-authored-hooks.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/lint": minor ---- - -`objectstack lint` now judges hooks authored as inline `handler` functions with the same write-set rules it already applied to explicit `body` hooks. - -The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, so on the stack `objectstack lint` handed the rule registry the whole family returned before reading anything — while the reference app authors every one of its hooks that way. `objectstack build` never had the gap: it lowers each inline handler to a metadata body before it parses and judges the lowered stack. - -`objectstack lint` now hands the registry's parsed-tier rules that same lowered view (the `lowerCallables` pass the build runs), so a handler-authored hook writing a `readonly` field through `ctx.api` is refused by the pre-flight exactly as the build would refuse it. What this does and does not change: - -- A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack lint` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` with the same finding, so nothing that built green fails lint red. -- The warning-severity members of the family (`hook-body-write-unknown-field`, `hook-api-update-readonly-when-field`, …) now report on inline handlers too; they never fail a run without `--strict`. -- Nothing about what `objectstack build` accepts changes, and `objectstack validate` — which parses without lowering — is unchanged and still does not see handler-authored hooks; both are recorded in the rules' headers. -- The lint input is never mutated: rules that read the live function value (`hook-body/not-lowerable` and its siblings) keep seeing it, and a handler the extractor refuses has no body on any command, so no rule guesses about a body that was not produced. - -Measured on this repository's own four example apps (`examples/app-crm`, `app-showcase`, `app-todo`, `app-multi-package`), before and after: **121 findings before, 121 after — row for row identical, and zero at `error` on both sides.** No config that passes today starts failing. Two of the six hooks in that corpus are `handler`-authored and were invisible to this family before; their bodies write nothing the family objects to, which is why the delta is zero rather than the family being unreached. The reach itself is pinned separately, with a body-authored control beside every leg. - -`@objectstack/lint` carries only the header ledger recording which *intakes* reach each hook rule — the call sites of `runAuthoringRules`, which are more numerous than the three commands (the scaffold validator is a fourth, and it has always reached this family). Its behaviour is unchanged. diff --git a/.changeset/lint-label-case-localized-guard.md b/.changeset/lint-label-case-localized-guard.md deleted file mode 100644 index 25fd2527f0..0000000000 --- a/.changeset/lint-label-case-localized-guard.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os lint` no longer crashes on a localized label. - -`convention/label-case` indexed its argument (`label[0].toUpperCase()`) on a parameter annotated `string`, while every call site reaches it through `any`-typed config walking and the spec does not require a label to be a string: `I18nLabelSchema` is `z.union([z.string(), InlineLocaleMapSchema])`. On the map form `label[0]` is `undefined`, the rule threw a `TypeError`, and the throw escaped `lintConfig` into the command's catch-all — so an author who localized an app label or a list-view label could not lint the project at all. Every face exited 1 with `Cannot read properties of undefined (reading 'toUpperCase')`, naming no rule, no path and no remedy, on input `ObjectStackDefinitionSchema` parses clean. - -The rule now checks `typeof label === 'string'` first. Two of the four carriers it walks accept the inline locale map — `apps[].label` (`AppSchema`) and a view's `list` / `listViews.*` labels (`ListViewShapeSchema`); the other two are `z.string()` and reject the map at the schema door (`objects[].label`, `objects[].fields.*.label`). - -**Nothing about a plain string label moves.** Same warning, same message, same `fix`, same path, on all four carriers — that is pinned per carrier rather than asserted. - -**The rule deliberately says nothing about a localized label**, rather than resolving the map and case-checking one of its entries. Case is a property of a literal, and deciding which locale entry a case verdict is taken against is a product call, not a lint call. Widening the rule that way is a separate change. diff --git a/.changeset/lint-non-record-collection-entry.md b/.changeset/lint-non-record-collection-entry.md deleted file mode 100644 index 57da994b25..0000000000 --- a/.changeset/lint-non-record-collection-entry.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -No authoring rule throws on a non-record entry of any stack collection. - -A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape. - -Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before. - -The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked. diff --git a/.changeset/lint-non-record-objects-readers.md b/.changeset/lint-non-record-objects-readers.md deleted file mode 100644 index 81d217ab75..0000000000 --- a/.changeset/lint-non-record-objects-readers.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): every `stack.objects` reader skips a non-record entry, so no authoring rule throws on the publish door - -A `null` member of `stack.objects` — what an empty YAML list item -deserialises to, and what a partial editor write leaves behind — crashed -13 of the 42 `AUTHORING_RULES` with -`TypeError: Cannot read properties of null (reading 'name')`. The -authoring rules are pure `(stack) => Finding[]` (ADR-0019) and run on the -RAW `lint` path as well as the parsed one, so nothing upstream had judged -the entry's shape. At the runtime publish gate they are called inside the -gate rather than behind a try/catch of their own, so the throw was an -exception on a WRITE path, not a skipped finding; on the CLI, `os lint` / -`os validate` / `os compile` died on the first one instead of reporting -the stack. - -The repair before this one guarded ONE seam — the object-graph index every -field-path rule opens with. The crash stood at fourteen more readers of -the same collection, each a hand-copied `asArray` whose array branch was -an unchecked `v as AnyRec[]`. Copies are why: the defensive spelling was -already present in about a dozen siblings and absent in the rest, so -fixing one left the others answering the old way. - -So the copies are gone. `recordsOf` — the guarded reader, exported from -`object-graph.ts` and package-private — is now the one coercion from a -collection authored as an array OR as a name-keyed map into the records it -holds, and fifteen files call it: - -- `validate-expressions.ts`, `validate-list-view-mode.ts`, - `validate-widget-bindings.ts`, `filter-walk.ts`, - `validate-object-references.ts`, `validate-record-title.ts`, - `validate-form-layout.ts`, `lint-autonumber-formats.ts`, - `lint-view-refs.ts`, `validate-org-axis-red-lines.ts`, - `validate-sharing-rule-enforceability.ts` — the eleven sites that threw. -- `validate-searchable-fields.ts`'s `indexObjectSearchTargets` and - `validate-page-field-bindings.ts`'s `indexObjectFields` — two shared - indexers inside the reference-integrity suite, each in front of two - rules and both hidden behind whichever suite member threw first. -- `object-field-groups.ts`'s `indexObjectFieldGroups`, which the - re-measure surfaced only once the eleven above stopped throwing. -- `validate-security-posture.ts`, the one that never threw: an `[]` - member passed its `typeof v === 'object'` read and drew a second - `security-owd-unset` at `object "(object 0)"` — an `error` about an - entry no author wrote. - -The verdict is a SKIP, not a finding, matching the seam it extends: a junk -`objects` member is a SHAPE defect and belongs to the schema, every rule -already re-answers the question in its own per-object guard, and reporting -it at the reader would emit one finding per member for one bad entry. On -the name-keyed map shape a member whose VALUE is unreadable keeps its key -(`{ name }`) — the author named it, only its body is illegible. - -No rule tier, id, message or accept-set changes. A valid object standing -beside a junk one is judged exactly as it is judged alone; only a path -index moves, and only for the rules that index `objects` raw, where -`objects[1]` is the honest position. diff --git a/.changeset/lint-preset-comparand-field-typed-arm.md b/.changeset/lint-preset-comparand-field-typed-arm.md deleted file mode 100644 index 3964edc750..0000000000 --- a/.changeset/lint-preset-comparand-field-typed-arm.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -`filter-preset-comparand` gains a FIELD-TYPED arm (#16106, maintainer-ruled 1′): on a declared `date` / `datetime` field, a dashboard date-range preset name (`last_30_days`, `this_quarter`, …) is now refused in EVERY comparand position — bare (implicit equality), `$eq` / `$ne`, `$in` / `$nin`, and their view-rule (`equals` / `not_equals` / `in` / `not_in`) and triple (`=` / `!=` / `in` / `nin`) spellings — with the same located message and prescription the ordering positions already carry (`{ $gte: '{30_days_ago}' }` for `last_30_days`, and so on). The field type is read from the stack's own object graph: a dashboard widget or report through its `dataset` to that dataset's `object`, a view through `data.object`, a flow CRUD node through `config.objectName`, a page component through `dataSource` / `properties`, an object's own list views and `relatedListFilter`, a summary field's child object. A position the graph cannot bind, a registry-injected column, a `time` field, or a select / text column stays unjudged — equality against a picklist value that collides with a preset name is a working filter. The field-agnostic schema door in `@objectstack/spec` keeps its ordering-only boundary unchanged; this closes the authoring-time gap where `objectstack lint` and the runtime publish gate accepted a filter the engine then refused with `INVALID_FILTER` / 400 on first render. diff --git a/.changeset/lint-readonly-create-scan-gap.md b/.changeset/lint-readonly-create-scan-gap.md deleted file mode 100644 index 0f7d3cedaa..0000000000 --- a/.changeset/lint-readonly-create-scan-gap.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`flow-update-readonly-field` and `hook-api-update-readonly-field` now report a non-system **create** of a static-`readonly` field — a new **error**-severity finding that fails `os lint` / `os validate` / `os build` on a shape they used to accept. - -Both rules scanned only the update verb (`update_record`; `ctx.api…update()` / `.updateById()`) and justified the omission with the same sentence: INSERT is engine-exempt from the author-declared `readonly` strip, so a create that seeds a `readonly` column is not a no-op. The maintainer ruling of 2026-09-03 (option C, #14147) made that false — `engine.insert` now runs the same `isSystem`-gated `stripReadonlyFields` the update path runs — so a flow `create_record` without `runAs: 'system'`, or a hook body's `ctx.api.object('…').insert()` under a non-system trigger, that writes a `readonly` field became a **silent no-op**: the row lands without the column (which falls back to its `defaultValue`), the step reports `success`, and only a run-time warning names the dropped field (measured end to end in `@objectstack/service-automation`'s `create-record-readonly-drop.test.ts`). Nothing reported it at build time. This closes that scan gap (#15394). - -**What now fails that passed before.** Exactly one new shape per rule, at `error`: - -- a flow `create_record` node whose literal `fields` map writes a field the target object declares `readonly: true`, on a flow that does not declare `runAs: 'system'`; -- an L2 hook body's literal `ctx.api.object('').insert({ … })` writing such a field, on a hook that does not declare `runAs: 'system'`. - -The rule ids and severities are the update ones — one id per shape, not per verb — and each finding's message names the verb it was judged on and what actually happens to a create. Everything the rules already skipped is still skipped: a templated object name, a non-literal payload, an object outside the stack or declaring no fields, an unknown field (the unknown-field rules' question), and any `runAs: 'system'` flow or hook, because seeding a `readonly` column at create time is a system act and that write lands. - -**Deliberately not reported.** - -- No `readonlyWhen` (conditional) finding on a create, on either surface: a conditional lock is evaluated against the record being written over, which a create does not have, and the engine runs no conditional strip on INSERT ("INSERT stays exempt"). A warning there would state something false about a write that lands. -- The hook rule judges `.insert()` only, not `.create()`. The host `ObjectRepository` aliases `create()` to `insert()`, but L2 bodies run in QuickJS and the VM-side `ctx.api.object()` installs no `create` leaf — a body calling `.create()` throws `TypeError: not a function` on its first run, a loud failure rather than the silent drop this rule reports. The silence is recorded as a reasoned method exclusion (`READONLY_HOOK_METHOD_EXCLUSIONS`) and pinned. -- No create finding on a **platform object** — one declaring `managedBy`, or in the reserved `sys_` namespace. The engine's create-side strip does not judge those at all (`staticReadonlyInsertSubject`: their own ADR-0086 write guard governs them), so a finding there would describe a strip that never runs. The update verb keeps judging them, exactly as the engine's update path does. -- `validate-readonly-action-writes` is unchanged: an action body runs system-elevated by design, so its create genuinely lands. - -**Migration.** If your build reds on the new finding, the fix is one of: declare `runAs: 'system'` on the flow or hook when seeding the `readonly` column is the intent (the intended channel — `readonly` governs the end-user/API surface, not trusted system writers); remove the key from the `create_record` `fields` / `insert()` payload when it is not; or stamp it in a `beforeInsert` hook on the target object (`ctx.input. = …`), which is a server value the strip does not touch. Measured over this repository's shipped examples (`app-crm`, `app-showcase`, `app-todo`): zero in-repo flows or hooks go red — the two `create_record` nodes that target an object carrying a `readonly` field write none of its `readonly` fields, and the one flow that creates unauthenticated already declares `runAs: 'system'`; no shipped hook body inserts through `ctx.api`. diff --git a/.changeset/lint-validate-expressions-non-record-field-entry.md b/.changeset/lint-validate-expressions-non-record-field-entry.md deleted file mode 100644 index dae0004f6d..0000000000 --- a/.changeset/lint-validate-expressions-non-record-field-entry.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`validateStackExpressions` no longer throws on a non-record entry in an object's `fields:` list. - -An empty item in a YAML `fields:` list deserialises to `null`, and `buildFieldIndex` cast each member of the list inline (`fields.map(f => (f as AnyRec).name)`) before the `.filter` two calls later could drop it. `Array.isArray` proves the LIST, never its MEMBERS, so linting such a stack failed with `TypeError: Cannot read properties of null (reading 'name')` out of the whole rule instead of reporting anything about the file. - -The list is now read through `recordsOf` — the one place that coercion is decided — which drops a non-record member of the array shape whole and in **silence**: it carries no author-written name, so there is nothing to report about it. That matches what the two sibling field readers in the same module (`buildFieldTypeIndex`, `fieldEntries`) already did with the same member, so the three readers now agree. The readable siblings of the junk member are still indexed, so unknown-field findings on that object continue to be reported. - -The map shape (`fields: { amount: { … } }`) is unchanged: there the author's key is the field name, which is what this index needs. diff --git a/.changeset/list-user-invitations-declared-verification.md b/.changeset/list-user-invitations-declared-verification.md deleted file mode 100644 index db025d1b21..0000000000 --- a/.changeset/list-user-invitations-declared-verification.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -`GET /organization/list-user-invitations` now honours the declared `requireEmailVerificationOnInvitation` — the per-user invitation inbox works for the unverified sessions it was declared open to - -`AuthManager` constructs better-auth's organization plugin with `requireEmailVerificationOnInvitation: false` on purpose: without a mailer wired in, nothing can ever verify an invitee, so requiring verification would dead-end every invite flow. The pinned better-auth 1.7.2 reads that option on `accept-invitation`, `reject-invitation` and `get-invitation`, but its `listUserInvitations` handler refuses every unverified session unconditionally. Measured on the real pipeline: the same unverified invitee got `200` from all three id-addressed routes and `403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION` from the listing, so on exactly the deployment shape the declaration exists for, an invitee could accept an invitation they were handed but never list it, and the SDK's `organizations.invitations.listMine()` inbox page was empty-by-403 for every user. - -The endpoint is now rebuilt in place on the organization plugin's own `endpoints` record, from the vendor endpoint's own options object (same path, method, query schema and OpenAPI entry), with one predicate changed: the verification refusal is asked against the declared option instead of assumed. The listing itself is still the vendor's own `getOrgAdapter(...).listUserInvitations(sessionEmail)` — invitations addressed to the session's email, pending only — so nothing widens beyond what the same session can already accept one by one. A client-side `?email=` is still refused with the vendor's `400`, and a request with no session keeps the vendor's `400`. - -Declared `true` keeps today's refusal byte-for-byte; an undeclared option keeps the vendor's list-route posture (refuse) rather than re-deriving the vendor-internal default the sibling routes use. No new public error code, no new export from the package entry. diff --git a/.changeset/list-view-grouping-server-side-contract.md b/.changeset/list-view-grouping-server-side-contract.md deleted file mode 100644 index 109fe84521..0000000000 --- a/.changeset/list-view-grouping-server-side-contract.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): list-view grouping is server-side — the group header query and the per-group row page compile from the view (#14556) - -Maintainer ruling A on objectui#7189 (2026-09-02): grouping on a list view is -server-side. The set of groups and every number in a group header — the count -and any per-group aggregation — are properties of the query, not of the fetched -page; rows inside a group are paged. Grouping one fetched window (the interim -behaviour) rendered two headers (86, 14) or five (31/31/30/7/1) for the same -186 rows in five units depending on row order, and left the rows past the -first window unreachable. - -The contract reuses the query shapes the platform already has — no new query -shape, no new engine verb, no new envelope: - -1. **The group keys and every header number are ONE aggregate query** - (`EngineAggregateOptions`, executed by `IDataEngine.aggregate`): `groupBy` - is `grouping.fields[].field` in nesting order (a multi-level grouping is a - multi-column `groupBy`), `aggregations` is a `count` node (the group's total - row count, alias `count`) plus the view's declared column summaries mapped - onto `AggregationFunction` — the one aggregation vocabulary datasets already - use — and `where` is the view's composed filter. -2. **The rows inside a group are the existing paged `find`** - (`EngineQueryOptions`) with the group's key predicate AND-ed into the view - filter, `limit` / `offset` per group. - -New on the `ui` entry, `view-grouping-query.ts`: - -- `compileListViewGroupQuery(view, { where?, depth? })` → the header query; - `compileListViewGroupRowsQuery(view, groupKey, { where?, limit?, offset?, orderBy?, fields? })` - → the row page; `listViewGroupKeyPredicate` (the empty group is spelled with - the `$null` predicate — the spelling the view filter dialect's `is_empty` - lowers to). -- `COLUMN_SUMMARY_AGGREGATION` — the `ColumnSummary` → aggregation table, - exhaustive by type: `count` → a fieldless `count` (`COUNT(*)`), - `count_unique` → `count_distinct`, `sum` / `avg` / `min` / `max` → the same - name, `none` → nothing; `count_filled` / `count_empty` / `percent_filled` / - `percent_empty` map by derivation — one `{ function: 'count', field }` node - (`COUNT(field)`, the non-null count, header column `count_`), from - which `deriveColumnSummary(row, summary, field)` computes all four on the - header row (`count_filled` = `count_`, `count_empty` = `count − - count_`, `percent_filled` = `count_ / count`, 0 when the count - is 0, `percent_empty` = `1 − percent_filled`). Server-side "empty" is `null` - on every face; the footer's client-side reading of `''` / `[]` as empty is - the renderer's to converge. A future member with no counterpart is refused - loudly at compile time (`ListViewGroupQueryError`, `NOT_IMPLEMENTED` / 501, - the summary's path — `UNMAPPED_COLUMN_SUMMARIES`, empty today); a value that - is no member at all is `INVALID_QUERY` / 400. -- Result-column naming on a header row: each grouped field under its own name - (raw stored value, `null` for the empty group; group keys are scalar), `count`, - and each summary under `_` (`columnSummaryAlias`). - -`GroupingConfigSchema` / `GroupingFieldSchema` / `ColumnSummarySchema` now say -this in their docs, with the shape's recorded limits (a date grouping field -groups per distinct stored instant; header cardinality is unbounded). Nothing -changes in what parses: no key is added, removed or re-shaped. `minor` because -a new exported helper and a declared contract semantics ship; not breaking — -the page-scoped behaviour was never declared. Both queries ride the existing -`POST /data/:object/query` door (`protocol.findData` → `engine.aggregate`, -answering `{ object, records, total, hasMore }`); the grid consuming the header -rows is objectui#7189. diff --git a/.changeset/listdrafts-updated-at-canonical-iso.md b/.changeset/listdrafts-updated-at-canonical-iso.md deleted file mode 100644 index e41f028056..0000000000 --- a/.changeset/listdrafts-updated-at-canonical-iso.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -`SysMetadataRepository.listDrafts` emits the ISO-8601 string its own signature declares for `updatedAt` - -`listDrafts` declares `updatedAt: string | null` on an inline TypeScript return type and reached the field through `row.updated_at ?? row.created_at ?? null`. `??` fires only on nullish, so the JS `Date` that Postgres and MySQL materialise for the builtin audit columns walked straight past it into a field the declaration calls a string. Driven through the published door, the pre-fix build answered `typeof "object"` and the visible text `Wed Mar 04 2026 05:06:07 GMT+0000 (Coordinated Universal Time)` where the same build's `dist/index.d.ts` promised `string | null`; it now answers `2026-03-04T05:06:07.089Z`. - -`updated_at` / `created_at` are builtin audit columns: `SqlDriver#formatOutput` repairs them (and folds declared `datetime` columns) only inside its `if (this.isSqlite)` arm, and `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp` deliberately untouched because those are instants. Nothing reported the mismatch — the declaration is an inline return type rather than a Zod schema, so a schema search finds nothing, and `rows` is cast `as any[]` one line above the map, so tsc saw a `string` assignment that never happened. - -Canonicalised at the producer through the same adapter boundary `rowToItem` already uses, with the terminal chosen per call site: `null` here, because the chain being replaced already ended in `?? null` and that is what "absent" already means to this projection's consumers. An Invalid `Date` — reachable on both live dialects — takes that same branch instead of raising. Already-canonical SQLite text passes through byte-identically, and `updatedBy` is unchanged: `updated_by` / `created_by` are `Field.lookup('sys_user')` string columns, which the dialect asymmetry never reaches. - -No published declaration moves: `dist/index.d.ts` and `dist/index.d.cts` are byte-identical across the fix, which already declared `updatedAt: string | null` before it. A JavaScript consumer that read the raw value and called a `Date` method on it, or stringified it, sees the corrected shape. diff --git a/.changeset/litekernel-enforces-plugin-contract.md b/.changeset/litekernel-enforces-plugin-contract.md deleted file mode 100644 index cfb54fe9b5..0000000000 --- a/.changeset/litekernel-enforces-plugin-contract.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/core": minor ---- - -`LiteKernel.use()` now enforces the declared plugin contract — the same check, the same refusal, as `ObjectKernel.use()`. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at registration on **both** published kernels instead of on one. - -**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.** Until this release `LiteKernel.use()` wrote the object straight into its registry: `PluginSchema` was run by `PluginLoader.validatePluginContract` only, and `PluginLoader` is reached from `ObjectKernel.use()` alone. So the same plugin was accepted by one kernel and refused by the other — a `type: 'ui'` plugin with no `slug` was refused by `ObjectKernel` with `PLUGIN_CONTRACT_VIOLATION` and mounted a route on `LiteKernel`. `AGENTS.md` names `LiteKernel` for tests, serverless and edge, so the lenient kernel was the one authors develop against and the strict one was production: a plugin could be green in vitest and refused at boot. Maintainer ruling of 2026-09-08 (option A, under the precedent that the two kernels converge rather than diverge): `LiteKernel` validates too. - -**Exactly what `LiteKernel.use()` newly refuses** is exactly what `ObjectKernel.use()` has refused since the `kernel.use()` enforcement release: all EIGHT declared 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. -- **`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 eight**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. - -**What a refusal looks like — one refusal, from either kernel.** The check is now one function (`assertPluginContract`, package-internal) that both kernels call, so the code and the message are produced once: - -``` -PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared -plugin contract at 'slug': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` plugin must declare `slug` — … -``` - -`LiteKernel.use()` is synchronous and throws that error as-is, so the stable code is on the error's `code` property as well as at the head of the message. `ObjectKernel.use()` is unchanged: it still re-wraps a failed load as `Failed to load plugin: - `, its existing wrapper for every load failure. The text after that prefix is byte-for-byte the `LiteKernel` message for the same input, pinned by test. - -**What is STILL ACCEPTED on `LiteKernel` — the narrowing stops where `ObjectKernel`'s does.** Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the very object passed in). A version-less plugin still loads, and so do `1.0.0-alpha.1` and `1.0.0+20230101`: `version` is excluded from the schema check on both kernels, and `LiteKernel` — which has never judged `version` — still does not. A plugin declaring no `type` still loads and still stores no `type`. A class-based plugin keeps its identity, its prototype and its prototype methods. And `PluginLoader`'s structural checks (`name`, `init`, semver) stay the loader's own: the convergence is on the schema, not on the loader. - -**Ordering, stated because it is observable.** `LiteKernel.use()` checks its state first (a kernel past bootstrap still says `Cannot register plugins after bootstrap has started`, never `PLUGIN_CONTRACT_VIOLATION`), then the contract, then registers — so a refused plugin never reaches the registry and cannot supersede an earlier registration under its name. - -**Blast radius, measured before landing rather than assumed.** Across this repository's suites, 813 `LiteKernel.use()` calls were reachable; 807 were accepted by the schema unchanged and the six refusals came from three test-local fixture objects in two files — zero product or library code. Externally authored plugins registered on `LiteKernel` are the population this reaches, and they are exactly the plugins that would already have been refused by `ObjectKernel` at production boot. - -**Migration.** There is nothing to rename. A plugin refused on `LiteKernel` now was already refused on `ObjectKernel`; fix the named key: give `type` a value from the closed set (or drop it — an absent `type` reads as `standard`), declare `staticPath` and `slug` on a `type: 'ui'` plugin, spell `slug` in `[a-z0-9-_]`, make `homepage` a URL, and never `null` a declared key. The refusal names the plugin and the first violated key. - - diff --git a/.changeset/liveness-carved-out-of-the-identity-step.md b/.changeset/liveness-carved-out-of-the-identity-step.md deleted file mode 100644 index 1e7108813a..0000000000 --- a/.changeset/liveness-carved-out-of-the-identity-step.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -`GET /api/v1/health` answers 200 whenever the process can serve HTTP, even while a configuration fault is making every other route 503. - -The dispatcher resolves a per-request identity before any route handler runs, and that step reads the tenancy posture for every request — credentialed or not. Since a `tenancy` service that is registered and fails to build is (correctly) re-raised as a 503 rather than absorbed into "there is no posture", an uncredentialed liveness probe was answered 503 for the length of the outage. A liveness probe reads 503 as *restart me*; the service then fails to build again on the new pod. A restart cannot fix a service that cannot build, so the result was a restart loop that hid the very fault the 503 exists to make loud. - -**Liveness is now carved out of the identity step.** `GET /health` runs its handler directly: no identity resolution, no configuration read, no credential read — the payload it answers (`status`, `timestamp`, `version`, `uptime`) was already process-local. Wire it to `livenessProbe`. - -**Readiness is unchanged, deliberately.** `GET /ready` keeps the full identity step and its 503 body, so traffic is withheld until the fault is fixed and existing operator dashboards keep the signal they have. Wire it to `readinessProbe`. Nothing else about the 503 moved: every other route, and an environment-scoped `/environments/:id/health`, answers exactly as before. - -Which routes count as liveness is **derived from the dispatcher's own route table** rather than listed anywhere: a route declares `liveness: true` on its registry entry, and `DomainHandlerRegistry.resolveLiveness()` answers through the same matcher that picks the handler — so the set cannot drift from the routes that exist. `DomainRoute.liveness` and `resolveLiveness()` are additive public surface on `@objectstack/runtime`; a route that does not declare the flag is untouched. diff --git a/.changeset/liveness-ledger-reaudit-16362.md b/.changeset/liveness-ledger-reaudit-16362.md deleted file mode 100644 index d2d122e1fc..0000000000 --- a/.changeset/liveness-ledger-reaudit-16362.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Liveness ledger: four verdicts re-derived and corrected ahead of the author-warning flip. - -The ledger's `dead` and `live-elsewhere` verdicts are about to start warning downstream authors, so each row was re-measured against a pinned tree — objectstack `5d55afec4d`, objectui `a472b071` — with a firing positive control on the same instrument and corpus before any zero was read as a reading. - -- **`validation.label` / `.description` / `.tags`: `dead` → `live`.** The 2026-08-10 sweep upheld `dead` on *reachability*, not on the read: `ValidationPreview` genuinely rendered all three, but the only route that mounted it was the standalone `validation` resource door, and ADR-0088 had retired that kind — so on the governed path (a rule embedded in its object) the preview was never handed a draft. That note named its own falsifier, and it has since landed: the standalone door is gone, and `EmbeddedItemEditor` now resolves `getMetadataPreview(editAs)` and mounts the preview on the live draft, with the embedded anchor binding `editAs: 'validation'`. Under the ruling that a designer preview rendering a key to a human is a runtime consumer, these three display keys are live. They remain docs-shaped and are still not author-warned. -- **`view` `list.tabs`: `live` → `dead`.** The previous note was wrong in both directions at once. It credited objectui's `TabBar` with reading `icon`/`visible`/`pinned`/`filter` — true of the component, but **nothing mounts it**: every `TabBar` render site in the whole renderer tree is its own definition or one of its two test files, and `ListView` never reads `tabs` off the view schema, so authoring `list.tabs` draws no tab bar. And it called `tabs[].order` a dead sub-surface while `getVisibleTabs` sorts on exactly that key. The two author-time readers that do walk the key (a field-reference lint and the metadata diagnostics) check `tabs[].filter[].field` for reference integrity and deliver none of the key's declared effect — validated-then-ignored is accept/reject, which this ledger has always kept separate from liveness. - -No published surface moves: these are ledger JSON rows plus the generated count table, with no export, key, or accept-set change. The `list.tabs` re-grade does mean an author who writes tabs on a list view will be told the key is inert — which it is, and was. diff --git a/.changeset/lucky-doors-tickle.md b/.changeset/lucky-doors-tickle.md deleted file mode 100644 index 20e2591bb4..0000000000 --- a/.changeset/lucky-doors-tickle.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/objectql': patch -'@objectstack/lint': patch ---- - -Stop reporting a declarative `operation: 'update'` action as "a button wired to nothing" - -The boot action-governance inventory (ADR-0110 D5) built its `unboundDeclarations` -finding from a `type`-only test. The declarative single-record field write -(`operation: 'update'` + `patch`, #14092) is exactly the shape that test mistakes -for a dead button: `ActionSchema` refuses `target` and `body` beside it and keeps -`type` at its default `script`, because the platform action route is where the -write is performed. Every such action was named at every boot and every -`metadata:reloaded` — with a prescription ("add a `body`, or register a handler -under the declared `target`") that parse itself refuses. - -Both readers now read `operation` before `type`, the precedence the runtime doors -already use: the engine inventory, and the authoring-time AI tool-reference rule, -which had diverged from the runtime's listing door and reported a resolvable -`action_` reference as fictional. diff --git a/.changeset/lucky-poems-invite.md b/.changeset/lucky-poems-invite.md deleted file mode 100644 index a4280d280a..0000000000 --- a/.changeset/lucky-poems-invite.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-hono-server": minor ---- - -fix(plugin-hono-server): the current-user faces assemble their `ExecutionContext` through the shared assembler (#15747) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). - -`makeExecutionContextResolver` is exported from this package's index. Its declared return moves **from** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise` — in practice `any`, since the exported function carried no return annotation at all and the envelope it built was a hand-rolled object literal cast `as any` — **to** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise`. `any` is assignable to everything and admits every property read, so a consumer's code really can stop compiling. - -What this asks of a consumer holding the resolver directly (the serverless host path that composes it, cloud#924): narrow the `undefined` arm before reading the envelope — under `strictNullChecks` the resolver has always been able to answer `undefined` for a request with no session, and no caller was ever asked to handle it; and stop reading members `ExecutionContext` does not declare, since the receiver is no longer `any`. A consumer that only calls `registerCurrentUserEndpoints` sees no change. - -The envelope itself is now assembled by `assembleExecutionContext` (`@objectstack/core`) — the fail-closed entry every other HTTP transport already uses — instead of the hand-rolled literal, which omitted six fields of the closed entry set: `principalKind`, `onBehalfOf`, `audience`, `accessToken`, `authGate` and `oauthScopes`. `principalKind` is `'human'` on these faces, the value the shared assembler derives for a session-backed principal; the other five are withheld on the record. A field added to `ExecutionContext` from now on fails to compile here until this face decides it. - -No runtime behaviour changes: `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` answer byte-identical bodies, pinned as goldens. - - diff --git a/.changeset/magic-link-reads-recipient-locale.md b/.changeset/magic-link-reads-recipient-locale.md deleted file mode 100644 index d1ddb5cf4f..0000000000 --- a/.changeset/magic-link-reads-recipient-locale.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): the magic-link mail reads the recipient's own `sys_user.locale` (#15106) - -`sendMagicLink` was the last of the five auth mail sends still on the two-rung -#14319 ladder — the request's `Accept-Language`, then the deployment default. -#14762 put the recipient's stored `sys_user.locale` above both at the three -sends that hold a user row, and #14641 reached the invitation; the magic link -was fenced out because it is handed `{ email, url, token }` and no row, so the -column has to be read on the address rather than on an id. The visible cost was -one deployment answering the same person in two languages: a Chinese -password-reset mail and an English magic link, decided by whichever browser -happened to send the request. - -It now reads the column behind the existing placeholder-address refusal, in the -same shape #14641 gave the invitation send — one projected `findOne` on -`sys_user` under a system context, best-effort, and never a reason a send fails. -This completes the #14788 option-D ladder (`sys_user.locale` when set → the -request's `Accept-Language` → the deployment default) across the whole auth mail -surface: all five `sendTemplate` sites now answer per recipient. - -The request rung is kept rather than replaced. A magic link is requested BY its -recipient, so its `Accept-Language` is the recipient's own and remains a -legitimate second rung for an account that has stated no language; ruling D -inserts the column above the header, it does not remove the header. - -Two branches, because a magic link is also a sign-up: an address that carries a -row is written in that account's language, and an address with no row keeps -exactly the previous behaviour. The address is lowercased for the lookup — -better-auth applies no case transform to the magic-link request body, while -`findUserByEmail`, which `/magic-link/verify` resolves the very same link with, -matches on `email.toLowerCase()`, so the column is read for the row the link -will sign into. An address that resolves nothing lands on the rungs below, which -is the documented floor. diff --git a/.changeset/manifest-integrity-unpack-tsdoc-truth.md b/.changeset/manifest-integrity-unpack-tsdoc-truth.md deleted file mode 100644 index af644e2de1..0000000000 --- a/.changeset/manifest-integrity-unpack-tsdoc-truth.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`manifest.integrity`'s TSDoc no longer asserts an unpack-time verification that nobody performs. - -The `integrity` docblocks said that per-file re-verification at unpack is the **cloud control plane's** obligation. The cloud repo's own design docs said it is the **runtime's**. Neither side unpacks anything, so the two published texts pointed at each other and a reader of either learned that a verification exists when none does. This text ships in `@objectstack/spec`'s `.d.ts`, so the wrong claim reached every consumer that hovered the field. - -Both `integrity` docblocks in `manifest.zod.ts` — `PluginIntegritySchema` and the `ManifestSchema` field — now state what is true: - -- **Computed and self-checked by the publisher.** `os plugin build` computes the map into the compiled manifest, and the `os plugin publish` preflight re-hashes the artifact bytes against it, refusing the upload on a digest mismatch, a declared entry with no file, or a packaged file the map does not declare. An absent map is a permissive pass — the field is `.optional()`. -- **Not re-verified at unpack.** That leg is not implemented: there is no `os plugin install`, and the archive reader's only production caller is the publish preflight reading back its own output. It is owned by the **future runtime loader** (ADR-0025 §3.5 steps 4–7), not by the control plane, which stores the artifact blob opaquely. The enforce leg is tracked on #11331. - -No schema, export, key or accept-set changes — the field's shape, optionality and `.describe()` are untouched, and a present `integrity` map validates exactly as before. What changes is that the documentation no longer advertises a guarantee the runtime does not deliver. - -The liveness ledger row for `integrity` and its README note carry the same corrected attribution. The row's `status` (`dead`) and `verifiedAt` are deliberately unchanged: this is a prose correction, not a re-measurement. diff --git a/.changeset/manifest-open-object-prose-sweep.md b/.changeset/manifest-open-object-prose-sweep.md deleted file mode 100644 index de9df125f0..0000000000 --- a/.changeset/manifest-open-object-prose-sweep.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/core": patch ---- - -Documentation: the manifest surface no longer describes itself as an open object. - -`ManifestSchema` became a `strictObject` when the manifest surface was closed against unknown keys, but five prose sites still described the earlier posture. They shipped, so an author (or an AI writing metadata) reading the declarations was told the manifest tolerates undeclared keys — while the runtime rejects them by name and offers the declared spelling for a near miss. Prose that contradicts a tightened contract teaches exactly the wrong reflex, so each site now states the current refusal rather than merely dropping the old claim: - -- `AssembledPackageBodySchema`'s docblock no longer explains its lack of a `strictObject` spelling by calling `ManifestSchema` open. The posture is inherited: the schema is `ManifestSchema.extend(...)`, and `.extend()` carries the base's unknown-key handling, so an undeclared key on an assembled body is refused — measured, with the rename suggestion intact. -- The artifact-registration seam kept the half of its reasoning that still holds (the schema applies defaults, so a parsed clone would not be byte-identical) and retired the half that does not ("Zod strips undeclared keys") — the key is now refused at that parse rather than dropped from the clone. -- The `os compile` per-package rule pass explains why a body may be re-read as its own manifest: nothing parses that superset, and against `ManifestSchema` it would now be refused. - -No schema, behaviour or export changed; `check:api-surface` and the generated reference pages are unmoved. diff --git a/.changeset/manifest-repository-directory-backfill.md b/.changeset/manifest-repository-directory-backfill.md deleted file mode 100644 index 7bcc56eccc..0000000000 --- a/.changeset/manifest-repository-directory-backfill.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/connector-mcp": patch -"@objectstack/connector-openapi": patch -"@objectstack/connector-rest": patch -"@objectstack/connector-slack": patch -"@objectstack/embedder-openai": patch -"@objectstack/knowledge-memory": patch -"@objectstack/knowledge-ragflow": patch -"@objectstack/plugin-approvals": patch -"@objectstack/plugin-email": patch -"@objectstack/plugin-pinyin-search": patch -"@objectstack/plugin-reports": patch -"@objectstack/plugin-sharing": patch -"@objectstack/service-sms": patch -"@objectstack/trigger-api": patch ---- - -These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. - -npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. - -Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. - -The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. diff --git a/.changeset/map-node-progress-state-lifetime.md b/.changeset/map-node-progress-state-lifetime.md deleted file mode 100644 index 6f841aa239..0000000000 --- a/.changeset/map-node-progress-state-lifetime.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A `map` node inside a `loop` body now runs its collection on every iteration, not just the first. - -`map` tracks its progress through the collection in the flow variable `.$mapState`, and wrote it into the flow's **shared** variable scope without ever removing it. A `loop` body region runs in that same scope by construction — that is what makes the iterator variable and the body's mutations visible to the rest of the flow — so the state written by iteration 1 was still there when iteration 2 entered the map. It read back `started === collection.length`, correctly concluded there was nothing left to start, and returned. - -The result was silent partial work reported as success: measured on the engine, **5 iterations x 2 items produced 2 child runs instead of 10**, the map step reported `success` on all five iterations, and the run finished `completed`. Nothing threw and nothing was caught, so `FlowRunSummary.failed` — the run-level counter that exists to expose contained failures — reported `failed = 0` over it. An operator reading that counter was told the run was clean while it had done a fifth of its work. - -The fix is a lifetime correction, not a new key: `$mapState` is now removed once the collection is exhausted, so its lifetime is one execution of the collection rather than the enclosing scope's. - -**The durable-pause path is deliberately unchanged.** A `map` whose per-item subflow pauses still writes its progress before suspending, and still reads it back when the engine re-enters the node — that write is the mechanism resume depends on, because a resume rebuilds the variable scope from the snapshot taken at the suspend and so can never see any later write. Only the node's terminal path clears the key. A `map` resumed mid-collection continues where it left off, exactly as before, and no item is re-run. diff --git a/.changeset/mcp-oauth-resource-registration.md b/.changeset/mcp-oauth-resource-registration.md deleted file mode 100644 index efccae745b..0000000000 --- a/.changeset/mcp-oauth-resource-registration.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -MCP OAuth can complete again: the MCP resource is registered as an RFC 8707 resource and DCR-registered clients are linked to it, so `authorize?resource=` no longer answers `invalid_target`. - -On 17.3.0 no MCP client could ever obtain a token. `plugin-auth` configured `@better-auth/oauth-provider` with `validAudiences: [authIssuer, mcpResourceUrl]`, an option the pinned 1.7.2 does not read — the string does not occur once in its dist. In 1.7.2 a requested `resource` is resolved from the `oauthResource` table (`sys_oauth_resource`) and `enforcePerClientResources` defaults to `true`, so the client must also be linked in `oauthClientResource` (`sys_oauth_client_resource`). Neither row was ever written, so every client that sends `resource=` — Claude Code does — was refused at `/oauth2/authorize` with `invalid_target: requested resource is not configured`. Discovery, dynamic client registration and the login page all worked; the flow died one step before consent. - -- **`resources: [mcpResourceUrl]`** seeds the `sys_oauth_resource` row from the provider's own `init`. Seeding is idempotent and defaults to `insertOnly`, so an administrator's later edits to the row's token policy are never reverted by a restart. -- **`clientRegistrationDefaultResources: [mcpResourceUrl]`** links each newly registered client to that resource inside the DCR transaction. This is the only place the link can be made: a client registers anonymously about one second before the browser login, leaving no window for an administrator to insert the row by hand. -- **`enforcePerClientResources` is left at its `true` default.** The per-client linkage check stays on — the fix makes the link exist rather than switching the check off. A client with no link row is still refused with `invalid_target`, and a test asserts that. -- **`validAudiences` is removed.** It was passed and read by nobody, which is precisely how the defect survived a version bump: it looked like configuration and enforced nothing. - -Two boot-path defects the resource seed uncovered are fixed in the same change, because seeding is the first thing this package ever wrote from a plugin `init`: - -- **`getAuthInstance()` now settles better-auth's plugin `init` hooks before it resolves.** `betterAuth()` returns synchronously and runs those hooks behind `auth.$context`, so a failure inside one had no catcher and escaped as an unhandled rejection — which Node terminates the process for by default. A boot failure now rejects the call that asked for the instance. -- **The no-`dataEngine` development fallback builds its own in-memory adapter instead of letting better-auth build one.** better-auth 1.7.2 keys that store by the schema *key* while every read resolves by `modelName`, so on that path every model this package renames was unreachable — `user`/`sys_user` as much as `oauthResource`/`sys_oauth_resource` — answering `Model not found`. Production never took this branch (it uses the ObjectQL adapter); development and tests did. - -No configuration change is required. Deployments that already ran 17.3.0 get the resource row on the next boot; MCP clients that failed to connect need to reconnect so a fresh registration picks up the link. diff --git a/.changeset/mcp-stdio-tenancy-posture.md b/.changeset/mcp-stdio-tenancy-posture.md deleted file mode 100644 index 8f4288c510..0000000000 --- a/.changeset/mcp-stdio-tenancy-posture.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/mcp": patch ---- - -The MCP stdio transport now vets an API key's organization against the deployment's tenancy posture, instead of trusting the key's own stored claim. - -`resolveStdioExecutionContext` — the whole of this transport's authorization, since every caller on it is an API key by construction and there is no session path — built its own header map and called `resolveAuthzContext` with no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one (`organization_required` at admission, `organization_membership_ended` after grants), so a door that supplied none ran neither: the key's `sys_api_key.active_organization_id`, never re-checked against current membership, became the request's tenant. Under a wall-enforcing posture a key stamped with an organization its owner had left read and wrote that organization's rows through this door. - -The posture is now derived in the plugin's `start()`, where the kernel is reachable, and threaded into the resolver. What changes for a deployment: - -- Under `isolated` or `group`, a stdio transport configured with a key whose owner is no longer a member of the organization the key names refuses to start, and a key already live is refused on its next call. Under `isolated`, an organization-less key is refused the same way. Both refusals are logged server-side naming the key, principal, organization and reason; nothing about them reaches the caller. -- A kernel that registers no `tenancy` service is unaffected: no organization wall exists there, so no posture-conditional refusal is made. That is the supported composition, not a degraded one. -- A `tenancy` service that is registered and **fails to build** now raises `SERVICE_UNAVAILABLE` (503) rather than reading as "no posture". A posture that could not be read is not a posture that is absent, and admitting on one is the permissive-on-failure shape this repair exists to avoid. - -The posture is re-read per call, on the same schedule as the identity beside it (ADR-0101 D1), so a wall that comes up or a membership that ends mid-session takes effect on the next call rather than at the next restart. diff --git a/.changeset/membership-ended-session-revoke.md b/.changeset/membership-ended-session-revoke.md deleted file mode 100644 index d6725426ab..0000000000 --- a/.changeset/membership-ended-session-revoke.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/plugin-auth': minor ---- - -`sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out - -Removing a member deleted the `sys_member` row and left the session alive, for up to seven -days. #15409 closed the security half per request (a session whose `activeOrganizationId` -is not backed by a membership resolves with no active organization). This is the courtesy -half an admin was promised, and it is **never the enforcement**: a trigger can be missed, -an evaluation cannot. - -- **New `revoke_reason` value, `organization_membership_ended`** — an accept-set widening - on a published system object, hence `minor` on `@objectstack/platform-objects`. Every - reason before it is a timer (`idle_timeout`, `absolute_max`, `concurrent_cap`) or an - interactive revoke (`user_revoked`, `admin`); this is the first authorization-event - cause. There is no Zod enum behind the column — it is free `text` — so the field's own - description is the published vocabulary, and that is where the value is declared. The - string deliberately matches the one the API-key arm of the same ruling family already - mints for this event (`ApiKeyRefusalReason` in `resolve-authz-context.ts`), so one grep - finds every place the platform acts on a membership ending. -- **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling, - decision batch #49 item 4, option B). A user who still holds another membership is - **re-pointed** to it — never signed out of organizations they legitimately belong to. A - user with no remaining membership has their session revoked through the existing - `revoked_at` / `revoke_reason` mechanism, which expires it in place: better-auth returns - nothing on the next request and the Console's existing 401 → login redirect handles it, - with **no client change**. -- **The seam is an engine hook on `sys_member`**, not a hook on better-auth's - `/organization/remove-member`. A census measured that the endpoint, a direct delete, a - bulk delete, the cascade from a `sys_user` delete and an organization re-point all reach - the hook, while an endpoint hook would have reached one of them. Same precedent as - `last-admin-guard.ts`. -- **New public surface on `@objectstack/plugin-auth`** — `MEMBERSHIP_ENDED_REVOKE_REASON`, - `endSessionClaimsForEndedMembership` and `registerMembershipEndedSessionTrigger`, hence - `minor` rather than `patch`. - -Known open by measurement, not by omission: a raw driver delete bypasses the trigger -entirely, and cloud's package-uninstall sample-data purge is one (filed as cloud#2003). The -per-request check covers it; the courtesy does not. diff --git a/.changeset/memory-analytics-date-range-timezone.md b/.changeset/memory-analytics-date-range-timezone.md deleted file mode 100644 index ec09397297..0000000000 --- a/.changeset/memory-analytics-date-range-timezone.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -`driver-memory` analytics honours `AnalyticsQuery.timezone` when it resolves a string `dateRange`, instead of accepting the field and answering on UTC (#16042) - -`AnalyticsQuerySchema` declares `timezone` optional with no default precisely because an absent value is a meaningful state the engine resolves (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and `service-analytics` resolves that whole chain and writes the answer into `query.timezone` before a driver ever sees it. `parseDateRangeString()` never read it: a caller asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was accepted, warned about nothing, and answered on the UTC day. - -⚠️ **This changes which rows a query answers for a caller already passing `timezone`.** Measured at `2026-09-06T20:00:00Z` with `timezone: 'Asia/Shanghai'`, `'today'`: - -| | window | rows selected, from the same 8 probes | -|:--|:--|:--| -| before | `[2026-09-06T00:00:00.000Z, 2026-09-07T00:00:00.000Z)` — the UTC day | `06T00:00:00.000Z`, `06T15:59:59.999Z`, `06T16:00:00.000Z`, `06T23:59:59.999Z` | -| after | `[2026-09-06T16:00:00.000Z, 2026-09-07T16:00:00.000Z)` — Shanghai's day | `06T16:00:00.000Z`, `06T23:59:59.999Z`, `07T04:00:00.000Z`, `07T15:59:59.999Z` | - -Four rows either way, and **two of the four are different rows**: `2026-09-06T00:00:00.000Z` and `2026-09-06T15:59:59.999Z` leave the answer (they are yesterday in Shanghai), `2026-09-07T04:00:00.000Z` and `2026-09-07T15:59:59.999Z` join it (they are today in Shanghai). Both row sets are asserted against the real `MemoryAnalyticsService.query()` entry, in the same test, so the before is measured rather than recalled. - -A query carrying **no** timezone is byte-identical to before: `zonedDateStartToUtcMs` returns plain UTC midnight for an unset, `'UTC'`, or unknown zone, so #15825's repair — the common case — is untouched, and both of its pins stay green. - -Two halves, each of which fails silently on its own and each of which is pinned: the reference timezone decides **which** calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern), and **where that day begins as an instant** (`zonedDateStartToUtcMs` — that zone's local midnight, which is what ADR-0053 already specifies for a `datetime` bound in `service-analytics`' drill ranges). Resolving only the first would anchor to the zone's calendar day and then cut it at UTC midnight — a window that is neither the UTC day nor the zone's. The end bound is a calendar step, never `+ 86_400_000`: on `America/New_York`, 2026-03-08 is 23 hours long. diff --git a/.changeset/memory-analytics-date-range-utc-window.md b/.changeset/memory-analytics-date-range-utc-window.md deleted file mode 100644 index 11a57cf6c3..0000000000 --- a/.changeset/memory-analytics-date-range-utc-window.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -`driver-memory` analytics resolves `dateRange` on the UTC calendar, so `'today'` and `last N ...` stop being offset by the process timezone (#15825) - -`MemoryAnalyticsService.query()` lowers a string `dateRange` through -`parseDateRangeString()`, and that function built its window on the **local** -calendar and rendered it as **UTC**. Two independent defects lived in it. - -**1. The window boundary was local midnight.** `new Date(y, m, d)` constructs -local midnight; `toISOString()` renders that instant in UTC. So in any process -not sitting at UTC, the `'today'` bucket was the **local** day expressed as a -UTC range. Measured 2026-09-05, with the clock at `2026-09-05T20:51Z`: - -| `TZ` | `'today'` window produced | the UTC day it should be | -|:---|:---|:---| -| `UTC` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | (agrees) | -| `Asia/Shanghai` | `2026-09-05T16:00Z` → `2026-09-06T16:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | -| `America/Los_Angeles` | `2026-09-05T07:00Z` → `2026-09-06T07:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | -| `Europe/Berlin` | `2026-09-04T22:00Z` → `2026-09-05T22:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | -| `Asia/Kolkata` | `2026-09-05T18:30Z` → `2026-09-06T18:30Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | - -That is wrong on **every day of the year**, with no DST transition needed. - -**2. The `last N ...` legs mixed two calendars.** `setDate(getDate() - n)` is -local arithmetic and `toISOString()` is a UTC rendering. `setDate` preserves -wall-clock time, so the instant moves `n × 24h` only while every local day in -the window is 24 hours long; across a DST transition it moves 23h or 25h and -the window start slips an hour. `setMonth` / `setFullYear` are the same class, -and can move it by a whole day: at `America/New_York` with the clock at -`2026-01-01T12:00Z`, `last 1 month` started at `2025-12-02T00:00Z` instead of -`2025-12-01T00:00Z`. - -**⛔ The two do not fix each other**, which is the easiest thing to get wrong -here: `setUTCDate` alone leaves the local-midnight boundary in place, and -`Date.UTC` alone leaves the arithmetic mixed. Both are repaired, each is pinned -by its own file, and each was ablated on its own to prove the separation. - -**Why UTC and not "any consistent calendar".** The rest of the platform -resolves a bare date to the UTC day — `@objectstack/core`'s `{today}` -filter-token macro builds its reference day as -`new Date(Date.UTC(year, month - 1, day))` and falls back to UTC parts when the -context carries no timezone, and `{TODAY()}` in flow templates resolves to the -UTC day (#14852 repaired the identical two-calendar shape there). Before this -change the same analytics question asked through the driver's `dateRange` and -through a flow token could select **different rows in one deployment**. UTC is -also the terminal fallback of the engine's own resolution chain -(`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2). - -**What did not change.** The parser's vocabulary, its `[range, range]` -fallback, and the shape of the emitted `$match` are untouched — this is a -calendar repair, not a rewrite. `AnalyticsQuery.timezone` is still not consulted -by this path; making the range tokens timezone-**aware** is a separate and -larger question, which #14852 also declined. - -**Who sees a difference.** Any deployment whose process is not at UTC: `'today'` -and `last N ...` now select the UTC day they always claimed to, so charts built -on a string `dateRange` shift by the process offset — toward agreement with -`{today}` / `{TODAY()}` and with the same query run at `TZ=UTC`. Deployments -already running at UTC are unaffected; the two spellings are indistinguishable -there, which is exactly why CI never reddened on this. diff --git a/.changeset/memory-i18n-declared-fallback-locale.md b/.changeset/memory-i18n-declared-fallback-locale.md deleted file mode 100644 index ab7d855cee..0000000000 --- a/.changeset/memory-i18n-declared-fallback-locale.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor -"@objectstack/runtime": minor ---- - -The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) - -`i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one. - -The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key. - -What changed: - -- **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with. -- **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged. -- **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared. - -A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix. - -`getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. diff --git a/.changeset/metadata-ambiguous-stem-refused.md b/.changeset/metadata-ambiguous-stem-refused.md deleted file mode 100644 index 33443247db..0000000000 --- a/.changeset/metadata-ambiguous-stem-refused.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -fix(metadata): two files sharing one stem are refused with both paths named, instead of one being listed twice and served by extension precedence (#14921) - -**BREAKING** accept-set narrowing on `FilesystemLoader`, shipped as `minor` -under the repo's launch-window convention for breaking changes. Ruled on -#14921 (2026-09-05, option 1 of three). - -**Remedy: delete or rename the duplicate file.** The refusal names every -colliding path and the metadata type, so the fix is visible at the point of -failure. - -`FilesystemLoader` derives a metadata name by stripping a flat file's -extension, and resolves a name back to a file under a FIXED extension -precedence (`.json` → `.yaml` → `.yml` → `.ts` → `.js`). Two files sharing a -stem therefore produced one name **twice** in `list()` while only the -first-precedence file was reachable through any name at all. With -`object/twin.json` and `object/twin.yaml` both present, `list()` answered -`['twin', 'twin']`, `twin.yaml` was addressable through nothing, and -`loadMany()` returned both bodies. `MetadataManager.listNames()` unions loader -output into a `Set`, which collapsed the duplicate and took the count -discrepancy with it — the file stayed unreachable either way, so a clean -`listNames()` was never evidence the collision had been absorbed. - -The invariant that broke: **what is listed is what is loadable.** The listed -set and the addressable set stopped being the same set. The failure was silent -in the direction that matters for authoring — convert `twin.json` to -`twin.yaml` and leave the old file behind, or land one from each of two -packages, and the JSON one is served forever with no diagnostic anywhere, -while `admitLoaderItems()`'s documented "keep the first and say nothing" -absorbs the collision a second time. - -`FilesystemLoader.list()` now throws `AmbiguousMetadataStemError` -(`AMBIGUOUS_METADATA_STEM`, HTTP 500) naming both paths and the type, and the -same refusal fronts the shared `loadMany()` / `loadManyKeyed()` walk, so the -two-body answer is gone rather than de-duplicated. `MetadataManager.listNames()` -and `list()` **propagate** it rather than absorbing it into their per-loader -degradation: an ambiguous stem is an authoring error no retry fixes, and -degrading it would drop every item the loader holds into a short-but-served -list while the server keeps reporting healthy. A real storage outage still -degrades exactly as before — the seams discriminate on a branded predicate, -`isAmbiguousMetadataStemError`, not on a blanket rethrow. - -**Refused shape**, precisely: two or more files **directly under -`ROOT/TYPE/`** whose basenames differ only by an extension belonging to one of -**this instance's registered serializers**. Register `javascript` and -`dual.json` + `dual.js` becomes ambiguous; under the manager's default format -set (`typescript` / `json` / `yaml`) it is not, because `.js` derives no name. -Nested files are untouched — they are neither listed nor resolvable (#14486), -so `crm/solo.json` beside a flat `solo.json` is not a collision. The refusal is -scoped to the type directory that holds it: a clean `view/` still lists while -`object/` refuses. - -New exports from the package root entry: `AmbiguousMetadataStemError`, -`isAmbiguousMetadataStemError`, `AMBIGUOUS_METADATA_STEM_CODE`, -`AMBIGUOUS_METADATA_STEM_STATUS`. - -Measured migration cost, which is what makes this narrowing cheap: **no tree in -this repository carries the shape.** A walk of all 7,770 tracked files across -526 directories found zero stem collisions among `.json` / `.yaml` / `.yml` / -`.ts` / `.js`, confirmed independently by a `git ls-files` pass, and the repo -holds no `.yaml`/`.yml` metadata file at all outside CI and workspace config. -No existing tree goes red. - - diff --git a/.changeset/metadata-database-loader-ttl-ms.md b/.changeset/metadata-database-loader-ttl-ms.md deleted file mode 100644 index 75b3841a93..0000000000 --- a/.changeset/metadata-database-loader-ttl-ms.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -feat(metadata)!: `DatabaseLoaderOptions.cache.ttl` → `cache.ttlMs` — the read-through cache TTL carries its unit in the key name (#14478) - - - -**BREAKING** rename on the exported `DatabaseLoaderOptions.cache` shape -(`DatabaseLoaderCacheOptions.ttl` → `ttlMs`), shipped as `minor` under the -launch-window convention. `MetadataManager` hands `config.cache.databaseLoader` -straight to `new DatabaseLoader({ cache })`, so this option is the spec key -`cache.databaseLoader.ttlMs` one layer down and renames with it: a loader -configured with `ttlMs: 60_000` expires entries after 60 seconds exactly as -`ttl: 60_000` did. The README example and the kernel metadata-service docs page -spell the new key. - -```ts -// before -new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttl: 60_000 } }); -// after -new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttlMs: 60_000 } }); -``` diff --git a/.changeset/metadata-endpoints-switch-radius-maintenance-key.md b/.changeset/metadata-endpoints-switch-radius-maintenance-key.md deleted file mode 100644 index cbc798a903..0000000000 --- a/.changeset/metadata-endpoints-switch-radius-maintenance-key.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/rest": minor ---- - -feat(spec): every `metadata.endpoints.*` switch gates exactly the face its name states, and the whole-store operations get their own key `maintenance` (#15542, #15854) - -`RestServerConfig.metadata.endpoints` declared three switches, each `describe()` naming -exactly one route, and each gated a different set. The mismatch ran in **both** -directions at once: - -- **`items`** — declared "GET /meta/:type - List items of type" — also gated the - whole-store family: the cross-type spec-validation sweep `GET /meta/diagnostics`, the - draft list `GET /meta/_drafts`, and the **`POST /meta/_migrate-stored` write door**. - An operator who switched off a listing read they considered chatty silently unmounted - a migration door. -- **`item`** — declared "GET /meta/:type/:name - Get specific item" — gated four - *reads* (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and left the - per-item **writes** `PUT` and `DELETE /meta/:type/:name` plus the whole history family - (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) answering to - `api.enableMetadata` alone. An operator who closed the per-item surface left its - writes mounted. - -Neither is a liveness defect — all three keys were genuinely read — which is why no -ADR-0049 census could ever flag them: what drifted was each key's **radius** against its -own documentation. - -**One principle now holds across the block: a switch gates exactly the face its name -states, reads and writes alike.** - -| key | mounts it gates (default prefix `/meta`) | -|---|---| -| `types` | `GET /meta`, `GET /meta/types` — one handler, two paths (unchanged) | -| `items` | `GET /meta/:type` — and nothing else | -| `item` | `GET` / `PUT` / `DELETE /meta/:type/:name`, `/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`, and `GET /meta/book/:name/tree` | -| `maintenance` | **new** — `GET /meta/diagnostics`, `GET /meta/_drafts`, `POST /meta/_migrate-stored` | - -All four `describe()` strings are rewritten to enumerate what they gate, so the -generated reference page is the radius rather than a sample of it. -`api.enableMetadata` remains the master switch above all four, and -`GET /meta/object/:name/state/:field` — the object FSM read, addressed by object name -rather than by `:type/:name` — deliberately stays under that master switch alone. - -**BREAKING** — for a programmatic embedder that authors `RestServerConfig.metadata.endpoints`, -the mounted route table moves for two of the four keys, in opposite directions: - -- **`items: false` now removes one route instead of four.** An embedder relying on it to - close `/diagnostics`, `/_drafts` and the `POST /_migrate-stored` door **regains all - three** unless it also sets `maintenance: false`. That is a write door coming back, so - it is the half to read twice. One line restores the old table: - `endpoints: { items: false, maintenance: false }`. -- **`item: false` now removes twelve routes instead of four.** An embedder relying on it - to close only the per-item *reads* while keeping `PUT`, `DELETE` and the history family - mounted **loses those eight**. There is no key that restores them — the per-item face is - one face by this ruling — so an embedder that wants the writes keeps `item` on and - closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`. -- **The exported type `MetadataEndpointsConfigParsed` narrows: `endpoints` gains a - REQUIRED member `maintenance: boolean`.** `maintenance` is `z.boolean().default(true)`, - so it is optional on the way *in* and always present on the way *out* — and - `MetadataEndpointsConfigParsed` is `z.infer`, the - OUTPUT side. Any code that builds one of these objects by hand — a test fixture, a - helper returning the parsed shape, a `satisfies MetadataEndpointsConfigParsed` literal — - stops compiling with `TS2741: Property 'maintenance' is missing`. This one IS - compiler-carried (the ADR-0087 D8 class), which is the good case: the break is loud, it - lands at build time, and no runtime behaviour depends on the author noticing a - changelog. Add `maintenance: true` to restore the previous mounts, or `false` to keep - the whole-store family closed. In-repo consumers of the type: none — the narrowing was - measured against a probe compiled from the rebuilt declaration, not assumed. - -Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no -shipped boot path** today (`os serve` fixes the config and the dev plugin passes none, -#15543), so the measured population of affected authors is **zero** and the blast radius -is programmatic embedders only. That is precisely why this lands now — once a boot path -starts authoring the config, the same change becomes a behaviour change on live -operators. - -**ADR-0087 disposition: a D3 semantic migration, no D2 conversion.** No -authored key changes shape or spelling — `items: false` still parses to `items: false`, -`maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema` -stays the #14691 tombstone it already was). There is nothing for the conversion layer to -convert: a `RestServerConfig` is plugin TS configuration, never a stack collection member -and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no -rehydration seam sees it. What changes is a mounted route table at construction time. - -Nor is the RADIUS change compiler-carried on the AUTHORED side — and that is the half a -D3 is owed for. Every authored key is an optional boolean, so `{ items: false }` still -compiles and still parses and simply mounts a different table: the author is told -nothing. (The parsed-type narrowing in the third BREAKING bullet above *is* -compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot -reach the embedder who authored `{ items: false }` and now silently gets three routes -back.) So for the change that actually moves the route table, both channels that would -otherwise reach a consumer are blind, which is precisely the residue D3 exists for — the prescription is registered as -`metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands -it to an upgrading embedder instead of leaving it as prose in a changelog. - - - -`@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where -the gates live, so the route-table change is observable there and not only in the -declaration. - -Every key's radius is pinned route by route, in both directions, in -`packages/rest/src/rest-config-mount-table.pin.test.ts` — the #15544 shape, which asserts -each route is **absent from the mounted table** when its switch is off rather than what -the switch normalizes to. A gate that grows or loses a route reddens there. diff --git a/.changeset/metadata-manager-inert-cache-keys-retired.md b/.changeset/metadata-manager-inert-cache-keys-retired.md deleted file mode 100644 index 10d5e7b27a..0000000000 --- a/.changeset/metadata-manager-inert-cache-keys-retired.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata": patch ---- - -feat(spec)!: retire the three inert outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (formerly `ttl`) and `maxSize` — read by nothing; `cache.databaseLoader` is the only live half (#15624, ADR-0049) - - - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). -ADR-0049 enforce-or-remove decides it: a declared-but-unenforced key with zero -measured readers comes off, and the published reference page stops teaching it. - -`MetadataManagerConfig.cache` declared three outer knobs — `enabled` (default -`true`), `ttlSeconds` (default 3600; spelled `ttl` until #14478) and `maxSize` -("Max cache size in bytes") — beside the nested `databaseLoader` block, and -**nothing read the outer three**. The only runtime consumer of the block is -`MetadataManager` (`packages/metadata`), which hands `cache.databaseLoader` and -nothing else to `new DatabaseLoader({ cache })`; a reader census over -`packages/**` (tests and changelogs excluded) found no runtime reader of any -outer key, while the same grep shape found the nested `cache?.databaseLoader` -read twice — the control that makes the zero a measurement. An author writing -`cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse -and a cache that behaved exactly as before, with no error and no warning, and -the published reference page (`references/kernel/metadata-loader`) documented -all three as if they configured something. - -**What is refused:** authoring `cache.enabled`, `cache.ttlSeconds`, `cache.ttl` -or `cache.maxSize` on `MetadataManagerConfig`, with any value — directly, through -`MetadataManagerOptions`, or through `MetadataPluginConfig.storage`. The nested -object is not `.strict()`, so each key is a `retiredKey()` tombstone rather than -a bare deletion (a deletion would have stripped it in silence — the same no-op -one layer down): authoring it is a `tsc` error (`never`) and a parse error -carrying the prescription, which names the live nested knob. - -**What stays, byte-identical:** the DatabaseLoader read-through cache under -`cache.databaseLoader` — `enabled` (default `true`), `maxSize` (an entry count, -default 500) and `ttlMs` (milliseconds, default 60000) — and every runtime -path. Parsed configs no longer carry the two former defaults (`enabled: true`, -`ttlSeconds: 3600`) that were materialized and never consulted. - -**The #14478 rename is folded in.** `cache.ttl` → `cache.ttlSeconds` was -registered under this same unreleased major and never reached a published -release, so it is absorbed by the removal: `cache.ttl`'s tombstone now -prescribes deletion (naming `cache.databaseLoader.ttlMs`) instead of a rename to -a key that is itself retired — an author upgrading from a published 17.x sees -one hop. The nested `cache.databaseLoader.ttl` → `ttlMs` half of that rename is -unchanged. - -## FROM → TO - -```ts -// before — parsed green; no runtime ever read the three outer numbers -new MetadataManager({ - datasource: 'default', - cache: { enabled: true, ttlSeconds: 3600, maxSize: 10_485_760, databaseLoader: { ttlMs: 60_000 } }, -}); - -// after — delete the outer keys; the nested block is the cache that runs -new MetadataManager({ - datasource: 'default', - cache: { databaseLoader: { enabled: true, maxSize: 500, ttlMs: 60_000 } }, -}); -``` - -**Migration.** Delete `cache.enabled`, `cache.ttlSeconds` / `cache.ttl` and -`cache.maxSize`; nothing replaces them, because nothing ever consumed them. If -you meant to switch the cache off, cap it or set its TTL, write -`cache.databaseLoader.enabled` / `.maxSize` (entries) / `.ttlMs` (milliseconds) -— those are honoured. No `os migrate meta` conversion runs on this surface: a -`MetadataManager` config is not a stack collection member and never a stored -row, so the chain has no seam for it; the D3 semantic entry -`metadata-manager-config-inert-cache-keys-retired` carries the prescription -into `spec-changes.json`, the upgrade guide and the `spec_changes` MCP tool. - -The retirement kit: `retiredKey()` tombstones on all three (and the absorbed -`ttl`), `RETIRED_KEYS_BY_MAJOR[18]` entries for each, the D3 semantic entry -above (the #14478 entry's outer half is re-worded from a rename to a deletion), -negative pins asserting each prescription and a positive pin asserting the -parse output no longer materializes the retired defaults, the published -reference pages regenerated, and the hand-written docs page and this package's -README (`@objectstack/metadata` ships `README.md`, hence its `patch`) no longer -authoring `cache.enabled`. diff --git a/.changeset/metadata-view-container-leaf-subpath.md b/.changeset/metadata-view-container-leaf-subpath.md deleted file mode 100644 index ca89281928..0000000000 --- a/.changeset/metadata-view-container-leaf-subpath.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/metadata': minor -'@objectstack/objectql': patch ---- - -feat(metadata): `deriveViewContainerObject` gets a leaf `/view-container` subpath, so objectql's lean ADR-0076 entry stops loading the manager, chokidar, glob and js-yaml for a six-line pure function - -`packages/objectql/src/engine.ts` reached `deriveViewContainerObject` through -`@objectstack/metadata`'s ROOT entry. `core.ts` — the ADR-0076 lean entry — -re-exports `engine.ts`, so `@objectstack/objectql/core`'s module-init closure -inherited the whole root entry: `MetadataPlugin` -> `NodeMetadataManager` -> -`chokidar`, plus `glob`, `js-yaml` and `readdirp`. - -The same file already carried the answer 79 lines above, at its -`@objectstack/metadata/errors` import: that leaf subpath exists "precisely so a -cross-package consumer gets the predicate without the manager, the loaders or -the YAML/filesystem machinery behind the root entry". This is that pattern, -taken a second time. - -**Measured on the built artifacts, not asserted** — every module Node actually -evaluates when `@objectstack/objectql/core` is loaded in a fresh process, -recorded through a `module.registerHooks` load hook (ESM and CJS) plus -`require.cache`, byte sizes from `statSync`: - -| `@objectstack/objectql/core` | modules | bytes | -|:---|---:|---:| -| before (ESM `dist/core.mjs`) | 190 | 12,348,424 | -| after (ESM `dist/core.mjs`) | 185 | 11,849,808 | -| **delta** | **-5** | **-498,616 (-486.9 KiB)** | -| before (CJS `dist/core.js`) | 188 | 12,654,238 | -| after (CJS `dist/core.js`) | 183 | 12,141,034 | -| **delta** | **-5** | **-513,204 (-501.2 KiB)** | - -Six modules stop loading — `packages/metadata/dist/index.js` (237,747 B), -`js-yaml` (114,610 B), `glob` (82,749 B), `chokidar` (2 files, 54,220 B) and -`readdirp` (9,836 B) — and one 469-byte module takes their place. Marginal -module-init time for that root entry, measured on a warm lean closure, was -~22 ms (median of 7; 20.4-27.5 ms) out of ~630 ms. - -⚠️ The figure the finding was argued on — "~3.6 KB to ~450 KB" — is right about -the delta and wrong about the baseline: the lean entry's closure was already -~11.5 MiB before this import existed, dominated by `@objectstack/spec` -(9,587,914 B) and `zod` (567,918 B), neither of which the metadata root entry -contributes. What the root import cost was ~487 KiB *on top of* that, not a -closure of 450 KB. - -The derivation itself moves to `packages/metadata/src/view-container.ts`, a -module with **no imports at all**, and `view-container-expansion.ts` imports -and re-exports it, so `index.ts`'s root export and `plugin.ts` keep their -spelling and the symbol stays on the root entry — this subpath is an additional -door, not a relocation. A re-export shim onto `view-container-expansion.ts` was -tried first and rejected on measurement: esbuild tree-shakes the unused -`expandRuntimeViewContainer` but keeps its two `@objectstack/spec` import -statements, so that shim's own closure was 84 modules / 3,035 KiB. The real -leaf's is 1 module / 469 B. - -`expandRuntimeViewContainer` is deliberately not exported from the new subpath: -`metadata-manager.ts` is its only caller, the root entry does not export it -either, and it is the half that carries the spec machinery. diff --git a/.changeset/migrate-meta-chain-line-protocol-label.md b/.changeset/migrate-meta-chain-line-protocol-label.md deleted file mode 100644 index ea6d28af3d..0000000000 --- a/.changeset/migrate-meta-chain-line-protocol-label.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os migrate meta` no longer prints the protocol version under the word "runtime", where it read as the installed package version. - -The chain line used to end `(runtime 17.0.0)`. That number is `PROTOCOL_VERSION` — the protocol major padded to a semver — and it is not, and never tracks, the version of the installed `@objectstack/cli` or `@objectstack/spec`. On a 17.3.0 install the line appeared beside the real package versions of the same upgrade session (`npm view`, the changelog), so it read as "your runtime is 17.0.0": an apparent downgrade or a stale install, neither of which was true. - -The value was never wrong — the label and the semver form were. The line now states the fact in the protocol's own units: - -``` -Chain: protocol 17 → 17 (this runtime implements protocol 17) -``` - -The parenthetical is relabelled rather than dropped, because it carries a fact nothing else on screen does: when `--to` stops below this build's major, it is the only place the operator is told where the runtime actually stands (`Chain: protocol 16 → 16 (this runtime implements protocol 17)`). - -The `--json` payload is deliberately untouched: its `runtime` key still carries the same padded protocol semver. Renaming a machine-readable key is a contract change owing a reader census and a deprecation window of its own, and it is tracked separately — an e2e pin now asserts the key's current value so that move cannot happen silently. diff --git a/.changeset/nav-contribution-group-mis-aim-describe.md b/.changeset/nav-contribution-group-mis-aim-describe.md deleted file mode 100644 index 43745fdfe3..0000000000 --- a/.changeset/nav-contribution-group-mis-aim-describe.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`navigationContributions[].group` now documents the mis-aimed case, not only the omitted one (#14925) - -The `describe()` on that key said what happens when `group` is **omitted** and nothing about what happens when it is **present and names no group the target app declares** — which is the case that actually bites. A contributing package cannot see the target app's group ids at authoring time (the target app belongs to another package), so a wrong id is undetectable by reading the contributor's own source; and the platform **relocates** the items to the app's top level rather than refusing them, so the menu renders, a smoke test passes, and the information architecture has silently changed. - -The description now names that third case: it is **not refused**, the items are appended at the app top level anyway, and a `nav_contribution_group_missing` diagnostic is emitted — by the runtime at `warn`, and by **both** `os build` and `os validate` at compile time, in each command's `--json` payload under the existing `warnings` key. - -Prose only. `group` remains `SnakeCaseIdentifierSchema.optional()`, the accept set is unchanged and nothing is refused that was not refused before; the recorded authorable key surface (`authorable-surface.json`) and the schema manifest (`json-schema.manifest.json`) are byte-identical. What moves is the string an author reads: the generated reference rows in `content/docs/references/ui/app.mdx` and `content/docs/references/kernel/manifest.mdx`, and the `description` on the published JSON Schemas that embed `NavigationContribution` (its own schema, the bundled `objectstack.json`, and 22 `json-schema/api/*` and `json-schema/kernel/*` package envelopes). diff --git a/.changeset/nav-object-ungranted-hint-drops-gating.md b/.changeset/nav-object-ungranted-hint-drops-gating.md deleted file mode 100644 index 741ada350f..0000000000 --- a/.changeset/nav-object-ungranted-hint-drops-gating.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`nav-object-ungranted`'s hint no longer tells you to gate the nav entry with `requiredPermissions`/`visible` — that never cleared the finding, because the rule never reads either key. Gating restricts who can see the entry; it doesn't grant the object read, so a holder who clears the gate could still hit permission-denied, and the warning kept firing anyway. The hint (and the module doc-block) now name the two remedies that actually clear it: grant read on the object in a permission set (`allowRead: true` or `viewAllRecords`), or drop the nav entry. No behavior change — the rule fires and stays silent on exactly the same inputs as before; only the wording of the hint moved. diff --git a/.changeset/notification-event-migration-ledger-claims.md b/.changeset/notification-event-migration-ledger-claims.md deleted file mode 100644 index 4a107de6f6..0000000000 --- a/.changeset/notification-event-migration-ledger-claims.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `adr-0030-notification-event` joins `CREATION_ATTESTED_MIGRATION_IDS`, and its docblock states what a run may claim in the `sys_migration` ledger (maintainer ruling 2026-09-05 on #15710) - -The ADR-0030 notification-convergence migration id was registered so that -"has this cut-over run here?" is answerable at all, with its ledger semantics -deliberately left open on the constant. The maintainer has now ruled them -(decision batch #47 item 5, verbatim 「同意」 — the question batch #21 reserved), -and this release lands the spec half: - -- **Creation-attested.** A datastore created after the cut-over has no legacy - `sys_notification` inbox rows by construction, so the id is now a member of - `CREATION_ATTESTED_MIGRATION_IDS`. A store created from empty on this release - therefore carries a third attestation row in `sys_migration` at boot, in the - same uniform shape as the two ADR-0104 rows (`details.attested: - 'datastore-created-empty'`, `applied_at: null`, `blocking: 0`, `verified_at` - set for the fact observed at birth). Existing stores are untouched: - `attestFreshDatastore` writes only on a store it observed being created and - never overwrites a row, so a store created before this release attests - nothing new — its row for this id arrives with the first run of the migration. -- **The ledger-claim matrix**, on the constant's docblock, replacing the - registration-era "silence is not an answer": `last_run_at` on every completed - non-`error` run (`migrated`, `already_done`, `not_applicable`); `applied_at` - only on `migrated`; `verified_at` never set by a run (the migration has no - self-check, and `verified_at` means one passed); `blocking: 0`; - `details.outcome` carries the four-valued result; an `error` run writes no - claim at all. -- **Receipt, not gate.** Nothing reads the row as a precondition, and nothing - may: it is what an operator reads, in the shape the seed-tenancy repair - already uses (`verified_at: null`, `blocking: 0`), which - `isDataMigrationFlagVerified` answers `false` to by design. - -Additive: no authorable key, export or accept-set narrows, so no BREAKING -banner applies. Which caller writes the run receipt when the migration runs is -the runner's own contract (`@objectstack/metadata/migrations`) and lands -separately. diff --git a/.changeset/notification-event-migration-run-receipt.md b/.changeset/notification-event-migration-run-receipt.md deleted file mode 100644 index e2c05326b8..0000000000 --- a/.changeset/notification-event-migration-run-receipt.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/metadata": minor ---- - -A completed run of the ADR-0030 notification cut-over now records itself in the `sys_migration` deployment ledger, per the ruled claim matrix. - -`migrateSysNotificationToEvent` reports `migrated` / `already_done` / `not_applicable` / `error` to its caller and — until now — recorded nothing anywhere. Once that line had scrolled, "did this cut-over run here, and when" had no answer in the deployment even in principle. The ledger row is what answers it, and what a run of this migration may claim under `NOTIFICATION_EVENT_MIGRATION_ID` is stated on that constant in `@objectstack/spec/system`: - -- `last_run_at` — stamped on every completed non-`error` run (`migrated`, `already_done`, `not_applicable` alike). -- `applied_at` — stamped only on `migrated`. Never cleared: a later `already_done` leaves an earlier backfill's stamp alone, because the backfill really did happen. -- `verified_at` — never written, in either direction. This migration has no self-check, and `verified_at` means a self-check passed. On a store created after the cut-over the row already exists and `attestFreshDatastore` set `verified_at` at birth; that certificate survives a run untouched, because the column is omitted from the update rather than sent as `null`. -- `blocking: 0`, and `details` carrying `{ outcome }` verbatim. -- An `error` run writes no claim at all — it does not know what it did, so it does not say. - -**Receipt, not gate.** Nothing reads a row under this id as a precondition and nothing may: a gate would need the self-check that does not exist. The row is what an operator reads, in the shape `sys_migration` already documents for the seed-tenancy repair. - -Two additions to the published surface of `@objectstack/metadata/migrations`, both driven by that: a new `SysNotificationMigrationReceipt` type, and a new `receipt` member on `SysNotificationMigrationResult` reporting what became of the claim (`inserted` / `updated` / `not-claimed` / `no-ledger` / `failed`, with a reason on the last two). This directory takes no logger and reports to its caller, so the claim's own fate is reported the same way the migration's is — a receipt that could not be written is never swallowed. Reading a result is unaffected; code that CONSTRUCTS a `SysNotificationMigrationResult` by hand (a test double) now supplies `receipt`. diff --git a/.changeset/notification-migration-preserve-audit.md b/.changeset/notification-migration-preserve-audit.md deleted file mode 100644 index 17a62ca84b..0000000000 --- a/.changeset/notification-migration-preserve-audit.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/metadata': patch ---- - -fix(metadata): keep the original notification instant when migrating `sys_notification` to the event model (#16312) - -`migrateSysNotificationToEvent` materializes each legacy inbox row into a -`sys_inbox_message` and a `sys_notification_receipt`, back-dating both to the -notification's own `created_at`. Both writes passed no options bag, so they -relied on the audit binder's create-side `record.created_at ?? now` — the -laundering #15964 removed, on the maintainer ruling of 2026-09-06. Without -that accident, every migrated inbox row and receipt is stamped with the moment -the migration RAN: a user's whole bell history collapses to "all arrived -today". - -The two writes now declare `{ context: { preserveAudit: true } }`, the explicit -historical-import channel the same ruling deliberately kept (#3493; it is what -REST import's `treatAsHistorical` sets). This is not a bypass of audit — it is -the door audit left open for a historical import. No exported symbol, schema or -config key moves. - -**Release ordering.** `@objectstack/objectql`'s side of #15964 is itself still -an unreleased changeset, so no published version of this migration has ever -written the flattened timeline. Releasing the two together keeps it that way. - -**If a deployment did run it from a build that has both halves**, the original -timeline is recoverable rather than lost: the source `sys_notification` rows -are rewritten in place, never deleted or archived, and `created_at` is not -among the legacy columns the run clears — so the notification's own instant is -still on the event row and reachable from both new rows through -`notification_id`. diff --git a/.changeset/object-door-searchable-listview-refusal.md b/.changeset/object-door-searchable-listview-refusal.md deleted file mode 100644 index 3dd84b8d08..0000000000 --- a/.changeset/object-door-searchable-listview-refusal.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have. - -`#15254` closed this one key over: it crossed the reference-integrity suite onto the object write door for the object's own field-name **lists** (`highlightFields`, `publicSharing.redactFields`). The two members that read the *other* field surfaces an object carries — its ADR-0061 search set and its built-in `listViews` — still declared `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived. An object could publish clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail the same silent way downstream: the engine filters a stale search entry out without a word (`resolveSearchFields`), so `$search` scans a narrower set than declared — or, once every entry is stale, the auto-default set the author never chose — and a dangling column renders one field short. - -- **`validateSearchableFields` and `validateListViewFieldRefs` gain `object`** in their suite-member `runtimeTypes`. No new rule and no new finding class: the rule ids (`searchable-field-unknown`, `searchable-field-unsearchable`, `list-view-field-unknown`, `list-view-field-dotted`) and their severities are unchanged — they now reach the door where the author actually is. -- **The crossing carries the #9313 precondition.** Both members resolve only against `stack.objects`, the one collection every per-write snapshot carries, so neither opens a missing-collection false-positive channel; their `views[]` rungs simply find no `stack.views` on an object snapshot. -- **Measured before crossing**, at the door's own snapshot shape and differential, over every shipped object definition in the monorepo: 116 objects (platform-objects 48, showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1, qa 1), 105 built-in list views on 40 objects, 666 list-view field-naming positions and 5 `searchableFields` entries judged — **0 findings for both members, precision 1.0**, against synthetic probes that are refused. -- **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication. - -## Migration - -**A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `objects.proj_task.searchableFields[1]` or `objects.proj_task.listViews.all.columns[1]` — plus the string that was written and the fields the object actually has. - -To fix a refusal, do one of: - -- rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or -- drop the entry from the declaration; or, for `searchable-field-unsearchable`, target a text-like stored column instead of a virtual or non-scannable one. - -`os validate` / `os build` / `os lint` already reported these findings at the same severity, so a code-authored stack can be repaired before it reaches a publish. Objects that name a platform-injected system column are unaffected — both members resolve those per object and stay silent where the platform really provisions them. diff --git a/.changeset/object-graph-null-entry-guard.md b/.changeset/object-graph-null-entry-guard.md deleted file mode 100644 index 851c7d6b73..0000000000 --- a/.changeset/object-graph-null-entry-guard.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/lint': patch -'@objectstack/metadata-protocol': patch ---- - -A junk entry in `stack.objects` no longer crashes the reference-integrity rules, and a probe rule that throws is reported instead of read as "nothing wrong". - -`indexObjectGraph` is the first statement of every rule that resolves a field path, and it read each `stack.objects` member without checking it was a record — so a `null` entry (an empty YAML list item, a partial editor write) threw `TypeError: Cannot read properties of null (reading 'name')` before any rule's own per-object guard could run. Because these rules also run inside the runtime publish gate, that was an exception on a write path rather than a missed finding. The seam now drops non-record entries — silently, matching every sibling collection reader in the package — and the valid objects beside them are judged exactly as before. - -On the publish receipt, `runBuildProbes`' object plane wrapped its rule call in a catch that produced an empty finding list, so a crashed rule was indistinguishable from a clean object while `checked.objects` had already counted it. A rule that throws now surfaces as a `runtime`-layer `object_field_ref_rule_failed` error carrying the thrown message, so an unverified object never reads as a verified one. Probes still never fail the publish they verify. diff --git a/.changeset/object-kanban-limit-row-cap.md b/.changeset/object-kanban-limit-row-cap.md deleted file mode 100644 index 532a0d8cca..0000000000 --- a/.changeset/object-kanban-limit-row-cap.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `ComponentPropsMap['object-kanban']` declares `limit`, the row cap four objectui faces already implement (#16503, the spec half of objectui#8172) - -`object-kanban` gains one optional authorable key: - -```ts -limit: z.number().int().positive().optional() -``` - -Maximum number of records loaded onto the board (row cap), lowered to the top-level `$top` of the board's one query. The renderer default stays 100 and is documented rather than declared, so an unset key remains unset. The component-level `dataSource.limit` wins when both are set, and a bound named view's `pagination.pageSize` fills the key only when the component authored none — the `ElementDataSourceGate` precedence table, unchanged. - -Measured at the objectui pin this repo builds against (`.objectui-sha` = `a472b0716`): `plugin-kanban` reads `schema.limit` as the query's `$top` (wired by objectui#4025), `OBJECT_KANBAN_DATA_SOURCE` maps `limit: 'limit'`, `KanbanSchema` declares `limit?: number`, and `content/docs/plugins/plugin-kanban.mdx` teaches it with a typed snippet (`limit: 250`) plus a Properties row. The strict props map refused the key by name, so an author following the published docs wrote a node the save gate rejected with the same `unrecognized_keys` verdict a typo gets. Decision batch #68 (2026-09-07, option A): the contract declares the capability that is already implemented, documented and in use. - -Widening a published accept set (Clause-② yes): `safeParse({ objectName: 'x', limit: 250 })` now succeeds; every other undeclared key on the node is refused exactly as before. objectui#8172 publishes the key in the registry declaration on its side. diff --git a/.changeset/object-title-grade-provenance-caveat.md b/.changeset/object-title-grade-provenance-caveat.md deleted file mode 100644 index 18fbe97509..0000000000 --- a/.changeset/object-title-grade-provenance-caveat.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`ObjectTitleCompleteness.status` now documents which INPUT each grade describes. No predicate, no grade computation and no accept set moves — the classification is unchanged and correct; what changes is what the declaration tells the reader it means. - -`explicit` was documented as "an explicit pointer (`nameField`/`displayNameField`) is set". That is accurate about the predicate, and that is what made it dangerous: on a body served by a `/meta` read exit a pointer is present whether or not the author wrote one, because the exit replays the registry's object-materialization seam (`materializeServedObjectOnto`, which runs `provisionPrimary` in designate-only mode). So `explicit` there means "a pointer is present", never "the author designated this" — and the old wording invited the second reading at every call site. - -The corrected declaration says three things the old one left to inference: - -- **Provenance decides the grade.** On a served body `explicit` carries no authorship information, and `derived` is unreachable except where that replay withheld the designation. On a body captured before the write seam — an authored definition as written, which is what `os build` / `os lint` hand this predicate through `@objectstack/lint`'s `validateRecordTitle` — `explicit` really does mean the author wrote a pointer. -- **Authorship is not recoverable afterwards, deliberately.** The write-side inverse `stripProvisionedPrimaryFrom` removes the pointer exactly when it is byte-identical to what the derivation would produce, and says so in its own words: the two are indistinguishable by construction, the same bytes. Neither the served document nor the stored row can answer "did the author designate this?". -- **The consequence for callers.** Never build a check on `explicit` that needs the authored answer without first proving the input is a pre-write body. - -`objectTitleCompleteness` itself carries the one-sentence form of the same warning, because a caller hovering the function is on a different path from a caller hovering the grade. - -The correct words already existed in this repo — on the WRITE seam, where nobody reading the grade goes. This moves their substance to the grade. diff --git a/.changeset/objectql-boot-loop-refuses-divergent-view-name.md b/.changeset/objectql-boot-loop-refuses-divergent-view-name.md deleted file mode 100644 index 6b07355ea3..0000000000 --- a/.changeset/objectql-boot-loop-refuses-divergent-view-name.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -fix(objectql): the boot loop refuses a view container whose `name` disagrees with the object it binds to, instead of silently rewriting the author's field (#14666) - -**BREAKING** accept-set narrowing on the ObjectQL boot loop's SOURCE registrar -(`registerMetadataCollections`), shipped as `minor` under the repo's -launch-window convention for breaking changes. Ruled on #14666 (2026-09-03, -direction 2). - -An aggregated `defineView` container is keyed by the OBJECT it binds to, not -by its own row identity, and `ViewSchema` declares an optional `name` whose -own description says that for an object-scoped container it *is* the object -name. Nothing enforced that. A container written as -`{ name: 'lead_views', object: 'crm_lead', list: { ... } }` therefore reached -the two SOURCE registrars and got opposite answers: this boot loop overwrote -`name` with the derived key `crm_lead` and registered it, discarding the -author's field with no diagnostic, while the artifact/HMR loader -(`MetadataPlugin._parseAndRegisterArtifact`) refused the whole artifact load -through `assertMetadataRegisterContract` (#7378 row 1, `VALIDATION_ERROR` / -400). Same document, and whether it loaded at all depended on how the package -was loaded. - -The boot loop now **refuses loudly**, with the same `VALIDATION_ERROR` / 400 -envelope the artifact door raises, naming the container's own `name`, the -object key it derived, and both remedies: drop `name`, or set it to that -derived key. #7378 row 1 already ruled that resolving such a disagreement -silently, in either direction, files the item under a key the caller never -wrote, so the two registrars converge on the refusal rather than on the -rewrite; the artifact door is unchanged. - -**Refused shape**, precisely: an aggregated view container in a stack `views:` -collection that carries a non-empty top-level `name` AND derives a different -object key from its own `object` (or, failing that, `list.data.object` / -`form.data.object`). - -Scope, which the ruling names as this change's main risk. A container with no -`name` is untouched, and still registers under its derived key. So is a -container whose `name` already equals that key, and one that declares no -binding anywhere else, since the derivation then falls back to that same -`name` and cannot disagree with itself. No other metadata kind changes -behaviour: the refusal is gated inside the `views` branch of the generic -registration loop. Standalone ViewItems and flattened overlays travelling in -the assembled `viewItems:` channel are untouched, because a container cannot -reach that channel at all. Every one of these has a control test. - - diff --git a/.changeset/objectql-hook-timeout-ms.md b/.changeset/objectql-hook-timeout-ms.md deleted file mode 100644 index 90d631ae61..0000000000 --- a/.changeset/objectql-hook-timeout-ms.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): the declarative hook wrapper reads the renamed `hook.timeoutMs` (#14478) - -`wrapDeclarativeHook` reads its wall-clock abort budget from `meta.timeoutMs` -instead of `meta.timeout`, following the `@objectstack/spec` rename of the -authored key (the unit now lives in the key name). Same value, same magnitude, -same abort; no public surface of this package changes. diff --git a/.changeset/objectql-system-write-organization-recognizer.md b/.changeset/objectql-system-write-organization-recognizer.md deleted file mode 100644 index 74db9a4109..0000000000 --- a/.changeset/objectql-system-write-organization-recognizer.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -`@objectstack/objectql` now publishes a recognizer for the org-less system-write refusal, so a consumer no longer has to choose between an unsound check and a re-spelled string. - -`SystemWriteOrganizationRequiredError` has always documented that it is identified by `code` rather than `instanceof`, "so the check survives crossing a package boundary where two copies of this module can exist". The convention was correct; the affordance for following it was missing. This package declares **both** realms in its own `exports` — `import` to `dist/index.mjs`, `require` to `dist/index.js` — so a consumer that loads it through the other realm than the engine did holds a second copy of the module. Measured across that split from a real consumer package: same class identity (`A === B`) **false**, `instA instanceof A` within one realm **true**, `instA instanceof B` across the two **false**, and a `code` compare **true**. So `instanceof` against this class was unsound for every consumer, and it failed silently — a `catch` that simply never fires. - -That left a consumer with one sound option: re-spelling `'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'` as a literal. That spelling is what `check:error-code-provenance` counts as a stamp site, so recognising one engine refusal cost the consumer's package a provenance decision of its own, and left the string spelled in two places with the typo failure mode standing — a typo in a `catch` produces a branch that never fires rather than an error. - -Two new exports close it, both from the package root: - -- **`SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`** — the code as a value. Same shape as this package's five existing published codes (`DUPLICATE_RECORD_CODE`, `HOOK_TARGET_REBIND_ERROR_CODE`, `HOOK_UNSCOPED_DATA_ACCESS_CODE`, `MULTI_UPDATE_HOOK_KEY_DIVERGENCE_CODE`, `EMPTY_CREDENTIAL_REFUSAL_CODE`) rather than a new abstraction. The class field now reads from it, so exactly one spelling of the string remains in the package and a typo at an import site is a compile error instead of a dead branch. -- **`isSystemWriteOrganizationRequiredError(err): boolean`** — the code compare itself, so a consumer performs the sound check without authoring the string at all. - -The predicate deliberately returns `boolean` and does **not** narrow to `err is SystemWriteOrganizationRequiredError`. A `code` compare is satisfied by any value carrying that code, including an envelope a transport rebuilt from the wire — #5437 withholds the prose and keeps the machine-readable code — so a type guard would promise `object`, `posture` and `reason` members such a value need not have, moving the unsoundness one layer down instead of removing it. - -⛔ Nothing about the refusal itself changes: not its `code`, not its 500 status, not when it fires, and not the #8844 `derive-or-refuse` ruling behind it. `SystemWriteOrganizationRequiredError['code']` stays the literal type it was, which is what the existing cross-package consumer types its own constant from. This is purely an addition to what the package publishes. diff --git a/.changeset/olive-donkeys-repeat.md b/.changeset/olive-donkeys-repeat.md deleted file mode 100644 index fa5f075988..0000000000 --- a/.changeset/olive-donkeys-repeat.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -feat(cli): point `@objectstack/cli/console` at a public barrel with a name-and-shape pin - -**BREAKING**: `@objectstack/cli/console` publishes three names instead of thirteen. Ten names it used to resolve no longer resolve through that subpath. - -The subpath pointed straight at `dist/utils/console.js` — an internal module — and carried no surface pin of any kind, neither names nor shapes. Two assertions did exist and neither is one: `./console` was held among the declared `exports` keys, and the specifier was held to resolving from the packed tarball. Both answer *is the door open*; neither can answer *what is behind it*. So all thirteen of that module's top-level exports were public API, and every export it gained afterwards became a permanent public contract the moment it landed, silently. - -The subpath stays open and now points at a dedicated barrel, `dist/console.js`, which re-exports by name (no star) exactly the three helpers the one ledgered out-of-repo consumer uses to mount the Console SPA: - -- `resolveConsolePath` -- `hasConsoleDist` -- `createConsoleStaticPlugin` - -Those three keep their existing shapes exactly, so a consumer importing only them compiles unchanged. - -These ten are no longer reachable through `@objectstack/cli/console`: - -- `CONSOLE_PATH` -- `ConsoleShaDrift` -- `DRIFT_OVERRIDE_ENV` -- `ResolveConsoleOptions` -- `createRuntimeAssetsPlugin` -- `decideConsoleMount` -- `detectConsoleShaDrift` -- `formatConsoleShaDriftRefusal` -- `formatConsoleShaDriftWarning` -- `isConsoleVersionCompatible` - -Nothing was deleted. `utils/console.ts` still exports all thirteen and every in-package caller still imports it directly; what these ten lost is only the ability to be named through a published specifier. `ResolveConsoleOptions` in particular is still `resolveConsolePath`'s parameter type, so the options object a caller passes keeps working structurally — only the type's name is no longer importable from this subpath. - -`decideConsoleMount` and `createRuntimeAssetsPlugin` were retired on a measurement rather than by default: every reference to either name in this repo is inside `packages/cli`, the consumer-specifier ledger names neither, and `decideConsoleMount`'s own docblock scopes it to `isDev` and states that no published install can reach the refusal it exists to produce. - -`packages/cli/test/published-subpath-console.pin.test.ts` now holds the packed `.d.ts` to exactly the three names and their shapes, compiled by a real consumer outside the workspace, with a control per retired name. Re-admitting any of the ten is a deliberate, reviewed, `minor`-bumped edit to that barrel and that pin. - - diff --git a/.changeset/olive-spiders-refuse.md b/.changeset/olive-spiders-refuse.md deleted file mode 100644 index cf9e7d95c0..0000000000 --- a/.changeset/olive-spiders-refuse.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -**BREAKING** `os create ` now refuses a project name that npm refuses, and refuses it before it writes anything. - -`os create plugin "My App"` used to exit 0 having written `./plugin-My App/`, carrying a manifest that read `name: "@objectstack/plugin-My App"`. Nothing failed at scaffold time, so the invalid name surfaced later at `npm publish`, in the terminal of whoever ran it next. `os init` has always refused that same input before touching the disk. The rule set is now shared between the two scaffolders rather than restated in one of them, so they answer the same way. - -`os create` also refuses a name whose composed scoped package name exceeds npm's 214-character ceiling. `@objectstack/plugin-` spends 20 of those characters before the name begins, so a name that `os init` accepts can still compose to one npm rejects; that check sits next to the composition rather than in the shared rule set. - -A scripted invocation that passed an invalid name now exits 1 with the reason on stderr, where it previously exited 0 and produced a project that could not be published. - - diff --git a/.changeset/open-core-multi-organization-runtime.md b/.changeset/open-core-multi-organization-runtime.md deleted file mode 100644 index 4718a4d61e..0000000000 --- a/.changeset/open-core-multi-organization-runtime.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/organizations': minor -'@objectstack/plugin-security': patch -'@objectstack/service-cluster': patch -'@objectstack/spec': patch ---- - -Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an -Apache-2.0 package in this repository (ADR-0132). - -Single-database, row-level organization isolation was already open — the tenant Layer 0 wall, -the three tenancy postures, the organization and invitation objects, better-auth's organization -plugin, and the `requiresService: 'org-scoping'` Setup gates. What was closed was the one -registrar of the `org-scoping` service, so an install that set `OS_TENANCY_POSTURE=isolated` -could not enforce it: `serve` refused the boot, and the only way past was -`OS_ALLOW_DEGRADED_TENANCY=1` — the wall configured but not enforced. This package is that -missing registrar. - -It provides: - -- **`organization_id` auto-stamp on insert**, from the caller's active organization. A supplied - — possibly forged — value is overwritten, never trusted. -- **Per-organization seed replay** on `sys_organization` insert, from the app's own seed - definitions. Never another organization's rows. -- **Default-organization bootstrap** for the platform admin, idempotent. -- **The walled-posture membership-policy gate**: a deployment that raises the wall must declare - what a new user joins, or the boot is refused. - -Only the commercial **entitlement** stays closed. The open class carries no licence check of any -kind and offers no hook for one; an enterprise deployment resolves the same package name to a -private, licence-gated subclass through its own `workspace:*` declaration, so which class is -mounted is decided by the manifest that declares the name. - -⚠️ Shipping the registrar is not yet the same as an open install raising the wall: `objectstack -serve` still resolves the runtime from the served app's own declaration and is not yet wired to -mount this package off `OS_TENANCY_POSTURE`. That, and the isolation matrix run against a real -registrar rather than a posture stub, are tracked separately. diff --git a/.changeset/org-hierarchy-timezone-columns.md b/.changeset/org-hierarchy-timezone-columns.md deleted file mode 100644 index 8c1144266e..0000000000 --- a/.changeset/org-hierarchy-timezone-columns.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/plugin-auth': minor ---- - -feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) - - - -Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238. - -**The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC. - -**What lands.** - -- `sys_business_unit.timezone` — `text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`. -- `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`. -- plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry. - -**The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written. - -**Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned. - -**One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately. - -**Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question. diff --git a/.changeset/organization-update-door.md b/.changeset/organization-update-door.md deleted file mode 100644 index 62e920a176..0000000000 --- a/.changeset/organization-update-door.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/platform-objects": minor ---- - -`sys_organization` admits generic `update` on the data door, column-gated by the ADR-0092 D2 identity write guard (#15873 — maintainer ruling 2026-09-07, decision batch #64, option (a), verbatim 「同意」). - -The organization table carries four platform-owned columns better-auth never reads or writes — `require_mfa` (ADR-0069 D3), `parent_organization_id` and `sort_order` (ADR-0105 D6), `timezone` (#14238). plugin-auth declares them generically editable (`MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization`, the guard's per-object update whitelist), while the object's `enable.apiMethods: ['get', 'list']` answered every `PATCH /api/v1/data/sys_organization/:id` with 405 `OBJECT_API_METHOD_NOT_ALLOWED` before the engine — and the guard — was reached. Declared editable, reachable from no product surface: the columns could be set only by a system-context caller. The ruling answers the card's question — yes, an administrator sets these columns through the product — and refuses the alternative of declaring them system-writable only. - -What widens (Clause ②) — three published surfaces move, all column-clamped by the same guard: - -1. The data door's accept set. `enable.apiMethods` becomes `['get', 'list', 'update']`, and `userActions: { edit: true }` declares the affordance ADR-0103 D3's `reconcileManagedApiMethods` requires before it lets a `managedBy` object keep a write verb at registration (without it the verb is stripped with a warning and the door keeps answering 405 — the second silent gate #7727 measured on `sys_api_key`). `PATCH /api/v1/data/sys_organization/:id` is admitted; `create` / `delete` still answer 405; `bulk` (`/batch`, the `*Many` routes) is not granted (recorded in `SINGLE_RECORD_WRITE_ONLY`). -2. The derived `import` door. `API_METHOD_DERIVATION` (`@objectstack/spec` `api-derivation.ts`) derives `import` from `any: ['create', 'update']`, so granting `update` admits `POST /api/v1/data/sys_organization/import` (and the async `/import/jobs` route) in `writeMode: 'update'` — one request updates N rows, each row clamped per row by the ADR-0092 D2 guard under the caller's context (a row carrying only better-auth columns is refused `PERMISSION_DENIED`; `treatAsHistorical` does not elevate). Insert-mode and upsert-mode import stay 405 (the conjunct named is `create`). The door's own 405 envelope advertises the derived set in `allowed`. -3. `/auth/me/permissions`. For a principal the permission layer already admits (the seeded platform admin's `admin_full_access` wildcard), `sys_organization.allowEdit` goes `false → true` (`clampManagedObjectWrites` reads `userActions.edit` for the `better-auth` bucket) and `apiOperations` gains `update` and `import` (`annotateEffectiveApiOperations`) — the payload the console renders its edit affordance from. `organization_admin` / `member_default` stay hard-denied on every better-auth table by `managed-object-write-denies.ts`, unchanged. - -What does not widen: the column set. The guard clamps every user-context update on this table to the whitelist. A PATCH of a better-auth column sent alone (`name`, `slug`, `logo`, `metadata`) is now refused by the guard's own verdict — 403 `PERMISSION_DENIED` — instead of the method gate's 405; sent beside a whitelisted column it is stripped and the whitelisted column lands. better-auth's own columns keep changing through better-auth's `organization/update` (the `update_organization` row action, unchanged). Per ADR-0092 D4's form-rendering constraint the four better-auth columns are now `readonly: true` on the object, so a standard edit form offers exactly what the guard admits; the engine's static-readonly strip exempts system-context writers, so better-auth's adapter is unaffected. - -Not breaking: no key, export or accepted value is removed; every request that succeeded before succeeds unchanged, and the 405 → 403 change applies only to requests that were refused before and are refused still. diff --git a/.changeset/os-create-emits-an-installable-project.md b/.changeset/os-create-emits-an-installable-project.md deleted file mode 100644 index f4da992bd5..0000000000 --- a/.changeset/os-create-emits-an-installable-project.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os create` now emits a project that installs outside this monorepo. - -Every project the command scaffolded declared its `@objectstack/*` dependencies -with pnpm's `workspace:*` protocol, extended a `tsconfig.json` two directories -above itself, and was written into this repository's own `packages/plugins/` or -`examples/` by default — so a developer following the documented command got a -project `pnpm install` refuses. The default emission is now standalone: - -- `@objectstack/*` dependencies are published semver ranges pinned to the - version of the CLI that generated them; -- the emitted `tsconfig.json` is self-contained and extends nothing; -- the project is written to `./` in the current directory (or `--dir`); -- a `pnpm-workspace.yaml` carries the build approvals a fresh `pnpm install` - needs on pnpm 11. - -The `plugin` template also emits `init` where it used to emit `initialize`. -`initialize` is not part of the `Plugin` contract, so the scaffold did not -type-check under its own `strict` config (TS7006 on the untyped `context` -parameter) and `kernel.use()` refused the plugin at load with -`Plugin init function is required` — a defect the kernel protocol docs -previously carried a warning about instead of a fix. - -The previous monorepo-internal placement is still available for ObjectStack -platform work as the explicit `--in-repo` flag, which keeps the `workspace:*` -specs and writes into `packages/plugins/` or `examples/`. diff --git a/.changeset/os-create-example-retired.md b/.changeset/os-create-example-retired.md deleted file mode 100644 index d059d3200e..0000000000 --- a/.changeset/os-create-example-retired.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli)!: retire `os create example` — it was a weaker `os init` plus a README, and the refusal now names `os init` (#16483, #15531) - -**BREAKING** — a published CLI surface is removed. `os create example ` is a -command a user can run today and cannot run after this release. Graded `minor` -rather than `major` under this repo's lockstep launch-window convention (no -package here has ever shipped a `major` changeset); the break is stated here -instead of in the number. - - - -**No alias and no deprecation window.** `os create example` will not come back, -so change the command rather than pinning an older CLI. (Those terms are recorded -on card #16483 and are pending maintainer confirmation — the removal itself is -settled by the #15531 batch entry below. The behaviour ships either way, and it is -the same shape `os g agent` already shipped.) - -#15531 rendered the real emission of both scaffolder families and hashed it file -by file. The only template-level duplication left between them was this one -template: `os create example` wrote a **subset** of what `os init` writes, plus -one README. The two families' emission policy is already unified through four -shared exports, so the remaining duplicate was the template itself — and the -ruling (decision batch #66, option B) is that it goes, not that the two command -families merge. They emit two different artifacts: a kernel code `Plugin` is not -a declarative app, and collapsing them would make that collision structural. - -**What to run instead** - -```bash -os init # a full application project -os init -t empty # config only, no src/objects -``` - -`os init` writes the same `tsconfig.json` the retired template did (byte-identical, -measured) and an **equivalent** `objectstack.config.ts` — both manifests are -`ManifestSchema`-valid but they are not the same bytes: the retired template wrote -`name: ''`, `description: ' example application'` and commented-out -barrels, where `os init` writes a title-cased `name`, an empty `description` and no -barrels. On top of that `os init` adds `src/objects`, a `.gitignore` and the -dependency install the retired template never had. - -**`os create plugin` is unaffected.** It scaffolds the kernel code `Plugin` -contract — `src/index.ts` exporting a `Plugin` with `init` / `destroy`, built by -`tsc`, publishable as `@objectstack/plugin-` — which `os init` does not -emit. `os create`'s flags and its standalone emission policy are unchanged for -`plugin`. ⚠️ `--in-repo` is narrowed rather than untouched: the flag survives, but -its `examples/` placement is removed with the template and gets no -replacement — `--in-repo` now only ever lands in `packages/plugins/plugin-`. - -**The removal is a signpost, not a deletion.** `os create example` still answers: -it exits **1** and names `os init`, rather than falling through to the generic -`Unknown type:` roster. A reader arriving from an older tutorial or a CI script -that still calls it learns what replaced it instead of learning only that their -spelling is off the list. Pinned end-to-end by driving the real CLI in -`packages/cli/test/create-example-retired.e2e.test.ts`, which asserts both halves -— the non-zero exit **and** the message naming `os init`. The four public doc -pages are held to the same promise by a SEPARATE pin, -`packages/cli/test/create-example-retired-docs-parity.test.ts`: it spawns nothing, -so unlike the `.e2e` file it runs in the per-PR tier rather than the nightly one. diff --git a/.changeset/package-state-per-project-key.md b/.changeset/package-state-per-project-key.md deleted file mode 100644 index 368d24895a..0000000000 --- a/.changeset/package-state-per-project-key.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -Package lifecycle state is keyed by the PROJECT as well as the environment id, so two projects on one machine stop sharing which packages an operator has disabled. - - - -**BREAKING** for a machine that relied on one environment id meaning one shared disable list. Shipped as `minor` under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. - -`packages/runtime/src/package-state-store.ts` is the only durable record of which packages an operator has disabled, and `AppPlugin.start()` replays it at boot. It was stored at `/package-state/.json`, and both halves of where that lived were machine-global: `resolveObjectStackHome()` takes no arguments (it reads `OS_HOME`, else `~/.objectstack`), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary `env_local` environment, therefore wrote one file. - -Driven with two real project roots, one home and one environment id, that produced two failures with one cause: - -- project B disabling `com.acme.billing` made project A's **boot read** answer `{ com.acme.billing, com.acme.reporting }` — A had never installed, seen or disabled that package, and the disable takes it out of A's running system; -- project B enabling `com.acme.reporting` erased project A's disable of it, so one project's operator action silently undid another project's operator intent. - -The file is now `/package-state/..json`, where the project component is a sanitised basename plus a short digest of the resolved project root — the same naming convention `os serve`'s runtime state file settled on, rather than a second spelling of one idea. The payload is unchanged. - -**An existing `.json` keeps working and is not deleted.** While a project has no per-project file of its own the runtime still reads the old name, and that project's first write lands under the new one. The old file is never written and never removed, so a machine that rolls back to the previous release still finds its operator's disables where that release looks for them. Disables made after the upgrade live under the new name only. - -**Which project the key is taken from:** the runtime's working directory, the base every path in a boot with no served-app anchor already resolves against. Two boundaries follow, stated rather than fixed. `os serve` anchors host resolution at the config file's own directory when that directory carries a `package.json`, so serving a config from elsewhere keys this file by the working directory while the CLI's supervision file keys by the config's directory; and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key two files, each internally consistent. Two boots of the same project from the same directory still share one file, which is the same-project case and unchanged here. diff --git a/.changeset/page-header-page-name-route-canonical.md b/.changeset/page-header-page-name-route-canonical.md deleted file mode 100644 index c393593b3a..0000000000 --- a/.changeset/page-header-page-name-route-canonical.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`translatePage` now reads a region-level `page:header` by **page name only**. The id route -(`pages..components..*`) is no longer read for that component, even when it carries an `id`. - -**Behaviour change, stated plainly:** a bundle that overrode a region-level header's title through -`pages..components..title` now falls back to `pages..title` (which itself falls back to -`pages..label`). The components key still parses — nothing is removed from `TranslationBundleSchema` — it is -simply no longer the address for this one component. - -``` -FROM pages..components..title # region-level page:header — no longer read -TO pages..title # …and .subtitle for the subtitle -``` - -Fix in one line: move the string from the components entry up to the page's own `title` key, and delete the -components entry for that header id. `os i18n extract` has always offered exactly the `TO` key, so a bundle -generated or checked by the CLI already writes it. - -**Blast radius, as measured on the card (inherited, not re-measured here):** HotCRM found **zero** such overrides — -all five of its region-level headers carry ids and none writes the components key. - -**Why.** Both sides were deliberate and they disagreed. The extractor skips a region-level `page:header` on purpose -(its copy is offered under `pages..title` / `.subtitle`, and emitting it twice would put one string under two -keys); the resolver read the id route on purpose (the more specific route wins). Together they produced the exact -failure `walkAddressedPageComponents` was extracted to prevent — the resolver reading an id the extractor omits — so -the key an author reached for won silently while the key the tooling reported as translated lost. The maintainer -ruled (2026-09-06, decision batch #58, verbatim 「同意」) that the page-name route is canonical: one component, one -address. `title` and `subtitle` now follow the same rule, closing the asymmetry where `title` had two addresses and -`subtitle` — never in `PAGE_COMPONENT_COPY_KEYS` — had one. - -Unchanged: a `page:header` **nested** inside a container is reached by the id route only, as it always has been; and -a region-level header's id still claims its bundle entry and still blocks a nested namesake, which is what the -extractor does too. - -Not `major`: nothing an author can write is removed or renamed. `pages..components.` remains a declared, -parsing, resolving address for every other component — including a nested `page:header` — so there is no key to -tombstone and no ADR-0087 conversion to register. Recorded here so the choice is checkable rather than assumed. diff --git a/.changeset/permissions-block-named-refusal.md b/.changeset/permissions-block-named-refusal.md deleted file mode 100644 index 9f2cd2f1bf..0000000000 --- a/.changeset/permissions-block-named-refusal.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The manifest `permissions` block's unknown-key refusal now names the surface and offers the rename, like every other block on the manifest. - -`PluginPermissionsSchema` decides which services, hooks, network hosts and filesystem paths a plugin may touch. It has refused unknown keys since it was introduced, but through zod's own bare message: an author who transposed `hooks` as `hoooks` read `Unrecognized key: "hoooks"` — the key echoed back, with no surface name and no suggested spelling — while every neighbouring block on the same manifest (`contributes`, `contributes.kinds[]`, `engines`, the legacy `engine`, and the manifest root itself) named all three. Born closed at the ADR-0025 plugin-distribution work, it never passed through the unknown-key campaign that gave the others their error maps. - -It now uses the same `strictObject` helper as its neighbours, so the refusal reads: - -``` -Unrecognized key(s) on the `permissions` block of this package manifest: `hoooks`. -Did you mean `hoooks` → `hooks`? … -``` - -Three spelled-out near-misses that edit distance cannot reach are curated as aliases: `filesystem` and `paths` point at `fs`, and `hosts` points at `network`. - -**The accept set does not move.** `strictObject` is `z.object(shape, { error }).strict()` — the declared keys and the strictness are unchanged, and an error map is consulted only once an issue is already being raised. The `permissions` union keeps both arms (the legacy flat string list and the structured block), and the union itself is untouched. Only the text of a refusal that already happened is different. diff --git a/.changeset/plain-unique-index-duplicate-preflight.md b/.changeset/plain-unique-index-duplicate-preflight.md deleted file mode 100644 index 174193bede..0000000000 --- a/.changeset/plain-unique-index-duplicate-preflight.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -A plain unique index over existing duplicate rows no longer kills the boot with the database's raw error, and `os migrate plan` no longer calls that op `safe`. - -Declaring a column unique over a table that already holds duplicates had two very different outcomes depending on one branch in the SQL driver, and only one of them was survivable. - -- **An organization-scoped unique** (the `unique: 'organization'` default, materialised as the NULL-safe `COALESCE(organization_id, '__global__')` composite) kept the boot up: the driver logged at `error` naming the index, the constraint that is not enforced and the remedy, and the ADR-0120 D4 duplicate pre-flight reported the blocked `create_index` as `category: 'destructive'` / `severity: 'error'` with the conflicting key groups and their row counts. -- **A plain unique** — no organization key part at all, reached by an object with `tenancy: { enabled: false }` or by any explicit `unique: 'global'` — took the process down: `initObjects` threw the database's own error, which names the index and the column and no rows and no remedy, nothing reached the durability channel, and `detectManagedDrift` (what `os migrate plan` reports) classified the very same op `category: 'safe'`, `severity: 'warning'`, so `os migrate apply` and dev `autoMigrate: 'safe'` walked straight into the raw failure. - -The plain path now reaches the same posture as the scoped one: - -- **The boot survives and says what is not enforced.** `syncDeclaredIndexes` absorbs a uniqueness violation on a plain unique index the way it already absorbed one on the NULL-safe composite: the failure is logged on the durability channel (`error`) naming the index, the conflicting key groups with their row counts, the constraint that is NOT enforced, and `os migrate plan` as the way out. A non-unique index and any failure that is not a uniqueness violation still surface as before. -- **The duplicate pre-flight covers it.** The ADR-0120 D4 probe no longer skips ops whose NULL-safe column set is empty, so a plain unique `create_index` over dirty data is reported `destructive` / `error` with the same row report instead of `safe`. Nothing new probes it: the existing probe already groups by the bare columns when there is no NULL-safe key part, so both key shapes share one pre-flight rather than a second copy that can drift from the first. - -Consumers of the classification see the op move from the "Safe" group to "Destructive (requires --allow-destructive)" in `os migrate plan` and `os diff`; `os migrate apply` defers it instead of attempting it; the artifact boot gate refuses with a named destructive-drift refusal instead of crashing; and dev `autoMigrate: 'safe'` leaves it alone. Clean data is unaffected — the probe finds nothing and the index is created exactly as before. diff --git a/.changeset/platform-iana-timezone-columns.md b/.changeset/platform-iana-timezone-columns.md deleted file mode 100644 index 91758b3703..0000000000 --- a/.changeset/platform-iana-timezone-columns.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -feat(platform-objects): `sys_job.timezone` and `sys_report_schedule.timezone` are validated against the IANA domain (#15872) - -**BREAKING** accept-set narrowing on two published columns, shipped as `minor` -under the repo's launch-window convention for breaking changes. Both columns now -declare `valueDomain: 'iana_time_zone'`, so a value the shipped build stored -without complaint is refused from this release on. During the launch window the -bump level is not the carrier of breaking-ness and says nothing about whether a -release breaks you; this banner is the carrier, and the ADR-0087 disposition at -the foot of this changeset is the other one. - -**What stops being accepted.** A write to either column is now refused with the -ADR-0114 field error code `value_domain` unless the value is a member of the -IANA/tzdb set, tested with the `Intl.DateTimeFormat` probe. Three classes of -string that the previous build accepted are outside that set: - -- **UTC-offset spellings** — `UTC+8`, `GMT+0800`, `+08:00`. They name an offset, - not a zone, and no offset spelling is an IANA identifier. The tzdb's own - fixed-offset zones are members and keep working: `Etc/GMT-8` is accepted. -- **Windows / CLDR display names** — `China Standard Time`, - `Pacific Standard Time`. That is the Windows time-zone vocabulary, a different - naming scheme from tzdb, and no member of it is a tzdb identifier. -- **shape-valid identifiers for zones that do not exist** — `Mars/Olympus`. A - `Region/City` pattern cannot separate an unassigned identifier from a real - one; membership can, which is what the domain is for. - -**What keeps working.** Every genuine IANA identifier, including `UTC` — the -membership predicate is the `Intl.DateTimeFormat` probe, deliberately not the -`Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC`. That -matters here rather than academically: `'UTC'` is `sys_report_schedule.timezone`'s -own declared default. - -**Stored rows are unaffected — only writes are judged.** No upgrade step, no -backfill, no DDL. In the published words of the contract this declaration is -governed by (`packages/spec/src/data/field.zod.ts`, the `valueDomain` description): - -> Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate -> class): a stored value outside a domain declared later is never re-read and -> survives unrelated edits — only a write carrying a non-member is refused, with -> the field error code `value_domain`. - -So a deployment already holding `UTC+8` in one of these columns keeps it and -reads it back unchanged; what changes is the next write. The one thing to know -before upgrading is therefore an authoring fact, not a data-at-rest one: a -producer that writes one of the three spellings above starts getting a refusal -where it previously got a success, and for `sys_report_schedule` that refusal is -the point — see the behaviour note below. - -The platform's two oldest IANA time-zone columns predate `valueDomain` and disagreed with each other in three dimensions at once — length (100 vs 64), default (none vs `'UTC'`) and validation (neither). This closes the third: both now declare `valueDomain: 'iana_time_zone'`, the same declaration and the same `Intl.DateTimeFormat` membership probe that `sys_business_unit.timezone` and `sys_organization.timezone` carry (#14238). Four columns, one spelling of "is this a real zone". - -**What it was worth, measured before the fix rather than assumed.** The two columns are not equally exposed, and only one of them was dangerous. - -- `sys_report_schedule.timezone` is read back and handed to a scheduler. `ReportService.nextRunAt` calls `new Cron(cron, { timezone }).nextRun(from)`, and croner does not reject a non-member zone at construction when there is no callback — it throws from `nextRun()`. That throw was caught and turned into a fall back to `interval_minutes`. So a typo'd zone silently discarded the cron expression: an admin's "every weekday 09:00 Asia/Shanghai" became "every 1440 minutes, forever", logged only as `invalid cron ''` — a warning naming the wrong input, because the expression was fine. Not a throw and not a fall back to UTC: the wrong instant, permanently. Refusing the write is what closes it. (`scheduleReport`'s eager create-time guard did not catch it either: it constructs a callback-less `Cron` and is blind to exactly this half of its own input. That is a separate defect in `plugin-reports`, carded, not fixed here.) -- `sys_job.timezone` is written and never read. `DbJobAdapter` mirrors the in-memory schedule onto the row; its three `sys_job` read sites take `id` / `run_count` / `failure_count` only. The zone the scheduler honours never travels through this column, and `DbJobAdapter.schedule` awaits the cron adapter before it upserts the row, so a non-member cannot even reach the column that way — croner constructed WITH a callback throws, and `AppPlugin` reports it as `Background job FAILED TO SCHEDULE — it will never run`. The door this declaration closes there is the other one: a direct write from Studio, REST or a script, which had no validation at all. - -**What is deliberately NOT converged**, and is pinned so that staying unconverged is a decision rather than a drift someone repairs by reflex: - -- **the defaults still differ.** A default here is a consumer semantic, not a shape question. `sys_report_schedule` documents and implements a UTC default; `sys_job` has no reader at all, and minting one would change what an unset row means. -- **the bounds still differ (100 vs 64).** `maxLength` is not only a write bound — it reaches DDL, and narrowing a physical `varchar(100)` is `driver-sql`'s `narrow_varchar` op at severity `error`, category destructive ("narrowing may truncate"). What the column physically holds in a deployment is not readable from the repo, so the convergence is a separate decision and #15872 stays open on it. Note what the domain declaration already costs the wider bound: no member is longer than 32 characters on the current Node baseline, so 100 now admits nothing 64 would not. - - diff --git a/.changeset/platform-object-tenancy-census-derived.md b/.changeset/platform-object-tenancy-census-derived.md deleted file mode 100644 index 7855601bcb..0000000000 --- a/.changeset/platform-object-tenancy-census-derived.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -The platform-object tenancy census is derived and gated instead of hand-written in a comment. Documentation only — no runtime behaviour changes. - -`PLATFORM_OBJECT_TENANCY`'s header explained why the reclassification needs a ledger rather than a schema read, and backed the argument with three hand-written digits and a parenthetical attributing them. Nothing re-derived any of it, so it was true only until the population moved and failed silently when it did — in both of the directions a prose count can. - -The parenthetical mis-attributed the exclusion: it named `sys_sso_provider`'s `tenancy.enabled: false` as an addition to the `managedBy: 'better-auth'` set that object was already in, and left `sys_api_key`'s identical opt-out unnamed. The arithmetic stayed right, which is why no reader and no gate caught it — a wrong reason producing a right total is the shape that survives longest. The digits then went stale when an object opted out of the tenant column through a third mechanism the parenthetical's taxonomy had no slot for (`systemFields: { tenant: false }`), while the gated page next door was updated in the same commit. - -The digits and the parenthetical are deleted rather than corrected. The header now points at `scripts/platform-object-tenancy-census.json` and states the PREDICATE it was missing: an object is inside the machinery when `resolveTenantFieldName` answers non-null on the **registered** schema — after `applySystemFields` has injected the tenant column, because the injected column is what the engine sees, not what the author typed. Counting `managedBy` as if the resolver read it is the mistake that produced the wrong reason. - -The artefact is derived by `scripts/platform-object-tenancy-census.mjs`, which loads `resolveTenantFieldName` and `resolveInjectedSystemColumns` from source and executes them rather than re-spelling what they decide, and is held to the tree by `scripts/check-platform-object-tenancy-census.mjs`. It records per object the declaration on that object's own schema that puts it outside the reach; declarations are not mutually exclusive and an object carrying two keeps both. An excluded object with no declared mechanism is an error, not a default: the generator refuses to commit the row and the gate reds, so a new exclusion mechanism is adjudicated rather than absorbed into an existing total. diff --git a/.changeset/platform-objects-dashboard-refresh-interval-seconds.md b/.changeset/platform-objects-dashboard-refresh-interval-seconds.md deleted file mode 100644 index 6b6b872d5c..0000000000 --- a/.changeset/platform-objects-dashboard-refresh-interval-seconds.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): the dashboard metadata-form bundles follow the `refreshIntervalSeconds` rename (#14478) - -The `metadataForms.dashboard` translation bundles key the auto-refresh field as -`refreshIntervalSeconds`, following the `@objectstack/spec` rename of the -authored key. Regenerated with `node scripts/check-i18n-bundles.mjs --write`; the -hand-written `zh-CN` / `ja-JP` / `es-ES` label and help text were carried across -the rename unchanged, because the field still means what it meant and each help -text already named the unit. diff --git a/.changeset/platform-record-page-labels-translated.md b/.changeset/platform-record-page-labels-translated.md deleted file mode 100644 index a5d83c57d1..0000000000 --- a/.changeset/platform-record-page-labels-translated.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/platform-objects": minor ---- - -The three platform record pages carry a translated label in every shipped locale. - -`sys_user_detail`, `sys_organization_detail` and `sys_position_detail` each declare a page-level `label` — `User`, `Organization`, `Position` — and those three strings rendered in English in every locale, including `zh-CN`, `ja-JP` and `es-ES`. They are the only keys on those pages the extractor reaches: all three author `regions: []`, so the shared walk (which roots at `regions[].components[]`) finds nothing else, and their other 45 authored copy sites are inline locale maps under `slots.*` that already carry all four locales. - -`SetupAppTranslations` now declares a `pages.*` entry for each of the three in all four locale files, so `translatePage` overlays the page label the same way it already does for the plugin-carried Setup pages. Their recorded source hashes are added alongside (`.source-hashes.ts`), so a later edit to one of the English literals marks the translations stale instead of serving a translation of a string that no longer exists. - -Nothing about the pages' shape changed: `label` is still the only key the extractor offers them, and the inline maps under `slots.*` are untouched. diff --git a/.changeset/plugin-auth-find-envelope-limbs.md b/.changeset/plugin-auth-find-envelope-limbs.md deleted file mode 100644 index 2eeba4b964..0000000000 --- a/.changeset/plugin-auth-find-envelope-limbs.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -A self-registration grant is refused, not silently redirected, when a permission-set row is malformed — and the fourteen dead `{ records }` / `{ data }` normalizer limbs behind that code are gone. - -`plugin-auth` carried fourteen array-or-envelope normalizer blocks of the shape `Array.isArray(x) ? x : x.records ?? []` (thirteen on a `records` limb, one on a `data` limb, four of them written as a guard clause rather than a ternary). All fourteen read the same concrete engine — the `ObjectQL` instance the kernel registers as the `objectql` / `data` service — which answers a bare array on every path, populated or empty. The envelope limb was unreachable code that read as a contract, so the next author writing a defensive normalizer here believed an envelope was possible. The limbs are removed, and the three local engine ports that declared `Promise` (`BootProbeEngine`, `DevAdminSeedProbeEngine`, `PhoneSmsTemplateEngine`) now declare the array they always returned. - -The user-visible change is in `settleSelfRegistrationGrant`, which carried the opposite defect. Its candidate filter dropped any permission-set row whose `id` was missing or blank, silently, before choosing which row to grant: - -- When the malformed row was the only one, the operator was told `no active sys_permission_set row named 'X' resolves` — false, since an active row named exactly that was present. That report is the only signal this path emits, and nothing retries it. -- When the malformed row was the **organization-scoped** one and a global row also carried the declared name, dropping it let the `organization_id == null` arm match instead, and the self-registrant was granted the **global** permission set their organization never declared — with a success log and no other trace. - -`active !== false` remains a selection predicate: a deactivated set still reports the ordinary "does not resolve". A malformed row is no longer a selection at all — the grant is refused and the report names the malformed row, so the ambiguity is surfaced instead of resolved by accident. A well-formed family grants exactly as before. - -**Upgrade note — one family now gets a refusal where it previously got a grant.** If a deployment's `sys_permission_set` already contains a row that is active and carries the declared name but whose `id` is missing or blank, self-registration grants against that name now stop and report, including the case where the malformed row is one nobody was relying on: a malformed **global** row sitting alongside a well-formed **organization-scoped** row used to be dropped silently, letting the org row be granted, and is now refused. This is deliberate — the old behaviour could not tell that family apart from the one where the silent drop granted the *wrong* set — and it is fully reversible without a code change: repair or delete the malformed row and the grant proceeds exactly as before. The refusal is loud and names the row, so it is visible rather than something to discover later; nothing is written while it stands. diff --git a/.changeset/plugin-describe-ui-type-spelling.md b/.changeset/plugin-describe-ui-type-spelling.md deleted file mode 100644 index cca7dfd3ab..0000000000 --- a/.changeset/plugin-describe-ui-type-spelling.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The `PluginSchema` describe strings for `staticPath`, `slug` and `default` now name `ui`, the plugin type the enum actually accepts. - -`PluginSchema.type` is `z.enum(['standard', ...CORE_PLUGIN_TYPES])`, and `CORE_PLUGIN_TYPES` spells the frontend member `ui`. The three describe strings beside it still named `ui-plugin` — a value the same schema refuses two lines above. They are not merely stale: they read as instructions ("Required for `type="ui-plugin"`"), so an author or an agent following the field's own documentation writes a value that is then rejected, with the correct spelling nowhere in the sentence that sent them there. - -The strings now read `(Required for type="ui")`, `(Required for type="ui")` and `(Only one "ui" plugin can be default)`. Because these describes compile into the published JSON Schema and into the generated reference page, the correction reaches every consumer that reads field documentation out of the spec rather than out of the source file — the generated `content/docs/references/kernel/plugin.mdx` table now agrees with the `type` row printed directly above it, which previously listed `'ui'` among the accepted members while the three rows underneath told the reader to write `ui-plugin`. - -No accept/reject behaviour moves: `type: 'ui-plugin'` is refused before and after, `type: 'ui'` is accepted before and after, and no key is added, renamed or removed. The closed-set pin tests that name `ui-plugin` as a non-member are deliberately unchanged — they are the reason this correction is provable. diff --git a/.changeset/plugin-dev-i18n-detect-packages-reader.md b/.changeset/plugin-dev-i18n-detect-packages-reader.md deleted file mode 100644 index 40375119b5..0000000000 --- a/.changeset/plugin-dev-i18n-detect-packages-reader.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/plugin-dev": patch ---- - -fix(plugin-dev): the i18n auto-detect resolves `translations` from `packages[]`, not only the flattened top level (#15232) - -`DevPlugin.init`'s 3b block read `options.stack.translations` and nothing else. -For a multi-package app under the ADR-0130 D4 option-B shape — where -`packages[]` carries each definition exactly once and the flattened top-level -copy is gone — that read returns `undefined`, the detection concludes "this app -declared no copy", and the boot continues. Nothing throws and nothing logs. - -What the developer gets instead is the wrong strings. `I18nServicePlugin` -(`@objectstack/service-i18n`) is never registered, so the `i18n` slot keeps the -core in-memory fallback: `os dev` serves message KEYS, or last release's copy, -for an app that declared real translations. It reads as "the translations are -broken", not as "a collection went missing", which is why it is a reader fix -rather than a footnote. - -The detection now reads the flattened top level FIRST and then each package -body, in the order `resolveArtifactPackageOrder` (`@objectstack/core`, -ADR-0130 D4+D5) registers them: - -- **Every artifact the platform emits today answers bit-identically.** The - flattened level still answers first and short-circuits, so the `packages[]` - pass can only supply a declaration the top level did not have. This is the - reader half of the ruled order (readers first, emitter last, the artifact - additive throughout), so it lands with no change to what any command emits. -- **The caller's original expression is preserved, not re-expressed.** - `Array.isArray(t) && t.length > 0` still decides the top level, per package - body as well — re-expressing a gate as a resolved-and-counted traversal is - what silently changes the verdict for a stack that declares the key empty. -- **⛔ `stack.packages` is not iterated directly.** - `resolveArtifactPackageOrder` is the platform's one traversal and also the - GATE that parses each entry, so a second traversal would disagree with the - load path about which artifacts are loadable. An artifact with no `packages` - key is left entirely on the old path — the key's absence is checked before - the call, because D4's second branch would otherwise hand the caller's own - object back and read the same `translations` twice. -- **A malformed `packages` is refused, not skipped.** A non-array `packages`, - an entry inlined instead of wrapped under `manifest:`, or a duplicate package - id raises the same ADR-0112 envelope (`code` + `status: 422`) that - `ObjectQL.registerApp` raises for the same object later in the same boot. - -The decision — detection plus the locales it derives — is now one exported -function, `devI18nPluginOptions`, so the #15004 option-B acceptance pin -measures it by CALLING it rather than re-implementing the read. `DevPlugin` -keeps the dynamic import and its degradation: those are about the optional -package being installed, which is a different question from what the stack -declares. diff --git a/.changeset/plugin-schema-ui-required-keys.md b/.changeset/plugin-schema-ui-required-keys.md deleted file mode 100644 index aec5e24dbc..0000000000 --- a/.changeset/plugin-schema-ui-required-keys.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -`PluginSchema` now REQUIRES `staticPath` and `slug` when `type` is `'ui'`, and core's `Plugin` interface inherits every `PluginSchema` key from `PluginDefinition` instead of restating two of them. - -**BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). `packages/spec/src/kernel/plugin.zod.ts` described `staticPath` and `slug` as *"Required for type=\"ui\""* while declaring both `.optional()`, with nothing behind the prose; since `kernel.use()` runs the schema on the boot path (#16049), that was a promise the runtime visibly did not keep. This is the spec half of #16049, split by director ruling (decision batch #58, 2026-09-06). - -**Exactly what is newly refused.** A plugin object with `type: 'ui'` that omits `staticPath`, omits `slug`, or spells either as `undefined`. Nothing else: every other declared type (`standard`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`), and a plugin declaring no `type` at all, still parses with neither key. A PRESENT value is judged exactly as before — `slug` keeps its `/^[a-z0-9-_]+$/` regex, `staticPath` stays any string, and the empty string is not refused by this change. - -**What a refusal looks like.** One zod issue per missing key, `path` naming the key, the new stable code `PLUGIN_UI_REQUIRED_KEY_MISSING` (exported from `@objectstack/spec/kernel`) at the head of the issue `message` and on the issue's `params.code`. At `kernel.use()` it rides the existing `PLUGIN_CONTRACT_VIOLATION` envelope unchanged, because the loader surfaces the first issue's `path` and `message` and reads nothing else: - -``` -PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared -plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` -plugin must declare `staticPath` — the absolute path of the static assets it -serves. Declare it, or drop `type: 'ui'` if this plugin serves no assets. -``` - -**The fix for an affected plugin** is the one the message names: declare both keys (`staticPath`: the absolute path of the assets it serves; `slug`: the URL segment it is mounted under), or drop `type: 'ui'` if the plugin serves no assets. There is no fallback to lean on: the Hono server's `slug || name.split('/').pop()` derivation is no longer reachable through the kernel, because the object is refused before it is stored. - -**`@objectstack/core` — `Plugin` derives its metadata keys.** `Plugin` now `extends PluginDefinition` (`z.input`), so `id`, `type`, `staticPath`, `slug`, `default`, `version`, `description`, `author` and `homepage` are ONE declaration shared with the schema the kernel enforces. Additive for every existing implementer: `type` and `version` keep the shapes they had (`type` is still `PluginType | undefined`, pinned type-equal in `packages/rest`; `version` still `string | undefined`), and the seven other keys are new optional members. A `ui` plugin can now carry `staticPath` / `slug` without widening its own type. Runtime-only members (`name`, `dependencies`, `optionalDependencies`, `requiresServices`, `providesServices`, `init`, `start`, `destroy`) stay declared on the interface. - -**Blast radius, measured.** No in-repo plugin object outside test fixtures declares `type: 'ui'` (searched `packages/`, `apps/`, `examples/` non-dist sources for a `type` key or class field holding the literal `'ui'`: three test files, nothing shipped), so no in-repo composition changes behaviour. Externally authored `ui` plugins that relied on the slug derivation, or declared no assets, are the population this reaches — and they are refused at boot, by name, with the key to add. - - diff --git a/.changeset/plugin-security-default-set-answer-not-container.md b/.changeset/plugin-security-default-set-answer-not-container.md deleted file mode 100644 index a128ef51d6..0000000000 --- a/.changeset/plugin-security-default-set-answer-not-container.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298) - -`declaredPermissionSets` carried a docblock stating a short-circuit its code did -not have: - -> The `packages[]` pass only supplies a set where the top level had none — which -> is precisely the option-B artifact. - -The code pushed the flattened top level and then **every** package body -unconditionally, so on today's additive artifact (flattened level *and* -`packages[]` both present) every permission set was collected twice. Nothing -observable came of it — the sole caller is private and takes the first -`isDefault` set, which the flattened copy still supplied — so this corrects a -false written contract on a security-path reader, not a live defect. That -distinction is the point: the sentence was load-bearing, because it was the -stated reason the reader half was revertible on its own and safe to land before -the emitter half (#14512), and the next reader would have believed the mechanism -was there. - -⚠️ Release-notes note: this supersedes one sentence of the #15226 entry in this same -unreleased batch — "The resolution now reads the flattened top level FIRST and then each -package body". That described #15226 accurately when it landed; after this change the -`packages[]` pass runs only where the top level named no default. The earlier entry is -left as written rather than retro-edited, so whoever compiles the notes collapses the two -deliberately instead of reading a contradiction. - -The reader now walks the discipline the docblock claims — start from the -expression this program replaced, `appDefaultPermissionSetName(config.permissions)`, -and consult `packages[]` only where it came back `undefined`. - -- **The condition is the resolved NAME, never the `permissions` container.** - Branching on the container re-creates the silent loss the reader program - exists to remove, one shape further along: a flattened level that carries - permission sets but marks none of them `isDefault` is legal today and - hand-authorable in any `objectstack.config.ts`, and a container-shaped - condition (`Array.isArray(flattened)`, with or without `&& length > 0`) shorts - it past the whole `packages[]` pass and answers `undefined` — nothing thrown, - nothing logged, every member of the app back down to the platform floor alone. - Reading the answer also retires the `[]`-is-truthy trap rather than patching - around it. -- **The package order is resolved BEFORE the top level is consulted.** - `resolveArtifactPackageOrder` refuses a malformed `packages` — not an array, - an entry inlined instead of wrapped under `manifest:`, a duplicate package id - — with an ADR-0112 envelope this reader does not catch, and that refusal must - not become conditional on whether the flattened level happened to name a - default first. An artifact is either loadable or refused; which level answered - is not part of that question. -- **No emitted artifact changes its answer.** Measured, not argued: 26 shapes — - the composed additive artifact, its option-B derivative, the collection-zoo - fixtures behind the #15004 acceptance pin, every config the unit suite drives, - the three malformed-`packages` refusals, and the hand-authored mixed shapes — - return byte-identical results before and after, with `@objectstack/plugin-security` - rebuilt and the change proven present in `dist/` on each leg. diff --git a/.changeset/plugin-security-scanner-ledger-entry.md b/.changeset/plugin-security-scanner-ledger-entry.md deleted file mode 100644 index c904cd9283..0000000000 --- a/.changeset/plugin-security-scanner-ledger-entry.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -ADR-0087 semantic-migration ledger: register the retirement of `@objectstack/core`'s `PluginSecurityScanner` (#14919) - -`PluginSecurityScanner`, `ScanTarget` and `SecurityIssue` are removed from -`@objectstack/core` in the same PR, under ADR-0049 enforce-or-remove (maintainer -ruling 2026-09-05, director summon #14, decision batch #42). This is the ledger -half: a D3 semantic entry -(`src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts`, -concatenated into `MIGRATIONS_BY_MAJOR[18].semantic` by `gen:migration-registry`) -so the retirement reaches `spec-changes.json` and the generated upgrade guide -rather than being invisible to every upgrade channel. - -FROM `new PluginSecurityScanner(kernel.logger)` → TO nothing: delete the import -and every call. There is no replacement export, and a caller that branched on -`result.status === 'passed'` takes that branch unconditionally — it is the only -branch the scanner ever produced, because four of its five scan methods returned -an empty issue list on every input and the fifth read a vulnerability database -whose only writer had zero callers. - -Why an entry is owed at all, and why D3 rather than a D2 conversion: the class -has no spec schema and never had one. It is a runtime TS class, so there is no -authorable key to tombstone with `retiredKey()` and no stored `sys_metadata` row -a conversion could rewrite — a scanner was constructed per call and every result -lived in a per-instance Map discarded with the object, so -`applyConversionsToStoredItem` has no seam that would ever see one. The enforced -channel is tsc at the consumer's own import site; for anyone it does not reach, -this entry and the upgrade guide are the only channel. That is the -`contracts.IDataDriver.findStream` and `actor-user-roles-to-positions` -disposition, applied to a surface one layer further out than either — those are -declared in `packages/spec`, this one only in `packages/core`. - -Measured, and worth recording because the entries README warns of a regeneration -lap that did not materialise here: `check:generated` reports all 15 artifacts up -to date after the entry landed, and running `gen:spec-changes` and -`gen:upgrade-guide` explicitly moved neither file — a major-18 semantic entry is -not yet projected into either. `registry.ts` is the whole generated diff. - -No behaviour in `@objectstack/spec` changes; this adds a ledger row and the -regenerated region that carries it. diff --git a/.changeset/plugin-security-scanner-retired.md b/.changeset/plugin-security-scanner-retired.md deleted file mode 100644 index b05dacd33c..0000000000 --- a/.changeset/plugin-security-scanner-retired.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919) - - - -**ADR-0087 disposition: registered**, as `plugin-security-scanner-retired` in -`MIGRATIONS_BY_MAJOR[18].semantic` — a **D3 semantic** entry, not a D2 conversion, -and so not the metadata migration the ruling excludes. The class has no spec schema -and never had one, so there is no authorable key to tombstone with `retiredKey()` -and no stored `sys_metadata` row a conversion could rewrite: a scanner was -constructed per call and every result lived in a per-instance Map discarded with the -object, so `applyConversionsToStoredItem` has no seam that would ever see one. An -entry is nevertheless owed rather than optional, because this changeset carries a -real consumer prescription — the enforced channel is tsc at the import site, and for -any consumer it does not reach, the ledger and the generated upgrade guide are the -only channel there is. Same disposition as `contracts.IDataDriver.findStream` and -`actor-user-roles-to-positions`. - -**BREAKING** — `PluginSecurityScanner` is removed from `@objectstack/core`, -together with its two companion types `ScanTarget` and `SecurityIssue`. Landing -as `minor` under the repo's launch-window convention for breaking changes. -**There is no replacement**, and none is planned. - -⚠️ **The out-of-repo consumer population for these three exports is NOT -MEASURED.** This changeset can state only what was measured *inside* the -sources this repo can read: zero constructors in objectstack, zero in objectui -at the pinned sha, and zero in the deleted example itself. How many published -consumers of `@objectstack/core` import the class is unknown — no download, -dependent or source telemetry was consulted. Read the removal as breaking for -an unmeasured population, not as a removal proven to break nobody. - -## Why it was removed rather than repaired - -The class was a shell that reported success. `scan()` composed five private -scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, -`scanConfiguration`) allocated an empty issue array, logged, and returned it -with no code in between — none could report a finding for any input. The fifth, -`scanDependencies`, ran a real loop but matched only against an in-memory -vulnerability database whose sole writer, the public `addVulnerability`, had -zero callers; `updateVulnerabilityDatabase()` logged twice and fetched nothing. -The database was therefore empty on every code path that has ever executed, so -no issue was ever produced, the score stayed 100, and the result was -`status: 'passed'` for every plugin the scanner was ever handed — a malicious -one included. - -A security control that cannot fail is worse than no security control, because -callers rely on it. Repair — writing a real vulnerability scanner — was refused -by name: it is a feature with a design surface and no demand, not a defect fix. - -## FROM → TO - -```ts -// FROM — compiles today, and passes every plugin it is given -import { PluginSecurityScanner } from '@objectstack/core'; - -const scanner = new PluginSecurityScanner(kernel.logger); -const result = await scanner.scan({ pluginId, version, dependencies }); -if (result.status === 'passed') { await kernel.use(plugin); } - -// TO — delete it. The condition above was always true. -await kernel.use(plugin); -``` - -**The one-line fix:** delete the import and every call; no symbol replaces it. -If your code branched on `result.status`, take the `'passed'` branch — that is -the only branch it ever took. - -**If you were relying on it for actual security**, you were not getting any. -Audit dependencies with the tools built for it (`npm audit` / `pnpm audit`, -Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited -third-party plugin as untrusted code. What ObjectStack does still enforce is -artifact **integrity and signatures** (`verifyPluginArtifactIntegrity`, the -plugin signature verifier — "is this what the publisher signed?", never "is -this safe?"), explicit plugin **permissions**, and the sandbox **resource -limits**; all three are unchanged. - -Removed under ADR-0049 enforce-or-remove, per the maintainer ruling of -2026-09-05 (director summon #14, decision batch #42). The retirement is pinned -as an export-list assertion on both barrels in -`packages/core/src/security/security-scanner-retirement.pin.test.ts`. diff --git a/.changeset/plugin-sharing-field-recipient.md b/.changeset/plugin-sharing-field-recipient.md deleted file mode 100644 index 89177fd28b..0000000000 --- a/.changeset/plugin-sharing-field-recipient.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/plugin-sharing": minor ---- - -feat(plugin-sharing): the `field` sharing recipient is enforced — expanded once per matched record - -`ShareRecipientType` gained `field` on the spec side (#14103, maintainer ruling -B): `sharedWith: { type: 'field', value: '' }` shares each -record the rule's criteria match with the user or users named by that column -on the record. This is the executor half (#15072): - -- `SharingRuleService` reads the named user-typed column on each matched - record. A `multiple: true` column shares with every user it names; a single- - user column with the one it names. **Fail-closed on empty**: a null or empty - column materialises no grant — never a match-all principal, never a fallback - to the record owner. `field` is the only recipient resolved per record; every - other kind (`user`, `team`, `position`, `business_unit`, - `unit_and_subordinates`) still expands once per rule. -- The grants re-materialise on the record's own write: the existing - `afterUpdate` hook has no changed-field gating, so an update that touches only - the recipient column re-runs the per-record reconcile, which revokes the - stale grant and materialises the new one. No second trigger was added. -- The whole-rule pass (`evaluateRule` — the background re-grant after an - unbounded bulk write, the `kernel:bootstrapped` backfill and the REST evaluate - endpoint) derives per-record (record, user) pairs for a `field` rule instead - of a matched-records × recipients product, so the rule is as correct after a - bulk write and a restart as it is inline. The recipient-axis revoke - (`revokeRuleGrantsForRetiredRecipients`) declines `field` rules — they have no - rule-wide recipient set to retire against. -- The declared-rule bootstrap seeds `field` rules (previously skipped with a - warning), the `sys_sharing_rule.recipient_type` select accepts `field`, and - `defineRule` refuses a `field` recipient whose `recipientId` is not a field - name (the same grammar the spec applies at parse). -- An active `field` rule whose column the object does not declare as user-typed - grants nobody and says so once per rule. - -There is no `manager` recipient: "the owner's manager" is a user field the -application stores on the record, named by a `field` recipient. diff --git a/.changeset/positions-name-is-not-platform-authority.md b/.changeset/positions-name-is-not-platform-authority.md deleted file mode 100644 index 76c63b3a8f..0000000000 --- a/.changeset/positions-name-is-not-platform-authority.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch -"@objectstack/plugin-approvals": patch -"@objectstack/plugin-security": patch -"@objectstack/runtime": patch ---- - -Four server-side authorization sites stop deriving platform-operator authority from a NAME in `ExecutionContext.positions`, and read the ADR-0095 posture rung instead. - -`positions[]` is the security axis, so it carries ADR-0057 D4 `sys_user_position` names alongside the built-ins. `sys_user_position` is `apiEnabled` and its `position` values are unconstrained, so a tenant could mint a row spelling `platform_admin` for one of their own users: `resolveUserAuthzGrants` pushed that name straight onto `grants.positions`, while `grants.posture` — derived from the unscoped `admin_full_access` grant and nothing else — correctly stayed `MEMBER`. Every reader of the name therefore answered `true` for a principal enforcement treats as an ordinary member. `resolve-authz-context.ts` states the rule at `hasPlatformAdminStanding` ("read the RUNG — never `positions.includes(...)`"), but a comment is not a gate and these four had not followed it. - -Each site now tests `posture === 'PLATFORM_ADMIN'`, byte-for-byte what `hasPlatformAdminStanding` returns: - -- **`plugin-sharing`** — `hasPlatformAuthority`. The minted row satisfied `assertResolvableAdminScope`, so an org-less caller holding only the ORG-scoped `manage_sharing` capability was answered with **every tenant's** sharing rules, and could delete platform-global rules. The `manage_platform_settings` capability spelling is unchanged. -- **`plugin-approvals`** — `isOverrideActor`. This predicate already read the rung and then ORed the name onto it, which is no protection: an OR is only as strong as its weakest arm. Because the platform arm deliberately crosses the tenant wall, the minted row let a member of one organization approve, reject or recall a **different organization's** pending request while holding no slot in its slate. The `ADMIN_FULL_ACCESS` capability arm and both TENANT_ADMIN arms are unchanged. -- **`runtime`** — the ADR-0126 §5 activation gate. Under a `group` or `isolated` posture this gate is the only thing between a tenant org admin and the **install-wide** `sys_metadata_activation` row, so the minted row reopened #10243 with a durable row behind it. -- **`plugin-security`** — `derivePosture` in the explain engine. Narrower than the other three, and stated precisely rather than overclaimed: the name-read sat behind an early `ctx.posture` return that `buildContextForUser` always populates, so the shipping path was already gated and a D4 row never moved it. What the read did reach was a posture-less hand-built context, where it made the panel **report** `PLATFORM_ADMIN` for a principal enforcement treats as a MEMBER — a misreport rather than an admission, but in the one tool an administrator opens to check exactly this. - -No behaviour changes for a genuine platform operator: their resolved context carries the rung, and the built-in position is still projected onto `positions[]` for display and predicate use. What changes is that the name alone no longer answers the authorization question. - -Graded `patch` on the surface it moves: no exported type, signature or contract changes, and no authorable metadata is added, removed or renamed. The only observable difference is that a principal who never held the capability grant stops being admitted — which is the defect, not a feature anyone could have depended on. diff --git a/.changeset/preview-column-enrichment.md b/.changeset/preview-column-enrichment.md deleted file mode 100644 index 04389e6b07..0000000000 --- a/.changeset/preview-column-enrichment.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Analytics: a draft-preview dataset response now describes its columns like the live one - -`AnalyticsService.queryDataset`'s ADR-0037 P3 draft-preview branch returned before the -ADR-0021 result-column enrichment ever ran, so a dataset queried while the base object had a -pending seed draft came back with none of its column metadata: `fields[].label`, `format`, -`currency`, `percentScale`, `builtinAggregate`, and the temporal `type` correction were all -absent, on measure and dimension columns alike. A renderer then fell back to humanizing the -raw measure name and guessing a percent scale from magnitude — so the same dataset in the -same widget described its columns differently depending only on whether a pending seed draft -existed, which is the surface an author is looking at while authoring the dataset. - -Every one of those keys is read off the authored dataset and the source object's field -metadata, never off the rows, so the enrichment is now one method both paths call. Dimension -VALUE label resolution (resolving a lookup id to a display name) stays skipped on the preview -path deliberately: drafted seed rows reference lookups by name, so there is no id to resolve. diff --git a/.changeset/prompt-template-experimental-marking.md b/.changeset/prompt-template-experimental-marking.md deleted file mode 100644 index e1f3afff30..0000000000 --- a/.changeset/prompt-template-experimental-marking.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): mark `PromptTemplate.system` / `.user` `[EXPERIMENTAL — not enforced]` (#15954, #16321) - -Prose only. `Clause-②: no` — no accept-set change, no new/narrowed authorable -key, no matrix declaration. Every value that parsed before parses now, and -every value refused before is refused identically. - -Under the #15954 ruling (decision batch #56, option B) the template-typed pair -is **marked, not retired**. Both `.describe()` strings on -`ai/PromptTemplateSchema` now carry the repo's existing -`[EXPERIMENTAL — not enforced]` prefix and state that no runtime renders or -executes the template today: - -```ts -system: TemplateExpressionInputSchema.optional().describe('[EXPERIMENTAL — not enforced] System prompt — supports {{var}} interpolation. No runtime renders or executes the template today.'), -user: TemplateExpressionInputSchema.describe('[EXPERIMENTAL — not enforced] User prompt template — supports {{var}} interpolation. No runtime renders or executes the template today.'), -``` - -**Why an author sees this.** `PromptTemplateSchema` has no consumer outside -`packages/spec`, so the `{{var}}` holes are never interpolated and the declared -`variables` are never checked against them. The ADR-0058 D7 conformance ledger -already recorded that verdict (`template-prompt`, `state: 'experimental'`, -`PARSE ONLY — NO EVALUATOR FOUND`); until now nothing said it at the -declaration, so the generated reference page advertised a capability the -runtime does not deliver. - -**What does NOT change.** `.user` remains **required** and `.system` remains -optional — the schema shape is untouched. Optionalising or retiring a required -key is a parse-breaking change and is deliberately left to its own card. No -tombstone and no ADR-0087 entry is owed: nothing is renamed, retired or -re-typed. diff --git a/.changeset/provenance-stamp-per-row-dispatch.md b/.changeset/provenance-stamp-per-row-dispatch.md deleted file mode 100644 index 80fb62496d..0000000000 --- a/.changeset/provenance-stamp-per-row-dispatch.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/plugin-sharing": patch -"@objectstack/plugin-webhooks": patch ---- - -The three provenance-stamp `beforeUpdate` hooks stop re-reading a row the engine has already read, and their contract now states what they actually do on a multi-row update. - -`sys_email_template`, `sys_sharing_rule` and `sys_webhook` each carry a hook that stamps `customized: true` when a non-system caller edits a package- or platform-seeded row — the half of seed-not-clobber that detects the admin edit. All three carried the same two comments, and both were assertions about runtime behaviour that runtime measurement falsifies: - -- **"multi-row updates (no single `input.id`) are not stamped."** Not true on any engine these packages ship against. A predicate (`multi: true`) update dispatches `beforeUpdate` once per matched row, and every per-row context arrives with `input.id` bound — so the `if (!id) return` guard answered "single write" on every row of a batch and declined nothing. The rows were being stamped all along. -- **"`previous` is not resolved before beforeUpdate hooks run — read the current row ourselves."** The engine binds `previous` before dispatching `beforeUpdate` on both write shapes, so each hook was issuing its own `find` for a row the engine had just read — on a bulk edit, one extra read **per matched row**. - -Observable behaviour is deliberately unchanged: the same rows are stamped, with the same values, and a bulk edit whose matched rows disagree on `managed_by` is still refused by the engine with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP 400) rather than widening one row's stamp across the batch. What changes is the cost and the contract: the redundant per-row read is gone, and the header of each hook now describes the per-row dispatch, the single `SET` clause a predicate write shares, and why declining to stamp on a bulk edit was rejected — unstamped rows are exactly the ones the next boot's seeder overwrites. diff --git a/.changeset/public-picker-filter-lowering.md b/.changeset/public-picker-filter-lowering.md deleted file mode 100644 index 867b2569c2..0000000000 --- a/.changeset/public-picker-filter-lowering.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -`GET /forms/:slug/lookup/:field` answers a search again: the public-form lookup picker no longer refuses every non-empty query with `400 INVALID_FILTER`. - -The route composed its filter list out of `ViewFilterRule` objects — the `{ field, operator, value }` dialect `FormFieldPublicPickerSchema.filter` declares in so many words ("Same `{ field, operator, value }` dialect as list-view filters") — and put them straight onto the `findData` filter slot. That slot accepts a `FilterCondition` object or a `FilterArray` (`[field, operator, value]`, a logical node, or a list of those) and refuses anything else. The refusal did not depend on an author declaring `publicPicker.filter`: the route's own `q` predicate is built in the same object shape, so **every** non-empty search was refused and only the degenerate empty-filter call could succeed — on an anonymous surface where a public-form applicant has no way around it. - -- **The route lowers; the parser is untouched.** The composed rows are translated to the array grammar the ingress parses, at the one door that speaks both dialects. ⛔ The repair deliberately NOT taken is teaching `findData` a second dialect: that maintains two filter grammars in the data layer permanently and spreads the object shape to every `findData` caller. The declaration already promises the object dialect on the authoring surface, so what changes is the side that failed to honour the promise. A test keeps the control that the object shape fed to the parser directly is still refused, so "the route lowers" cannot be confused with "the parser was loosened". -- **Both branches.** The declared `publicPicker.filter` rows and the route's own `contains` search row are lowered together and ANDed explicitly; no declared filter still means no filter (`[]`), never an empty logical node the ingress would refuse. -- **The operator fold is the spec's own.** Lowering reuses `normalizeFilterOperator` from `@objectstack/spec/ui` — the fold `ViewFilterRuleSchema.operator` itself runs — so a stored row carrying a legacy spelling (`notEquals`, `isNotEmpty`, `gt`) folds exactly as the schema folds it. No second alias table. -- **A rule that cannot be read is forwarded, not dropped.** The request is then refused exactly as before. That direction is deliberate: a picker's static filter is often the only thing keeping an anonymous visitor's search inside the rows a form may expose, and silently skipping a row nobody understood would answer 200 over an unfiltered table. - -No authoring surface moves: `FormFieldPublicPickerSchema` already declared this dialect as accepted, and this makes the runtime honour it. diff --git a/.changeset/public-sharing-enabled-canonical-predicate.md b/.changeset/public-sharing-enabled-canonical-predicate.md deleted file mode 100644 index 17b3856580..0000000000 --- a/.changeset/public-sharing-enabled-canonical-predicate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-sharing": patch -"@objectstack/runtime": patch ---- - -`publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. - -`isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. - -Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. - -Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. diff --git a/.changeset/public-sharing-enabled-standing-policy-tsdoc.md b/.changeset/public-sharing-enabled-standing-policy-tsdoc.md deleted file mode 100644 index 1aaf837f68..0000000000 --- a/.changeset/public-sharing-enabled-standing-policy-tsdoc.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Document `publicSharing.enabled` as the standing policy it is, and name the switched-off block among `resolveToken`'s `null` causes. - -The TSDoc above `publicSharing.enabled` read "when false, no share links can be issued for this object" — true, but only the mint half. Since the switch became a standing policy held at every redemption, a block that is off also stops every existing link on it from resolving: links minted while it was on, and links minted through the system-context / `permissive` mint bypass alike. Re-enabling the block serves them again; no row moves. The comment now says so, in the shape the sibling `eligibility` predicate's prose already uses. - -`IShareLinkService.resolveToken` enumerated the causes of its undifferentiated `null` — unknown, revoked, expired, audience, password, record gone, ineligible — without the switched-off block, so an implementer reading the list to enumerate refusal causes got an incomplete set. The list now carries it, in the position the gates run; the contract's design notes gain a matching entry beside the eligibility one, and the `isSystem` mint bypass is marked as mint-only. - -Documentation only: no schema, shape or behaviour change, and the `.describe()` string that feeds the generated reference is untouched. Where the corrected text reaches consumers, measured on the built package: every new line in `share-link-service.ts` ships in the published `dist/contracts/index.d.ts` (the interface-member docs and the module design notes both survive the declaration bundle); the `object.zod.ts` property comment reaches no `.d.ts` (the schema's declaration is an inferred type) and ships through the source file `@objectstack/spec` publishes directly (`src/**/*.zod.ts`) and through `dist/data/index.js.map`. diff --git a/.changeset/published-cli-stderr-nonblocking-guard.md b/.changeset/published-cli-stderr-nonblocking-guard.md deleted file mode 100644 index 60ce0d2ac3..0000000000 --- a/.changeset/published-cli-stderr-nonblocking-guard.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -The published `os` binary no longer freezes in the kernel when whatever is reading its output stops draining. - -Node puts the CLI's stderr on the non-blocking write path when it opens the pipe, so a write to a reader that has stopped is buffered rather than parking the thread. libuv clears that flag again in the pre-exec of every child spawned with **inherited** stdio — and inheriting is `dup2`, so the flag lives on an open file description the spawner shares. Clearing it for the child clears it for the CLI too. - -Measured on the built binary, `os dev --verbose` with its output piped to a reader that stopped draining: `os dev` spawns `os serve --dev` with inherited stdio at 2.8 s, that child spawns the esbuild service with inherited stderr at 5.2 s, and fd 2 stays blocking for the rest of the run. 3.1 s after the reader stopped, the main thread sat in `write(2)` (`wchan=sock_alloc_send_pskb`), 4 of 4 runs — parked 28.9 s, **ignoring SIGINT while parked**, and released only when the consumer resumed. Not a crash and not a timeout: alive, idle, unresponsive, with an empty log. Anything that pipes `os dev` and reads it slowly — a CI log collector, a backgrounded runner, a supervisor that stops draining while it does work — could park the CLI this way. - -`bin/run.js` now installs `keepStderrNonBlocking()` before oclif can write a byte. The guard re-asserts `O_NONBLOCK` immediately ahead of each write, which is what the measurement requires: the clearing that persisted was made by a **grandchild** the CLI does not spawn and cannot see, so a one-shot at startup would be undone silently and no change to the CLI's own spawn sites would have prevented it. - -The guard itself is not new — it shipped in no published install. It lived at `packages/cli/bin/stderr-nonblocking.mjs`, and `files` names only `dist`, `README.md` and `CHANGELOG.md`; npm packs a `bin` **target** regardless of `files`, which is why `bin/run.js` reached every install and the module beside it reached none. It now compiles from `src/utils/stderr-nonblocking.ts` into `dist/`, under the whitelist that was already there. - -Nothing about which arguments the CLI accepts, what it prints, or what it exits with changes. The refusal of `setBlocking(true)` in `src/utils/format.ts` stands and is untouched — this is its inverse, and what keeps its premise true. diff --git a/.changeset/quiet-donkeys-smoke.md b/.changeset/quiet-donkeys-smoke.md deleted file mode 100644 index cf48c5fcf8..0000000000 --- a/.changeset/quiet-donkeys-smoke.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os create plugin ` now derives the exported plugin symbol as a JavaScript identifier rather than copying the project name into an identifier position. - -`validateProjectName` accepts exactly what npm accepts — a dot, an underscore and a leading digit included — so `os create plugin foo.bar` used to exit 0 having written `export const foo.barPlugin: Plugin = {`, a property access where a binding name belongs. The scaffolded project did not parse. - -What the command accepts is unchanged, and so is what it emits as a name: the package name, its scope and the project directory stay byte-for-byte what was typed. Only the code identifier is normalised — every run of characters that is legal in an npm name but illegal in a JavaScript identifier now folds the way `-` already did, and a leading digit takes an `a` prefix. Ordinary names are unaffected (`my-app` still exports `myAppPlugin`). The emitted README names the derived symbol in prose, so the mapping is stated where it is read. diff --git a/.changeset/quiet-pans-repair.md b/.changeset/quiet-pans-repair.md deleted file mode 100644 index b88b4b5d68..0000000000 --- a/.changeset/quiet-pans-repair.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Remove seven dead `{ records }` union-normalizer limbs on engine `find()` results, and repair the one that was silently dropping instead of gapping. - -Six seams in this plugin normalized an engine read as `Array.isArray(x) ? x : x.records`. The envelope limb was unreachable: `ObjectQL.find` resolves a bare array of row objects, measured by booting a real engine over a real `SqlDriver` and driving each seam through the shipped function that owns it, rather than inferred from `IDataEngine.find`'s declared `Promise` (a declared type is not proof — this repo also has a `find()` that resolves an envelope). Each seam keeps its existing disposition for a non-array; only the dead limb is gone. - -The seventh is repaired in the opposite direction. `SecurityPlugin`'s `sys_permission_set` loader mapped three different facts onto one value: a read that succeeded on an empty catalog, a read that threw, and a read that resolved something it could not read all left as `[]`. On the enforcement plane that silently withdraws grants that exist while every request still looks normal, and it made `PermissionEvaluator`'s existing "db lookup failed" warning unreachable — so a transient database error and an empty catalog produced identical, undiagnosable 403s. The loader now lets the read fault propagate and refuses an unreadable result with `DATABASE_ERROR`. Enforcement is unchanged for every result the shipped engine produces; an envelope or a non-row element now refuses (fail-closed) where the old code read through it. An unanswered read still grants nothing; what changes is that it is now reported instead of silent. diff --git a/.changeset/react-list-view-binding-aliases-retired.md b/.changeset/react-list-view-binding-aliases-retired.md deleted file mode 100644 index a3937ac4f0..0000000000 --- a/.changeset/react-list-view-binding-aliases-retired.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor -"@objectstack/example-showcase": patch ---- - -feat(spec)!: `` / `` are retired from the react-tier component contract — `data={{ provider: 'object', object }}` / `type` are the only spellings (#14791) - - - -**BREAKING** — an accept-set narrowing on a published contract. The `REACT_BLOCKS` -ListView entry no longer publishes the `objectName` and `viewType` overlay props that -#11284 had deprecated in favour of ListViewSchema's own `data` / `type`: the generated -contract (`skills/objectstack-ui/references/react-blocks.md`) drops both rows, and -`@objectstack/lint`'s `validate-react-page-props` now REFUSES either spelling on a -`kind:'react'` page with a new `react-prop-retired` error that carries the fix, where it -used to warn and accept. Shipped as `minor` under the repo's launch-window convention for -breaking changes; the hand-migration prescription is registered under protocol major 18 -(`ui-react-list-view-binding-aliases-retired`). Maintainer ruling on #14791 (2026-09-07, -director seat summon #17, decision batch #1, option B — retire now, no deprecation window, -「同意」). - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `` | `` | -| `` | `` | -| `` with no binding at all | add `data={{ provider: 'object', object: '…' }}` — it is the required binding on a react page | - -One-line fix: on every `` in react page source replace `objectName="X"` with -`data={{ provider: 'object', object: 'X' }}` and `viewType="K"` with `type="K"`, then re-run -`objectstack validate` — a leftover alias is reported as `react-prop-retired` with this -same prescription, and a list with no data source as `react-prop-missing-required`. - -## Why now, and why no window - -The contract deprecated both aliases (#11284) while objectui's ListView still read only -`objectName`, so a page written the canonical way validated green and rendered an empty -list. That consumer half has landed and ships in the console this repo pins -(`normalizeListViewSchema` at `a472b071` folds `data.provider === 'object'` onto the key -the renderer reads and takes the author's `type` for the view kind), so both spellings -render today — and the maintainer's standing rule for a spelling with zero external -authors is to retire it at once rather than keep two vocabularies alive. - -## What else moved - -- `REACT_RETIRED_OVERLAY_PROPS` is a new export of `@objectstack/spec/ui`: the tombstone - ledger (prop → replacement + one-line fix) the lint quotes, the react-tier twin of a - metadata schema's `retiredKey()`. -- `data` is restated on the ListView overlay as its **required** binding (ledgered in - `REACT_OVERLAY_SHADOWS`), so the generated contract marks it ✓ and a `` with no - data source is refused — the check the required `objectName` used to carry. -- `REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']` prescribes the canonical spelling. -- The showcase pages (`crm-workbench`, `renewals-pipeline`, `task-desk`), the published - `objectstack-ui` skill and the react-pages / validating-metadata guides write the - canonical spelling; `@objectstack/lint` exports `REACT_PROP_RETIRED`. diff --git a/.changeset/readonly-field-rejected-code-constant.md b/.changeset/readonly-field-rejected-code-constant.md deleted file mode 100644 index af3a98ec62..0000000000 --- a/.changeset/readonly-field-rejected-code-constant.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -`ReadonlyFieldRejectedError`'s error `code` is now an importable constant. - -The strict-readonly refusal — thrown by `engine.update` and `engine.insert` when `options.strictReadonlyWrites` is set and the payload carried caller-supplied fields the engine would have stripped — already told readers to identify it by `code`. `content/docs/kernel/contracts/data-engine.mdx` says so in its own words: *"Catch it by `code`, not `instanceof`, and read `drops` for the per-reason breakdown"*. Until now the code was an inline string literal with nothing to import, so the only way to FOLLOW that published instruction was to re-spell `'ERR_READONLY_FIELD_REJECTED'` in your own package — which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. - -One new export from `@objectstack/objectql`: - -- `READONLY_FIELD_REJECTED_CODE` — `ReadonlyFieldRejectedError`'s ADR-0112 `code`. - -**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of the class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary, which is exactly what the documentation has been telling readers to do. - -**Nothing about the wire changed.** The constant holds text byte-identical to the literal it replaces; the refusal throws the same `code` and the same message as before. Consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing. - -**`ReadonlyFieldRejectedError` itself was already exported and stays exported.** Unlike the classes converted alongside it on this sweep, both routes are published here, so the class and the constant must name the same refusal; a test pins that they do. diff --git a/.changeset/ready-signal-reports-degraded-boot.md b/.changeset/ready-signal-reports-degraded-boot.md deleted file mode 100644 index cf977568ea..0000000000 --- a/.changeset/ready-signal-reports-degraded-boot.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/core": patch -"@objectstack/cli": patch ---- - -The `Server is ready` line now reports the degraded boot it is standing on, instead of printing a green `✓` over it. - -`✓ Server is ready` and the kernel's `System started with degraded capabilities. Missing core services: …` were two statements about one boot, produced by two packages — the banner in `@objectstack/cli`, the conclusion in `@objectstack/core` — with **no data path between them**. So the ready signal did not depend on the thing that broke, and therefore could not report it. Measured twice within a day, from unrelated causes: an objectui CI boot where the auth plugin failed and not one `sys_*` table existed, and this repo's own weekly registry canary on the published `npx create-objectstack@latest` on-ramp, where the tick printed directly **above** four boot warnings. In the second case the ready line carried no weight in the job's verdict at all — it was present, green, wrong, and believed by nobody. - -- **The data path.** `ObjectKernel.validateSystemRequirements()` now publishes the list it had already computed — the same array behind its own warning — on the kernel's service registry, which is the seam boot facts already cross to reach the banner (`serve` reads `auth` and `seed-summary` off it the same way). No member and no type is added to `@objectstack/core`'s public surface, and nothing re-derives which services count as `core`: that judgement stays in `ServiceRequirementDef` alone. -- **The line.** On a degraded boot the banner prints `⚠ Server is ready — DEGRADED: missing core services: `, naming exactly what the kernel found missing. On a healthy boot the ready block is byte-for-byte unchanged, so an ordinary boot's output does not move. -- **Readiness is NOT made strict.** Nothing about what boots, binds, or exits changes. A machine deliberately running without auth still starts, still prints ready, and still exits 0 — the line just says what state it is ready in. diff --git a/.changeset/recall-docblock-summary-line-drops-submitter-only.md b/.changeset/recall-docblock-summary-line-drops-submitter-only.md deleted file mode 100644 index f54f4186c5..0000000000 --- a/.changeset/recall-docblock-summary-line-drops-submitter-only.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -Documentation: `ApprovalService.recall`'s docblock summary line no longer claims the submitter is the only actor. - -The block opened with "Withdraw a pending request (submitter only)" and then, three paragraphs down, stated the #3424 privileged override correctly — "The #3424 privileged override reaches a PENDING request only (#12775, maintainer ruling 2026-09-02)". Both cannot be true, and the code settles it in the paragraph's favour: `overrideAdmits` short-circuits the non-submitter guard on a `pending` request. A reader who finishes the block is not misled, but the summary line is the one an editor shows on hover and the one any single-line extraction takes. - -The summary line now reads "Withdraw an undecided request." — status is the axis and the actor rules are left to the paragraphs that already state them correctly, the same structural move the `IApprovalService.recall` docstring makes on the spec side. - -Prose only: no guard, no branch and no signature changed. It earns a changeset rather than `skip-changeset` because `@objectstack/plugin-approvals` publishes `dist/`, and this text ships inside the published `dist/index.d.ts` for `ApprovalService.recall`. diff --git a/.changeset/record-picker-filter-rule-array.md b/.changeset/record-picker-filter-rule-array.md deleted file mode 100644 index 67f9ef2f67..0000000000 --- a/.changeset/record-picker-filter-rule-array.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `ComponentPropsMap['element:record_picker'].filter` converges onto the `ViewFilterRule` array form — the last record-form `filter` in the map (#14406, objectui#6206 Option B) - - - -**BREAKING** accept-set change on one props-map entry, shipped as `minor` under -the repo's launch-window convention for breaking changes; the migration -prescription is registered under protocol major 18. - -One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, -verbatim 「同意」, Option B): after `element:number` converged (#12039 Key 2), -`element:record_picker`'s `filter` was the one `filter` input in -`ComponentPropsMap` still declared as the MongoDB-style record -(`FilterConditionSchema`) while the three array-declared siblings -(`record:related_list`, its nested Add-affordance picker, `element:number`) -declared `z.array(ViewFilterRuleSchema)` — the four `object-*` doors declare -`filter` as `z.unknown()`, #15449 — so the filter a list view stores and -renders was refused by the picker beside it. The entry now declares the same -array form those siblings do, and the `FilterConditionSchema` import that existed for this -one site leaves the file with it. - -Sequenced measurement-first, as that convergence had to be: the `record_picker` -read path was measured at the objectui pin before the declaration moved. The -renderer hands `filter` to `query.$filter` and calls `adapter.find()`, whose -`convertQueryParams` lowers a rule array through `translateFilterArray` into -filter AST tuples — the door every list view's stored rule array already takes -— and nothing on that path parses `properties` against the installed spec. - -**Migration** (`element-record-picker-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 `{ amount: { $gt: 100 } }` becomes -`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys -become several rules (they AND). The record form is refused at `filter` -(`invalid_type`, expected array). The binding-level `dataSource.filter` on the -same node is a different key and is unchanged by this release. - -`ElementRecordPickerPropsParsed` is declared (ADR-0122): the entry's parsed -state now differs from its authored state on `filter` (`operator` normalizes on -parse), so the bare alias is no longer isomorphic. diff --git a/.changeset/reference-helptext-three-locales.md b/.changeset/reference-helptext-three-locales.md deleted file mode 100644 index f6eacdac60..0000000000 --- a/.changeset/reference-helptext-three-locales.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Metadata forms i18n: the `object.fields.reference` help text now carries the -`tree` rule in Spanish, Japanese and Chinese, and no longer claims the field is -for `lookup` / `master_detail`. - -The English source for this row gained a normative sentence on 2026-09-05 — a -`tree` field's `reference` is optional and, when present, must name the -declaring object; a link to a different object is a `lookup`. That rule is -enforced at parse time, so an author who writes a foreign target meets it as a -refusal rather than as guidance. - -The three translated locales still served the pre-2026-09-05 sentence. They -were wrong in both directions at once: they dropped the `tree` rule entirely, -and they asserted a purpose the source no longer states — "(para -lookup/master_detail)" / "(lookup/master_detail 用)" / "(用于 lookup / -master_detail)" — which the `tree` case contradicts. A Spanish, Japanese or -Chinese console therefore told the author that `reference` was for the two -relationship types that exclude `tree`, and gave no hint of the constraint they -were about to hit. - -Only the three `helpText` values move. The `label` siblings, the key set and -the generated structure are unchanged. diff --git a/.changeset/reference-page-block-tag-payload-render.md b/.changeset/reference-page-block-tag-payload-render.md deleted file mode 100644 index 9ad26d1bdd..0000000000 --- a/.changeset/reference-page-block-tag-payload-render.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Reference pages no longer print `@example` and `@category` tag lines as literal text. - -A module docblock is JSDoc, so its header carries block tags, and the reference-docs -renderer emitted a tag written on a prose line verbatim — 18 such lines reached 14 -customer-facing pages, as `@example Basic field mapping` above a code fence and -`@category Security` at the foot of four `system/` pages. `#13796` removed `@module` -from the page and left these two open, because a blanket `^@\w+` line filter would -have taken reader prose off the page and orphaned the fences below it. - -The verdict is per tag, and the axis is the payload rather than the spelling: - -- **`@example CAPTION` is REWRITTEN** into that caption, in bold, above the block it - captions — the shape `@see` already had (`See also: …`). 12 lines across 10 pages. - Bold rather than a heading because heading renumbering has already run by then, so - an emitted heading would carry a level chosen blind of the page, add entries to the - pages' tables of contents, and put a caption in reach of `check:docs-single-h1`. -- **A bare `@example` is DROPPED.** With no payload it is the `@module` case exactly, - and the fence beneath it is visibly an example without a line announcing one. 2 - lines (`studio/plugin`, `studio/object-designer`), both sitting against the - `check:skill-examples` opt-in marker that was already dropped there. -- **`@category VALUE` is DROPPED.** 4 lines, all reading `Security`, on four pages that - already sit under a `system/` section saying as much — and nothing in the repo reads - the tag: no typedoc or api-extractor (neither is used here), no search index, no - gate. Routing it into page frontmatter instead would publish a field with no - consumer. The tag stays in the source, where it is a legitimate JSDoc tag; only the - rendered page drops it. - -No schema behavior changes. The pins assert on the rendered fragment rather than on the -emitted `.mdx`, because `check:docs` compares the artifact against the source and -reproduced all 18 tag lines faithfully. diff --git a/.changeset/references-door-organization-forwarding.md b/.changeset/references-door-organization-forwarding.md deleted file mode 100644 index a642db13da..0000000000 --- a/.changeset/references-door-organization-forwarding.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -The admin "Used by" panel no longer clears a delete when the caller's own organization is using the item. - -`GET /api/v1/meta/:type/:name/references` backs that panel, whose empty case reads "Nothing in the metadata graph points at this item. Safe to delete." — advice given to an operator about to delete something. The door supplied no organization, so the reference sweep read the environment partition only: an organization-scoped `view` (or `dashboard`, `report`, `translation`, `email_template`) pointing straight at the object being deleted was invisible, and the panel issued a false clearance. It now passes the caller's organization, and those references are returned. - -The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field or contract surface. `req.params.type` is the reference TARGET, while the sweep spends the organization on the SOURCES it reads per type; `getMetaItems` applies the `allowOrgOverride` read gate to its own request type, so each source is scoped on its own registry flag. A non-overridable source (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into a delete clearance. An anonymous or organization-less caller reads exactly what it read before, and no status code or response shape moves. diff --git a/.changeset/references-door-refusal-envelope-converged.md b/.changeset/references-door-refusal-envelope-converged.md deleted file mode 100644 index 9c777959ec..0000000000 --- a/.changeset/references-door-refusal-envelope-converged.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -`GET /api/v1/meta/:type/:name/references`: both of the door's 501 refusals now answer the same ADR-0112 nested envelope, and the unanswerable-target refusal keeps the prescriptive message ADR-0110 D3 requires of it. - -The route can refuse in two ways, and the two answers agreed on neither the envelope nor the message: - -``` -A the protocol cannot answer for this TARGET type (a `field`) - 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} -B the resolved kernel has no `findReferencesToMeta` at all - 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} -``` - -A now answers in B's shape, carrying the producer's own sentence: - -``` -501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} -``` - -Why the message matters more than it looks. This door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." to an operator whose next click is a delete. A `field` target can never MATCH a reference site — fields are addressed by the composite `.` key while every property naming one holds the bare name — so the protocol refuses instead of answering an empty list, and its message names the question that IS answerable: ask the owning object. Relayed as "Internal server error", that instruction never reached the operator. - -Two consequences for a caller: - -- `body.error.code` now reads `NOT_IMPLEMENTED` on **both** refusals; the top-level sibling `body.code` this route used to answer on refusal A is gone. `@objectstack/client` reads either position, so `err.code` is unchanged for SDK callers; `err.message` improves from `Internal server error` to the prescriptive sentence. A raw HTTP caller branching on `body.code` for this route's 501 should read `body.error.code`, which is what the route's other refusal has always answered. -- Nothing else on the door moves. A genuine server fault reaching this route — the 503 a `sys_metadata` outage raises — keeps its withheld generic message and its flat body, and 200 answers are untouched. diff --git a/.changeset/refuse-create-at-hook-body-lowering.md b/.changeset/refuse-create-at-hook-body-lowering.md deleted file mode 100644 index 576d8e89c3..0000000000 --- a/.changeset/refuse-create-at-hook-body-lowering.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/lint": patch ---- - -`objectstack build` now refuses to lower a hook/action body that calls `.create(`, and the shared write-pattern ledger stops advertising the verb. Three layers used to disagree about `ctx.api.object('x').create({ … })`, and the loudest one was wrong. - -- The spec contract `IScopedObjectRepository` (`packages/spec/src/contracts/scoped-context.ts`) declares `insert` and names `create` as measured-and-deliberately-excluded. -- The QuickJS sandbox installs exactly `insert / update / delete / updateMany / deleteMany / upsert` as the `ctx.api.object()` write leaves — no `create`. An L2 body calling `.create()` therefore threw `TypeError: not a function` on its **first run**, and under a hook's default `onError: 'abort'` that throw aborted the triggering write, with a message naming no member. -- The extractor ledger nonetheless advertised `.create({…})` as legal `api-crud-literal` syntax and mapped it in `API_WRITE_METHODS`, so `hook-body-write-unknown-field` graded the payload as a live write and stayed silent when the field existed — a clean bill of health for a call that cannot run. Build time said nothing at all. - -What changes: - -- **`@objectstack/cli`** — `.create(` joins `FORBIDDEN_PATTERNS` in the hook/action body extractor, beside `.sudo(` and for the same reason (a member real on the in-process `ScopedContext` / `ObjectRepository` and absent from the VM). The refusal names `.insert({ ... })` as the spelling the sandbox actually has. Behaviour is the `forbidden-token` fallback every other entry has: the callable is still registered and still shipped through the back-compat `.mjs` bundle, so a handler keeps running in-process where the host `create()` alias exists — `objectstack build` merely declines to *also* emit it as a body that cannot run. Under `--strict-body` it is a hard failure, correctly. The rule is receiver-loose like `.sudo(` (`const repo = ctx.api.object('x'); repo.create(…)` is refused too) with one carve-out: `Object.create()` is a real sandbox global and is **not** affected. -- **`@objectstack/lint`** — `create` is withdrawn from `HOOK_BODY_WRITE_PATTERNS`' advertised `api-crud-literal` syntax and from `API_WRITE_METHODS`, on the hook and action surfaces alike. `hook-body-write-unknown-field` / `action-body-write-unknown-field` no longer grade a `.create()` payload; `hook-api-update-readonly-field` keeps its existing `create` exclusion, whose *reason* is updated — it is no longer "the call throws, so a silently-dropped finding would be false" but "the shape can no longer reach this rule at all". - -**Migration.** If a hook or action body calls `ctx.api.object('x').create({ … })`, spell it `ctx.api.object('x').insert({ … })` — the same host method, the one the sandbox installs and the only insert verb the contract declares. The host-side `ObjectRepository.create()` alias is untouched and stays reachable from in-process handlers and actions. diff --git a/.changeset/region-tagger-branch-key.md b/.changeset/region-tagger-branch-key.md deleted file mode 100644 index c888377190..0000000000 --- a/.changeset/region-tagger-branch-key.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -The run step log tells a `parallel` branch apart from a `loop` row: `iteration` is the enclosing loop's iteration, always, and the branch index moves to `branch`. - -The engine half of the ruling `@objectstack/spec` already declares (`ExecutionStepLogSchema.branch`). One field used to hold both meanings, told apart only by reading `regionKind` first, and `runRegion`'s tagger let the innermost region win outright — it skipped any step a nested region had already tagged. Together those two facts made `loop { body: [ parallel { branches } ] }` unreadable: every branch step recorded its branch index and **no** step of that branch recorded the row it ran for, so a per-row failure inside a branch was attributable to a branch and never to a row. That is the shape a fan-out inside a sweep has, and the one an operator most needs to read. - -- **`branch` is written, and only inside a parallel branch.** `parallel` tags its branch regions with `branch: i` instead of `iteration: i`. A step outside a parallel branch carries no `branch` at all. -- **`iteration` is single-valued and carried through nesting.** `runRegion`'s tagger now splits what "innermost wins" governs. The IDENTITY fields — `parentNodeId`, `regionKind`, `retryAttempt` — answer *which region ran this step* and still belong to the innermost region outright; an enclosing region never relabels them. The INDEX fields — `iteration` and `branch` — answer *which pass of which region*, and nested regions contribute different ones that are both true of the same step, so an enclosing region now fills the index the inner region left undefined instead of being turned away at the door. A branch step inside a loop body therefore carries **both**: the row on `iteration`, the branch on `branch`. -- **`try` / `catch` inside a loop is unchanged**, deliberately. Such a region has no index of its own, so its steps keep carrying the enclosing loop's `iteration` with `regionKind` still naming the region, and gain no `branch`. It is the control arm of this change, not a subject of it. -- **Nested loops are unchanged too.** "Fill only what is undefined" still holds in both halves, so for `loop { loop }` the inner loop's `iteration` stands. - -`StepLogEntry` (exported) gains `branch?: number`. It is not derived from the spec type, and is now held equal to it by a type-level pin rather than by a comment claiming they agree. - -**Reading a run recorded before this change.** `iteration` on a `regionKind: 'parallel-branch'` step written by an older engine is a BRANCH index, not a row — the same absent-versus-zero care the run summary's other counters need. Nothing is migrated and nothing is defaulted: a step with no `branch` key is either a pre-change record or a step that ran outside a parallel branch, and `regionKind` is what tells those apart. Bumped `minor` rather than `major` to match the contract half of the same ruling, which shipped its declaration change that way. diff --git a/.changeset/register-namespace-conflict-error-code.md b/.changeset/register-namespace-conflict-error-code.md deleted file mode 100644 index 3fb5ca5e50..0000000000 --- a/.changeset/register-namespace-conflict-error-code.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": minor ---- - -`POST /api/v1/packages` now answers an install-time namespace collision with `error.code: "NAMESPACE_CONFLICT"`. `NAMESPACE_CONFLICT` is registered in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`) gains one member and a caller can branch on the refusal directly. - -**The wire, before and after** — measured through the shipped door (`HttpDispatcher.handlePackages` over a real `SchemaRegistry`), not derived from the call graph: - -- before: `422` with `error.code: "VALIDATION_ERROR"` and `error.declaredCode: "NAMESPACE_CONFLICT"` -- after: `422` with `error.code: "NAMESPACE_CONFLICT"` and **no** `declaredCode` — with the spelling registered there is nothing left to demote - -The status, the message and the throw are unchanged. `NamespaceConflictError` (`@objectstack/objectql`'s `SchemaRegistry.installPackage`, ADR-0048 Phase 1 / ADR-0130 D1) has carried `code` and `status: 422` since the envelope landed; what changed is that the door's #9106 narrowing no longer demotes the spelling. Until now a caller wanting to tell "your namespace is taken, rename it" from every other `422` had to read `declaredCode` — the channel ADR-0112 declares as the open, not-guaranteed one — because `error.code` carried the generic member `422` derives. - -Scope of the widening: one new accept value on `ApiErrorSchema.code`; no export changes, no schema-shape changes, and nothing narrowed. A consumer that treats `error.code` as a closed set it enumerates locally will see a value it does not know, which is what a vocabulary widening means and why this is a `minor`. - -The now-discharged `pending-registration` row ratchets out of `packages/runtime`'s dispatcher-error-vocabulary table in the same change — registration is what makes that row stale, and `pnpm check:dispatcher-error-vocabulary` fails on a registered code still carrying one. The door's answer is pinned in `packages/runtime/src/package-door-namespace-conflict-code.test.ts`, which drives the real route and asserts the body, so the reachability the removed row asserted is now held by a test rather than by a claim. diff --git a/.changeset/register-remaining-boot-refusal-codes.md b/.changeset/register-remaining-boot-refusal-codes.md deleted file mode 100644 index ac42228248..0000000000 --- a/.changeset/register-remaining-boot-refusal-codes.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): register the fourteen remaining `door: 'none'` error codes that ship in `dist` — the rest of the #16404 class after #16449 enters `ERROR_CODE_LEDGER` (#16649) - -Under the #16404 ruling (director seat, decision batch #62, 2026-09-07, option D; maintainer 「同意」) **the published contract face for error codes is `ERROR_CODE_LEDGER` / `StandardErrorCode`**: every `code` that ships in a package's `dist` is registered there, door or no door, because a consumer's `catch (e) { switch (e.code) }` pins the spelling the moment it ships. #16449 registered the nine codes measured on its tree; fourteen more were still shipping unregistered — every `boot-refusal` row `dispatcher-error-vocabulary.ts` carried — and now have rows, each under the package that stamps it: - -| code | stamped by | `status` on the thrown value | reaches an HTTP door on this tree? | -|---|---|---|---| -| `INVALID_ARTIFACT_PACKAGES` · `INVALID_ARTIFACT_PACKAGE_ENTRY` · `DUPLICATE_ARTIFACT_PACKAGE` | `@objectstack/core` (`resolveArtifactPackageOrder`, ADR-0130 D4/D5) | 422 | no — boot-time `manifest.register()` aborts boot; the install route answers with its own `PLUGIN_REGISTER_FAILED` | -| `NO_SUCH_RUN` · `PLAN_CHANGED` · `PREFLIGHT_FAILED` · `NOT_COMPENSABLE` | `@objectstack/core` (`MigrationJournalRefusal`, the migration-journal runner) | none | no — caught by the CLI's `migrate` commands with `instanceof` and printed | -| `SERVICE_NOT_REGISTERED` | `@objectstack/core` (`PluginLoader.getService`'s "never registered" rejection) | none, by design | no — read in-process by the seam that catches the rejection | -| `PLUGIN_CONTRACT_VIOLATION` | `@objectstack/core` (`assertPluginContract`, raised at `kernel.use()`) | none | no — raised while the kernel is still registering plugins | -| `MIXED_ARTIFACT_COLLECTION_SHAPE` | `@objectstack/runtime` (`resolveArtifactCollections`, ADR-0130 D4) | 422 | no — every call site resolves before a transport exists | -| `DUPLICATE_ARTIFACT_OBJECT_NAME` | `@objectstack/objectql` (`SchemaRegistry.installPackage`, ADR-0130 D3) | 422 | no — the HTTP install sites pass no artifact scope, so they cannot raise it | -| `MEMORY_MULTI_TENANT_UNSUPPORTED` | `@objectstack/driver-memory` (the tenancy guard) | none | no — a boot refusal the CLI rethrows pre-HTTP | -| `MONGODB_MULTI_TENANT_UNSUPPORTED` | `@objectstack/driver-mongodb` (the tenancy guard) | none | no — a boot refusal the CLI rethrows pre-HTTP (registered by #3724, unregistered by #8035, re-registered here under the ruling) | -| `WALLED_MEMBERSHIP_POLICY_UNDECLARED` | `@objectstack/organizations` (the walled-posture membership-policy gate, `kernel:bootstrapped`) | none | no — fires before `kernel:listening` opens the socket | - -**Wire consequence, stated plainly.** Registration changes what a client reads only where a code reaches an HTTP door: `error.code` would carry the specific code instead of the standard member the status derives, with the producer's spelling no longer demoted into `declaredCode`. Re-measured on this tree at the sites each `boot-refusal` row named (the table's last column is that reading, one line per group), **none of the fourteen has such a door**, so **no HTTP body changes with this release**. What changes is the face: `ErrorCode` — the union `ApiErrorSchema.code` parses against — gains fourteen members, `REGISTERED_ERROR_CODES` lists them, the ledger gains two owner keys (`@objectstack/driver-mongodb` returns after #8035 removed it; `@objectstack/organizations` is new), and each refusal's `e.code` is now a member of the union a consumer's exhaustive `switch` is written over. Should a door ever answer with one of them, the wire carries the specific code from then on. - -**`MONGODB_MULTI_TENANT_UNSUPPORTED` is a deliberate reversal, not drift.** #8035 unregistered it on the ground that "host boot matching is not wire vocabulary"; the #16404 ruling supersedes exactly that ground (the ledger header's "Retiring a code" section records both halves), and the test that pinned its absence now pins its presence. What still retires a row is a code with no producer left in `packages/**` — `OVERLAY_PERSISTENCE_FAILED` (#5783) remains the pinned witness of that class. - -**Why `minor`, and no `BREAKING` banner.** Nothing is removed or renamed; every existing body parses exactly as before. The change is a purely additive widening of a published surface (fourteen new `ErrorCode` members, two new owner keys), which the 2026-09-04 ruling on #15294 requires to be at least `minor`. The one consumer-visible cost is type-level: an exhaustive `switch` over the `ErrorCode` TYPE gains fourteen cases to cover. - -The fourteen `boot-refusal` classification rows in `dispatcher-error-vocabulary.ts` ratchet out with the registrations (the gate reports a registered code's row as `stale-row`), their reachability reading now carried on the ledger rows; that module is not part of `@objectstack/runtime`'s published entry, so nothing in that package's `dist` moves. The `boot-refusal` verdict itself stays declared for a future pre-HTTP producer; retiring it and widening the gate's spec-face refusal to every published package is the card's second half and is not in this release. diff --git a/.changeset/register-shipped-error-codes.md b/.changeset/register-shipped-error-codes.md deleted file mode 100644 index 26b6d8e365..0000000000 --- a/.changeset/register-shipped-error-codes.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): register every error code that ships in `dist` — `OBJECT_OWNERSHIP_CONFLICT`, the seven `STACK_*` `defineStack` refusals and `PLUGIN_UI_REQUIRED_KEY_MISSING` enter `ERROR_CODE_LEDGER` (#16449) - -Under the #16404 ruling (director seat, decision batch #62, 2026-09-07, option D; maintainer 「同意」) **the published contract face for error codes is `ERROR_CODE_LEDGER` / `StandardErrorCode`**: every `code` that ships in a package's `dist` is registered there, door or no door, because a consumer's `catch (e) { switch (e.code) }` pins the spelling the moment it ships and nothing could flag a later rename. Nine codes were shipping unregistered on this tree and now have rows, each under the package that stamps it: - -| code | stamped by | `status` | reaches an HTTP door on this tree? | -|---|---|---|---| -| `OBJECT_OWNERSHIP_CONFLICT` | `@objectstack/objectql` (`SchemaRegistry.registerObject`, ADR-0029 D3) | 422 | no — every path aborts boot or is caught below any door | -| `STACK_SCHEMA_INVALID` · `STACK_CAPABILITY_UNKNOWN` · `STACK_CROSS_REFERENCE_INVALID` · `STACK_NAMESPACE_PREFIX_INVALID` · `STACK_SINGLE_APP_VIOLATION` · `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` · `STACK_TRIGGER_CAPABILITY_REQUIRED` | `@objectstack/spec` (`defineStack`, #14552 / #15963) | 422 | no — raised by `os validate` / `os build` and the host configs at boot | -| `PLUGIN_UI_REQUIRED_KEY_MISSING` | `@objectstack/spec` (`PluginSchema`'s `superRefine`, on the zod issue's `params.code`, #16334) | rides `PLUGIN_CONTRACT_VIOLATION`'s | no — raised at `kernel.use()` | - -The card's ninth, `NAMESPACE_CONFLICT`, was already registered by #14748 and already answers `error.code: NAMESPACE_CONFLICT` at `POST /api/v1/packages`; this release changes nothing there. - -**Wire consequence, stated plainly.** For a code that reaches an HTTP door, registration changes what a client reads: `error.code` becomes the specific code instead of the standard member the status derives (`VALIDATION_ERROR` for 422) with the producer's spelling demoted beside it in `declaredCode`. That is the ruling's intended effect — a consumer can branch on the real code — and it is what the Clause-② review judges. Measured on this tree, **none of the nine has such a door** (the table's last column; `OBJECT_OWNERSHIP_CONFLICT`'s reading was re-taken: the only two non-test `registerObject` callers outside `objectql`, both in `metadata-protocol`, catch it and log), so **no HTTP body changes with this release**. What changes is the face: `ErrorCode` — the union `ApiErrorSchema.code` parses against — gains nine members, `REGISTERED_ERROR_CODES` lists them, the generated docs references carry them (`check:generated` found nothing else stale — no authorable schema and no JSON-schema artifact reads this union), and each refusal's `e.code` is now a member of the union a consumer's exhaustive `switch` is written over. Should a door ever answer with one of these codes, the wire carries the specific code from then on. The `declaredCode` demotion (#9106) remains for genuinely unknown / third-party spellings only. - -**Why `minor`, and no `BREAKING` banner.** Nothing is removed or renamed; every existing body parses exactly as before. The change is a purely additive widening of a published surface (nine new `ErrorCode` members), which the 2026-09-04 ruling on #15294 requires to be at least `minor`. The one consumer-visible cost is type-level: an exhaustive `switch` over the `ErrorCode` TYPE gains nine cases to cover — additive, and the shape the ruling asks for. - -Also in this release, as the mechanism that keeps the class closed: `check:dispatcher-error-vocabulary` now refuses to classify a `packages/spec/src/**` stamp site as anything but `foreign-vocabulary` or `runtime-pinned` (`spec-face-unregistered`) — a code raised under the spec tree is a ledger member or it fails CI — and the ledger's header records the ruling as the "door or no door" rule in its own words. The nine `boot-refusal` classification rows in `dispatcher-error-vocabulary.ts` ratcheted out with the registrations, their reachability reading now carried on the ledger rows. diff --git a/.changeset/registry-conflict-code-constants.md b/.changeset/registry-conflict-code-constants.md deleted file mode 100644 index 8b53f1bbd5..0000000000 --- a/.changeset/registry-conflict-code-constants.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -The registry's three conflict refusals now publish their error `code` as an importable constant. - -`SchemaRegistry`'s install-time and registration refusals each already told the reader, in their own docblocks, to identify them by `code` rather than `instanceof` — and offered nothing to import. `NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME` and `OBJECT_OWNERSHIP_CONFLICT` were inline string literals, so the only way to follow that instruction was to re-spell the string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. - -Three new exports from `@objectstack/objectql`: - -- `NAMESPACE_CONFLICT_CODE` — the ADR-0048 Phase 1 install-time namespace gate's refusal. -- `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` — the ADR-0130 D3 one-artifact object-name refusal. -- `OBJECT_OWNERSHIP_CONFLICT_CODE` — the ADR-0029 D3 single-owner-per-object-name refusal. - -**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary. - -**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; the refusals throw the same `code`, the same `status: 422` and the same message as before. Existing consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing. - -**The error classes stay unexported, deliberately.** Publishing them would publish the `instanceof` route this convention exists to replace. diff --git a/.changeset/remote-loader-list-nameless-guard.md b/.changeset/remote-loader-list-nameless-guard.md deleted file mode 100644 index a78137b5e7..0000000000 --- a/.changeset/remote-loader-list-nameless-guard.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/metadata": patch ---- - -`RemoteLoader.list()` no longer reports a nameless remote body as a literal `undefined`. - -The method declares `Promise` and read the collection as `loadMany<{ name: string }>(type)` before mapping `items.map(i => i.name)`. That type argument is an **assertion** about bodies that arrived over HTTP, and nothing checked it: a body with no top-level `name` yielded `undefined`, which went into an array the signature declares as `string[]`. `MetadataManager.listNames()` unions loader `list()` output unfiltered, so the violation reached consumers — measured on this fixture, `listNames()` answered `[ 'account', undefined, 42 ]`. - -The guard is `DatabaseLoader.list()`'s, one file away: the same cast-then-map spelling with `.filter(name => typeof name === 'string')` behind it. `RemoteLoader` was the only one of the four loaders in that directory with no guard at all — `MemoryLoader` answers with its store keys, and `FilesystemLoader` reports only names `findFile()` resolves. Dropping silently rather than throwing is the direction those siblings already carry: a name in the list that the door answers `null` for is the silent failure an author reads as their own typo, so the list is narrowed to agree with the door. - -Nothing that was validly returned before stops being returned: the only entries that disappear are the ones whose type the signature already ruled out. A caller that previously received `[undefined]` now receives `[]`. `loadMany()` is deliberately untouched — it keys nothing, so a body carrying no `name` is still served there; this loader reads over HTTP and holds no store key, so `body.name` is the only identity it has and the family's "identity is the store key" rule cannot be satisfied for it. diff --git a/.changeset/report-chart-axis-own-selection.md b/.changeset/report-chart-axis-own-selection.md deleted file mode 100644 index 1ddd27ef6e..0000000000 --- a/.changeset/report-chart-axis-own-selection.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`chart-axis-not-selected` resolves a report chart against its own `chart.yAxis`, not `report.values` (#15734) - -**Behaviour change — one false finding removed on the report surface.** A report chart whose `chart.yAxis` names a declared measure that `report.values` does not select no longer raises a `chart-axis-not-selected` warning. Nothing else about the rule moves, and no other surface moves at all. - -The warning stated a query consequence the renderer refutes. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), `plugin-report/src/DatasetReportRenderer.tsx` does not query `report.values` for the chart at all — it runs the chart's own, narrower query out of the two axis strings: - -``` -const state = useDatasetRows( - dataset, - plan.kind === 'series' && xAxis ? [xAxis] : [], - wantsQuery && yAxis ? [yAxis] : [], -``` - -and says so in that file's own words at the `scopeOrder` docblock: *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*. So the measure the warning said "the query does not return" is exactly the one the query asks for, and the chart plots it. `report.values` is the selection of the TABLE beneath the chart. - -Both limbs follow from that one measurement: - -- **No not-selected check at the report `chart.yAxis`.** That position IS the chart's query, so it cannot fail to select itself. `chart-measure-unknown` there is untouched: an UNDECLARED measure is still no column at all, and still an `error`. -- **`chart.series[].name` resolves against the singleton `{ chart.yAxis }`.** The entry is a display-name override paired with a DERIVED series, and the chart derives exactly one (`buildChartSeries(…, [xAxis], [yAxis], …)`). An entry naming `chart.yAxis` now lands however the table is selected, and one naming any other declared measure is still reported — including a measure `report.values` does select, which it could not reach before. - -The list-view and page-component surfaces are unchanged, and carry firing controls that say so: on both, `values` IS the measure set the query asks for (`ObjectView` hands it to the chart; `ObjectChart` queries `{ dimensions: schema.dimensions, measures: schema.values }`), so the existing resolution is the right one there. - -The per-position tier and consequence wording is untouched — only the SET the report surface resolves against moves. diff --git a/.changeset/report-dataset-resolved-without-a-chart.md b/.changeset/report-dataset-resolved-without-a-chart.md deleted file mode 100644 index 957668f212..0000000000 --- a/.changeset/report-dataset-resolved-without-a-chart.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -A report's `dataset`, `rows`, `columns` and `values` are checked whether or not the report draws a chart (#16105) - -**Behaviour change — new findings on reports that used to publish clean.** `validateChartBindings` reached a report through one closure that opened `if (!isRec(chart)) return`, and that closure was the only place a report's `dataset` was ever passed to the resolver. Two gaps followed, and both are closed: - -- **A report authored without a `chart` was not checked at all.** Bind it to a dataset that does not exist and `os lint` exited 0 and named nothing. It now reports `chart-dataset-unknown` at `error`, the same as a charted report always did. -- **`rows` and `columns` were resolved on no report, charted or not.** On one and the same report object the measure selection (`values`) was resolved against the dataset and the dimension selection beside it was not. Both now report `chart-dimension-unknown` at `error` for a name the bound dataset does not declare as a dimension, at `reports[i].rows[j]` / `reports[i].columns[j]`. A chartless report's `values` is resolved for the first time too, under the existing `chart-measure-unknown`. - -`ReportSchema` is what makes these bindings rather than free text: it requires `dataset` + `values` on every non-`joined` report, and declares `rows` (the down axis) and `columns` (the across axis a `matrix` pivots on, ADR-0021 D2) as dimension names taken from that dataset. The chart is optional decoration on top of a binding the report already has. So a report bound to a missing dataset, or grouping on a dimension its dataset does not declare, now fails authoring instead of rendering blank or mis-grouped in production. - -No new rule id, no severity moved, and the charted path is unchanged — `chart-axis-not-selected` stays a `warning` and still resolves against the chart's own `chart.yAxis`. Two smaller corrections come with the restructure, both on messages an author reads: - -- The dataset finding on a report now points at `reports[i].dataset`, the key the author wrote. It used to say `reports[i].chart.dataset`, a position a report does not have. -- Its sentence ends "there is no data to render" rather than "the chart has no data to render", which is not true of a report that draws no chart. - -Blocks of a `joined` report carry the same keys and take the same checks. An unresolvable dataset is still exactly one finding per report or block. diff --git a/.changeset/report-schedule-timezone-guard.md b/.changeset/report-schedule-timezone-guard.md deleted file mode 100644 index 7e9ce5a6e4..0000000000 --- a/.changeset/report-schedule-timezone-guard.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/plugin-reports": minor ---- - -fix(plugin-reports)!: a non-member schedule `timezone` no longer discards the cron expression, and a schedule already holding one stops instead of firing on a cadence nobody asked for (#16291) - -**BREAKING** for a deployment that already stores a report schedule with a cron expression and a `timezone` that is not an IANA member. Such a schedule is delivering today, on the wrong cadence; after this change it does not deliver at all until a human corrects the zone. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs` refuses `major`); the version number is not the signal here, this entry is. - - - -## What an upgrading operator has to do, and how to find out - -If `sys_report_schedule` holds a row whose `timezone` is not a real IANA zone **and** whose `cron_expression` is set, the sweep now marks it `last_status: 'failed'` with a `last_error` naming the zone, and stops running it. Correct the `timezone` on that row; the schedule resumes on the next sweep with no re-enable and no second action, because `active` and the past `next_run_at` are deliberately left alone. - -Only rows written **before** `valueDomain: 'iana_time_zone'` landed on that column can be in this state, and the set cannot grow: measured on a real kernel with a real SQLite driver, `insert` into `sys_report_schedule` with `timezone: 'Mars/Olympus'` is already refused today — `VALIDATION_FAILED · Timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")`. A set that cannot grow is still not an empty one, which is why this carries a banner rather than a shrug. - -## The defect - -croner (10.0.1) answers a non-member zone in three different ways, and only the middle one was ever reached here: `new Cron(expr, { timezone })` **without a callback** validates the expression and lets any zone through, `nextRun()` on that instance then throws a `CronDate` conversion `TypeError`, and the callback form throws at construction. `scheduleReport`'s eager guard used the callback-less form, so the timezone half of its own input passed straight under a guard whose stated purpose was "a clear error at schedule time instead of a schedule that silently falls back to interval on sweep" — and `nextRunAt` caught that deferred throw and returned `from + interval_minutes`. A schedule authored as "every weekday 09:00 Asia/Shanghai" became "every 1440 minutes, forever", re-derived on every sweep, logged only as a complaint about a cron expression that was perfectly good. - -## What changed - -- **The create-time guard now asks the right question.** `scheduleReport` consults `isValueDomainMember('iana_time_zone', …)` from `@objectstack/spec/shared` — the same predicate `sys_report_schedule.timezone`'s `valueDomain` declaration enforces on write — and refuses a non-member with `VALIDATION_FAILED: invalid timezone '': not a member of the 'iana_time_zone' value domain`. One answer at both doors, so this one cannot accept what the storage door refuses; it says so earlier and names the input that is actually wrong. It applies whether or not a `cron_expression` is set, because the storage gate does too. **This is not what makes the change breaking:** the storage door already refuses the same value today, so no reachable accept set narrows — what moves is which door answers and how clearly. -- **The row now stores the string the scheduler evaluates.** An empty `timezone` was stored verbatim while every `new Cron` call site read it as `UTC`; it is normalised to `UTC` on the way in. -- **A schedule already holding an unusable zone is stopped, not rescheduled.** It is not run and its `next_run_at` is not advanced; `last_status` / `last_error` carry the reason. Repairing the value automatically was rejected: the intended zone is not recoverable from a typo, and rewriting it to `UTC` would deliver at yet another set of wrong instants while the row looked healthy. Interval-only schedules are untouched — interval arithmetic never consults the zone, so a legacy bad value there still delivers on the cadence its author asked for. -- **Both fall-back warnings name both inputs.** The "no next occurrence" and the former "invalid cron" lines each mentioned only the expression, so either of them on a timezone fault sent an investigator to audit the half that was fine. They now carry the expression *and* the zone, and the second no longer asserts the expression is the broken one. diff --git a/.changeset/rest-api-config-consumes-parse.md b/.changeset/rest-api-config-consumes-parse.md deleted file mode 100644 index 6c8b40cf45..0000000000 --- a/.changeset/rest-api-config-consumes-parse.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -The REST server's `api` configuration defaults now come from `RestApiConfigSchema` alone, instead of being restated in `packages/rest`. - -`RestServer.normalizeConfig` already parsed `config.api` against `RestApiConfigSchema` — and then discarded the result, rebuilding the block from a `??` chain over the raw input. That chain restated the schema's eleven top-level `z.default(...)`s as eleven literals in a second package. They agreed key for key, and nothing measured that they would keep agreeing: changing a default in `@objectstack/spec` silently failed to propagate, because `api.enableUi ?? true` answers `true` for an absent key whatever the schema declares. Consuming the parse deletes the duplicate and makes the schema authoritative. - -The parse itself is unchanged, so **nothing new is accepted or refused**: the same schema, with the same `.omit({ requireAuth: true })`, already ran at construction. `api.requireAuth` keeps its retired warn-and-ignore posture (`@objectstack/rest`'s plugin reads it off the raw config, so the warning is untouched), and every authored value still wins over the default. - -One bounded behaviour change, for a caller who writes `api.documentation` or `api.responseFormat` — and it runs in two directions, not one. **Filled in:** those objects now arrive carrying their own declared inner defaults — `documentation.enabled` / `.title`, and `responseFormat.envelope` / `.includeMetadata` / `.includePagination`. **Stripped:** inner keys the schema does not declare no longer survive, at either depth — an authored `documentation.logo`, a `documentation.contact.phone` or a `documentation.license.spdxId` inside the nested objects, a `responseFormat.extra` — where the `??` chain passed the authored object through by reference and kept every key on it. Both halves are the same parse: `documentation` / `responseFormat` (and their `contact` / `license`) are non-strict `z.object()`s, which fill in their `.default()`s and drop what they do not name — dropped silently, so this is a strip and not a new refusal. An object left unwritten stays absent, and nothing in the platform reads either key today: the normalized block is `private` to `RestServer`, which reads only scalars off it (`apiPath` / `basePath` / `version` in `getApiBasePath`, the `enable*` flags, `projectResolution`), and the repo has no other read site for either key — so no consumer observes either half. diff --git a/.changeset/rest-data-doors-compiled-against-protocol.md b/.changeset/rest-data-doors-compiled-against-protocol.md deleted file mode 100644 index 880a0230f8..0000000000 --- a/.changeset/rest-data-doors-compiled-against-protocol.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -The REST data doors' protocol requests are compiled against the declared contract again, so a field added to a data request schema reddens the build instead of going silently unsent. - -No runtime behaviour changes — every door assembles and forwards exactly the object it did before. What changes is what the compiler is allowed to see. `packages/rest/src/rest-server.ts` dispatched to the protocol through two erasing forms: `p.deleteData({ … } as any)` on the argument, and the stronger `(p as any).updateData({ … })` on the protocol object itself, which erases the check on *every* member — a misspelled method name would not have errored. Across the file that was 22 dispatch sites spanning `findData` / `getData` / `createData` / `updateData` / `deleteData`, their `*Many` and batch siblings, and `getUiView`. - -The casts were load-bearing rather than lazy: these call sites pass `environmentId` and `context`, and neither is a member of any data request schema. Neither should become one. `environmentId` is the transport routing key that selects the kernel *before* the protocol call and is already ruled out of the request shape; `context` is the server-derived execution context, and a caller-supplied `context` is a privilege escalation the ingress deletes unconditionally — putting it in the published request schema would re-open that door. Both are now declared on a typed envelope alongside the request type, so they stay server-side *and* compiled, and every other member of every literal is checked against the spec. - -One slot stays deliberately untyped and is now named rather than diffuse: `findData`'s `query` accepts both the declared AST and an undeclared wire dialect (`$top`, `$orderby`, `filters`, …) that the protocol normalizer folds. Three server-built literals speak that dialect; the erasure there is confined to the query slot alone, and the declared-versus-shipped mismatch is filed as its own question. diff --git a/.changeset/rest-generic-passthrough-object-key.md b/.changeset/rest-generic-passthrough-object-key.md deleted file mode 100644 index b24c8e33b9..0000000000 --- a/.changeset/rest-generic-passthrough-object-key.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest): the generic declared-status passthrough names its object on both error doors (#14725) - -**Response-body change on the published bulk / metadata / UI doors: one optional -key is added, `object`.** Nothing is removed, no status moves, and no `code` -value changes spelling. - -#14541 made the two REST error doors agree for every refusal a *bespoke* arm -classifies. They still disagreed for every refusal that reached the *generic* -declared-status passthrough, because the two copies of that one passthrough -differed by exactly one key: `classifyDataError`'s copy ends -`...(object ? { object } : {})` and `resolveErrorResponse`'s 4xx arm had no such -limb. Measured on `main` @ `a12b15e394` — one error object, both doors: - -| door | before | -|---|---| -| `mapDataError(err, 'duly_note')` (single-record `/data`) | `409 {"error":"…","code":"DUPLICATE_RECORD","object":"duly_note"}` | -| `sendThrownError(res, err, 'duly_note')` (bulk / metadata / UI) | `409 {"error":"…","code":"DUPLICATE_RECORD"}` | - -One refusal, two bodies, decided by which route caught it — the #14541 shape one -arm over. The bulk door now answers the first row too. - -It closes the same card's second residue with it. `recordNotFoundError` -(`@objectstack/core`) declares `code`, `status = 404` **and** `object`, so that -declared status carries a record-level not-found past the `RECORD_NOT_FOUND` arm -into this same generic passthrough on every route reporting through -`handleRouteError` / `sendThrownError`, while the single-record `/data` door -reached the generic arm in `classifyDataError` and shipped the name. Both doors -now agree for that producer in every combination of declared status and -door-supplied object. - -**Who sees the new key.** The name comes from the door's `object` *argument*, -never from `error.object`, so only a route that supplies one is widened. Of 35 -route call sites of this door, **9** pass an argument that can be a non-empty -object name — `POST /data/:object/batch`, `/createMany`, `/updateMany`, -`/deleteMany`, `POST /data/:object/:id/clone`, `POST /data/:object/import`, -`POST /data/:object/import/jobs`, `GET /data/:object/export`, and -`GET /ui/view/:object/:type`. The other 26 (21 passing nothing, 5 passing the -literal `''`) answer byte-identical bodies. `classifiedRefusalAnswer` — the -entry point the analytics dataset face and the record-share family re-dress — -calls this door with no `object` argument at all, so those envelopes' key sets -do not move. - -**What deliberately does not change.** The declared-**5xx** arm gains nothing: -its sibling `declaredServerFaultAnswer` names no object either, so the two doors -already agreed in that band and adding the limb there would *create* a -divergence, on top of putting a caller-supplied name into a body whose whole -rule is that a declared server fault says nothing beyond status and code. The -`RECORD_NOT_FOUND` arm's message-**text** limb -(`/^Record \S+ not found in \S+/i`) is not lifted above the passthrough either — -that boundary is #14541's, and it is now pinned behaviourally and positionally -rather than described. - -Consumer note: a client that key-counts or exact-matches an error body from a -bulk, import, export, clone or UI-view route will see `object` alongside `error` -and `code` where the equivalent single-record `/data` response has carried it all -along. A client that reads named fields is unaffected. diff --git a/.changeset/rest-method-not-allowed-names-failing-conjunct.md b/.changeset/rest-method-not-allowed-names-failing-conjunct.md deleted file mode 100644 index 854e8bb470..0000000000 --- a/.changeset/rest-method-not-allowed-names-failing-conjunct.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -`OBJECT_API_METHOD_NOT_ALLOWED` now names the conjunct that actually failed, instead of one its own `allowed` array lists. - -An object declaring `apiMethods: ['get','list','update','bulk']` refused `deleteMany`, `createMany` and each op of a cross-object `POST /batch` with an identical body: - -```json -{ "error": "API operation 'bulk' is not allowed on object 'sys_user'", - "code": "OBJECT_API_METHOD_NOT_ALLOWED", - "allowed": ["get","list","update","bulk","aggregate","history","search","import","export"] } -``` - -Every one of those refusals was correct in outcome — `deleteMany` is `bulk ∧ delete`, `createMany` is `bulk ∧ create`, and `updateMany` / `batch`, which need only `bulk`, are still admitted — but the message named the half that PASSED, and the same envelope listed it as allowed. The writeMode-refined `import` had the identical shape: `import` derives from create ∨ update, so `update` alone puts `import` in the effective set while an `insert` import still needs `create`. - -The message now names a conjunct that is genuinely missing: `delete`, `create` or `update` for the cases above, and still `bulk` when the `bulk` primitive itself is what the object withholds. Three requests that previously produced one indistinguishable envelope are now told apart. - -**`allowed` is unchanged, in contents and in meaning** — it is still the object's declared effective operation set, not the set the gate evaluated against. That matters because the array is read as a discriminator: a declaration re-widened to create/update can still 405 for an unrelated reason, so only the set proves which gate answered. Nothing about which requests are admitted or refused moved; the HTTP status, the `code` and the `object` field are all as before. A client matching on the `error` string for these bulk and import refusals sees the new name. diff --git a/.changeset/rest-package-routes-single-implementation.md b/.changeset/rest-package-routes-single-implementation.md deleted file mode 100644 index 5c5e89069d..0000000000 --- a/.changeset/rest-package-routes-single-implementation.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -`GET /api/v1/packages`, `GET /api/v1/packages/:id` and `DELETE /api/v1/packages/:id` have one implementation: the runtime dispatcher's `/packages` domain. `@objectstack/rest`'s `registerPackageRoutes` no longer mounts its own copies of those three routes; it mounts `POST /api/v1/packages/publish` and nothing else. - -The two copies had already diverged, and a comment in the REST registrar claimed its copies shadowed the dispatcher's while on a stock boot they were never mounted at all (the registrar decided at registration time, before the `package` service had registered). One URL, one body, ruled on #14503. - -What changes on the wire, for a deployment whose composition really did reach the REST copies: - -- `GET /packages/:id` answers `{ success: true, data: }` — the installed-package row directly under `data`. FROM `data.package` TO `data`. There is no `{ package }` wrapper. -- The rows on `GET /packages` and the row on `GET /packages/:id` carry no `source: 'registry' | 'database' | 'both'` key. **Deliberately removed**, not ported: it had no reader outside the REST registrar's own tests — none in this repo's production code, the Console, the docs or the OpenAPI document, and the SDK declined to declare it twice on purpose. -- `GET /packages` and `GET /packages/:id` read the **installed** packages from the in-memory registry (`registry.getAllPackages()` / `registry.getPackage(id)`) and nothing else. The REST copies merged the durable `sys_packages` rows (`PackageService.list()` / `.get(id, version)`) into the registry set, so a package **published but not installed** was listed there and gettable there; on the surviving door it is neither. **Deliberately removed** with the routes, not silently dropped: the published-artifact store keeps its own surface (`POST /packages/publish` here, the marketplace browse elsewhere), and the family this door serves is the installed set. -- `?version=` is not read on `GET /packages/:id` or `DELETE /packages/:id`, so its repeated-parameter refusal (`400 VALIDATION_ERROR` on `?version=a&version=b`) is gone with it. **Deliberately removed**: the single implementation reads the installed package from the registry, and a version-scoped durable lookup was a behaviour only the REST copy had. The one in-tree sender is the SDK's `ScopedEnvironmentClient.packages.get(id, version?)`, whose binding is tracked on #12034. -- A missing package answers `404 RESOURCE_NOT_FOUND` with the message `Package '' not found` (the dispatcher's spelling) instead of `Package "" was not found.`. -- `DELETE /packages/:id` uninstalls the package (registry plus persisted metadata rows, `?keepData=true` to keep the object tables); the REST copy's version-scoped delete of a published artifact is gone. -- **The uninstall's tenancy width narrows.** The REST copy called `protocol.deletePackage({ packageId, allTenants: true })` — a package-wide uninstall across every tenant, the width #7705 case 4 pinned on purpose because that registrar had no organization to resolve. The surviving door calls `protocol.deletePackage({ packageId, organizationId?, keepData? })` with the organization it resolves for the caller (`resolveActiveOrganizationId`), so a `DELETE /packages/:id` that used to reach the REST copy now removes the package's metadata for the caller's active organization, not for all tenants. **Deliberately narrowed**, not silently dropped: one door, one width, and it is the width the dispatcher has always answered on every stock boot. -- **Capability refusals answer a different `error.code`.** On all three routes a caller holding neither `manage_metadata` (write) nor `studio.access` / `setup.access` (read) is refused with `403 PERMISSION_DENIED`. FROM `403 FORBIDDEN` TO `403 PERMISSION_DENIED`: the removed REST copies emitted `sendError(res, 403, 'FORBIDDEN', …)` explicitly, while the dispatcher's `requireManageMetadata` / `requireReadCapability` (`packages/runtime/src/domains/packages.ts`) call `deps.error(message, 403)` with no code and `packages/runtime/src/error-envelope.ts` derives one from the status — `standardErrorCodeForHttpStatus(403)` = `PERMISSION_DENIED`. **Same status, same message**: the two cohort messages ("Managing packages requires the `manage_metadata` capability." and "Reading packages requires the `studio.access` or `setup.access` capability.") are identical on both doors. Both codes are ADR-0112 standard members, so the envelope shape is unchanged; what moves is that a client branching on `err.code === 'FORBIDDEN'` for a package read or delete refusal stops matching on any composition that really did reach the REST copies. - -`POST /api/v1/packages/publish` is unchanged. - -Spec conformance on the surviving door is claimed for `GET /packages/:id` **only**: its `{ success, data: , meta }` is exactly `GetInstalledPackageResponseSchema` (`packages/spec/src/api/package-api.zod.ts`, `data: InstalledPackageSchema` bare). The other two routes do **not** match their declarations, and the REST copies did not either — this drift is **pre-existing, not introduced by this release**, and is carded on #16781: - -- `GET /packages` answers `{ packages, total }`, while `ListInstalledPackagesResponseSchema` requires `hasMore` (and declares `enabled` / `limit` / `cursor` inputs the door does not read). -- `DELETE /packages/:id` answers `{ success, registryRemoved, persisted }`, while `UninstallPackageApiResponseSchema` requires `packageId`. - -Nothing in this release changes either shape; with one implementation there is now exactly one thing to reconcile, and #16781 carries that reconciliation together with the `responseSchema` pins the runtime ledger rows for `packages.list` / `packages.uninstall` still lack. - -`GET /discovery` on the REST server now advertises `routes.packages` on every boot — the family base under which its publish route is mounted — instead of only when its own copy of the list route had been mounted at start. On a stock `objectstack serve` boot that copy never was (the `package` service registers after the REST plugin starts), so discovery omitted `routes.packages` while the dispatcher served the family; the SDK's convention fallback covered it. - -The three removed REST rows are gone from `REST_ROUTE_LEDGER`; the runtime route ledger carries the surviving routes. - -The environment-scoped mount (`/environments/:environmentId/packages…`) is served by the same dispatcher domain **only where the `@objectstack/hono` catch-all is mounted** (`createHonoApp`): the catch-all strips the environment prefix and hands the request to the domain. The dispatcher plugin's own explicit mounts (`plugin-hono-server`) register `/packages*` at the **unscoped** prefix only, and that plugin's sole route into the dispatcher (`setFallbackHandler`) serves declarative `apis:` endpoints, not domains. So a host composed as `plugin-hono-server` + the REST plugin with `enableProjectScoping: true` + the dispatcher plugin, **without** `createHonoApp`, had exactly one door for scoped package reads and deletes — the REST mirror this release removes — and after it has none: the scoped `GET /environments/:id/packages`, `GET /environments/:id/packages/:id` and `DELETE /environments/:id/packages/:id` answer the transport's plain 404 there. That composition is reachable from the open-core CLI when the standalone boot is skipped (`shouldBootWithLibrary()` false — any host config, or `OS_MODE=off`) and `api.enableProjectScoping` is forwarded verbatim. Every consumer population reachable from this repo is zero for the scoped mount (no in-repo production caller of `ScopedEnvironmentClient.packages.*`, no Console call to a scoped `/packages` URL); it is stated here so it is a known gap rather than a silent one. On a `plugin-hono-server` composition with `enableProjectScoping` and no `createHonoApp`, the scoped `/api/v1/environments/:id/packages[/:id]` routes have no door until #16781 lands (ruled C′ on #14503). diff --git a/.changeset/rest-server-canonical-query-ast.md b/.changeset/rest-server-canonical-query-ast.md deleted file mode 100644 index 4ada0882ea..0000000000 --- a/.changeset/rest-server-canonical-query-ast.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -The REST server's own `findData` calls now build the canonical QueryAST instead of an undeclared wire dialect, and the helper that erased the type on that one slot is gone. - -Four server-built query literals in `rest-server.ts` — the import-job loader, the import-job listing, the export chunk loop and the public reference picker — spelled their query in transport aliases (`$filter`, `$top`, `$skip`, `$orderby`, `$expand`, plus the bare `filters` / `select` / `sort`). None of those spellings is declared by `QuerySchema`, so three of them were routed through a `wireDialectQuery` helper that cast the `query` member to `FindDataRequest['query']`, and the fourth escaped the compiler entirely because its protocol handle was typed `any`. All four now spell `object` / `where` / `orderBy` / `limit` / `offset` / `fields` / `expand`, so the slot compiles against the declared contract like every other member of the request, and the helper is retired. - -**No behaviour moves, and that is measured rather than asserted.** `@objectstack/metadata-protocol`'s `findData` folds every alias onto its canonical key by the spec's own table (`RPC_QUERY_ALIAS_SLOTS`) and moves the value verbatim, so both spellings reach `engine.find` as the same option bag. `rest-server-canonical-query-ast.test.ts` drives all four before/after pairs through the real normalizer and asserts that equality, and reads the source to keep the erasure retired — a cast compiles, so a type-check alone could not hold this ground. - -**Nothing is removed from the published surface.** `wireDialectQuery` was a module-local `const` in `rest-server.ts`: it carried no `export` keyword, `packages/rest/src/index.ts` never named it, and it appeared in no other file in the tree. Deleting it moves no exported symbol, which is why this is a patch. - -**What this change deliberately does NOT do:** it does not touch how the HTTP door treats a *caller's* query. The wire aliases stay accepted on `GET /data/:object` exactly as before — declaring them in the spec's alias table is a separate piece of work — and `GET /data/:object` still forwards the caller's own querystring bag untouched. diff --git a/.changeset/rest-server-config-embedder-only-reachability.md b/.changeset/rest-server-config-embedder-only-reachability.md deleted file mode 100644 index dd40abf04e..0000000000 --- a/.changeset/rest-server-config-embedder-only-reachability.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`RestServerConfig` now documents its own reachability: the `crud` / `metadata` / `batch` blocks are embedder-only, and the schema says so instead of implying a deployment posture nobody can author. - -`RestServerConfig` is the argument a host passes when it constructs the REST server, and there is exactly one door: `createRestApiPlugin({ api })`, whose `start()` is the only non-test site that reaches `new RestServer(...)`. No shipped boot path opens it with a config of its own — `os serve` reads the stack config's own top-level `api:` block and forwards exactly two keys out of it (`api.enableProjectScoping`, `api.projectResolution`), and the dev plugin passes none at all. So on a CLI-started deployment every other key is whatever its `.default()` says, and until now the schema did not say so anywhere an operator would look. - -- **The file header gains a `WHO CAN WRITE THIS CONFIG` section**, which is the part that reaches the generated reference page, and the `crud` / `metadata` / `batch` sub-schemas each gain a `Reachability: EMBEDDER-ONLY` line. The three keys' entries on the parent `RestServerConfig` table say it too, so the fact survives into `content/docs/references/api/rest-server.mdx` rather than living only in the TS source. -- **One documented carve-out, and it is the security-relevant key.** `RestServer.normalizeConfig` folds the environment into the *effective* value of `metadata.maskObjectFields`: `OS_ALLOW_UNMASKED_OBJECT_METADATA` turns the ADR-0106 D8 mask off whatever the key says. That env var is the only thing outside an embedder's argument that moves any value in this schema, and it is now stated on the key, on the sub-schema and in the file header. -- **`metadata.maskObjectFields`'s docblock is corrected.** It said `false` "opts this server out and serves the full schema to every authenticated caller" and offered the env var as a "deployment-wide counterpart", as if a deployment could pick either. Only an embedder can write the key; a deployment reaches the mask only through the env var. -- **`api.enableSearch` is corrected the same way.** Its docblock called it a "Deployment-wide switch" and its `describe()` a "deployment-wide search opt-out"; `os serve` does not forward it either, so it is embedder-only like the rest of the block apart from the two project-scoping keys. -- **The liveness ledger answers the ADR-0049 question in writing.** Every `live` row in `liveness/crud_endpoints.json`, `metadata_endpoints.json` and `batch_endpoints.json` gains a `REACHABILITY` sentence, and each file's `_note` carries the measurement once. `status` and `verifiedAt` are untouched on purpose: `live` answers who *reads* a key, reachability answers who can *set* it, and adding the second re-verified no call graph. - -⚠️ **A correction to the record this change is built on.** An earlier draft of these sentences named a second door, `createHonoServerPlugin({ restConfig })`. No such function exists — a definition probe returns zero across the tree, against a positive control that finds `createRestApiPlugin` at `packages/rest/src/rest-api-plugin.ts:115`. `HonoServerPlugin` is a class that declares a `restConfig?: RestServerConfig` option whose single reader takes `api.basePath` for the SPA fallback; it never constructs a REST server, so it is not a door onto any of these keys. The claim was inherited from prose that was already in the tree, and on a card whose whole subject is a declared posture nobody can reach, publishing a declared door that does not exist would have been the same defect one level up. Every place this change touches now says the corrected thing. - -⚠️ **`batch.maxBatchSize` really does describe itself as deployment policy — in another package.** The phrase does not occur in `packages/spec/src/api/rest-server.zod.ts`, but it exists verbatim in the REST server: *"The cap is deployment policy — `RestServerConfig.batch.maxBatchSize` (1..1000, default 200)"* at `packages/rest/src/rest-server.ts:2071`. Same defect class, different package, and not touched here — it is owed to a follow-up in `packages/rest`. - -No behaviour changes and no schema shape changes — no key, default, bound or refusal moves, so the accept set is byte-identical. This is prose plus ledger rows, and the regenerated `content/docs/references/api/rest-server.mdx` that follows from the `describe()` edits. diff --git a/.changeset/rest-translate-options-default-locale.md b/.changeset/rest-translate-options-default-locale.md deleted file mode 100644 index ab37741797..0000000000 --- a/.changeset/rest-translate-options-default-locale.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the metadata reads pass the declared default locale to the label resolvers, so a request for it answers with the authored label (#15711) - -`translateOptionsFor` — the single seam every metadata-document translation in the REST server goes through — now threads `i18n.getDefaultLocale()` into `ResolveOptions.defaultLocale` beside the declared fallback chain it has passed since #14882. Both accessors are optional on `II18nService` and both are feature-detected: a provider that declares no default gets no default, one that declares no fallback gets no chain, and the seam never answers `'en'` on a provider's behalf. - -Measured on the reporter's stack shape (`defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, an `en` bundle and no `zh-CN` bundle): `GET /api/v1/meta/object/kpi_entry_sheet` with `Accept-Language: zh-CN` — or with no header at all, which resolves to the default — now serves the authored `填报单`, not the `en` bundle's `Entry Sheet`; a `fr` request still walks the declared `en` bundle; an `en` request still gets the `en` bundle. Pinned in `meta-i18n-declared-fallback-chain.test.ts` §4 and §5. diff --git a/.changeset/resume-failure-report-on-approval-results.md b/.changeset/resume-failure-report-on-approval-results.md deleted file mode 100644 index 6e83a74c96..0000000000 --- a/.changeset/resume-failure-report-on-approval-results.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ResumeFailureReport` — the machine-readable half of a resume failure, declared once in `contracts/approval-service.ts` and carried as the optional, additive `resumeFailure` member of `ApprovalRecallResult` and `ApprovalDecisionResult` (the contract half of the #16472 family ruling, decision batch #76). - -The ruling: when a resume failure is told to the caller, it is told in a shape the caller can act on — a registered error code, the `runId` of the run that is actually stranded, and `repairable` — and the door's status code does not change because of it. A decision whose own run advanced still answers success, with the failure behind it carried on the success answer. This change declares that shape; the doors adopt it separately (#15556 for the decision door's subflow bubble-up, #15970 for `recall`). - -- **Declared once, by reuse.** `ResumeFailureReport extends ResumeFailureDetails` (`api/automation-api.zod.ts`), the structure the automation resume door already publishes inside its `400 FLOW_FAILED` details. `runId`, `status` and `repairable` are inherited, never re-spelled, so the carriers cannot drift; a caller that parses the member with `ResumeFailureDetailsSchema` reads the same three facts it reads off that door. The report adds exactly the one member a success envelope cannot leave to its envelope: `code`. -- **No new error code is minted.** `code` is typed as `ErrorCode`, the ADR-0112 ledger vocabulary, so an unregistered spelling fails `tsc` rather than reaching the wire. A consumer that needs a distinct code to branch is its own card. -- **The absence rule is explicit and pinned.** The member is optional because it is additive, and an absent member means no report was made — a producer that predates this field, a door that never resumes — never that no run is stranded. A consumer may branch on presence to read a failure; it must not branch on absence to conclude health. -- **`resumeError` is no longer "when `resumed` is false".** Both carriers' `resumeError` docblocks now say its presence is decided by whether a failure was told, never by `resumed`, and name `resumeFailure` as the machine-readable half of the same telling; both `resumed` docblocks say `true` speaks for this door's own resume, not for every run behind it. `ApprovalSendBackResult` and `ApprovalResubmitResult` are unchanged — the ruling names no carrier on those doors. -- `StrandedDecisionDetails` (`@objectstack/types`, the error-envelope carrier of the `decide` door's own strand, #13807) is unchanged. diff --git a/.changeset/rls-check-membership-staging.md b/.changeset/rls-check-membership-staging.md deleted file mode 100644 index 0df7580a89..0000000000 --- a/.changeset/rls-check-membership-staging.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -An RLS `check` clause that reads a membership-resolver key now resolves on a bare insert. - -An app that registers an `IRlsMembershipResolver` (ADR-0105 D11) and authors `using` + `check` twins reading its key — `record.employer_org in current_user.employer_org_ids` — saw reads resolve the key and every bare insert refused with `PERMISSION_DENIED` ("would violate a row-level CHECK"), whether or not the payload carried the value the policy wanted. The membership sets were staged onto the request context only inside the read-filter computation; `computeWriteCheckFilter` compiled the `check` clause against a context in which the key had never been staged, so the variable was unresolved, the policy dropped, and the write failed closed. The two write shapes that passed — a by-id update and an insert of a `controlled_by_parent` child — passed only because an earlier read on the same context (the pre-image, the master read) happened to stage it first. - -The write path now stages the resolver's sets itself, immediately before the `check` clause compiles, so a `check` resolves exactly the variables its `using` twin resolves regardless of whether the request read first. Staging is memoized per request context, so the read-first shapes still consult the resolver once. Nothing is relaxed: with no resolver registered, a resolver that throws, or a key the resolver does not publish, the policy still drops out and the write is still refused. diff --git a/.changeset/runtime-declarative-row-update-executor.md b/.changeset/runtime-declarative-row-update-executor.md deleted file mode 100644 index d4aca34c61..0000000000 --- a/.changeset/runtime-declarative-row-update-executor.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -feat(runtime): the platform action route executes the declarative row-level `operation: 'update'` action (#14092) - -The spec half (#15077) made `operation: 'update'` + `patch` parse; nothing performed the -write, so an authored update action reached the action route with no handler and collected -the registry's loud not-registered answer. It now performs the write. - -`POST /api/v1/actions///` — and the MCP `run_action` bridge, through -the same shared executor — performs exactly ONE data-plane update of the current record: - -- **As the caller.** The write carries the caller's own `ExecutionContext`, never the - `isSystem`-elevated context a `type: 'script'` BODY runs under. There is no author body here - to trust, so the data plane's own gate is the only gate — the object's permissions, its hooks - and its validations fire exactly as for a user edit, and their refusals reach the caller with - their own `code` and `status`. This consumes the `runAs: 'user'` direction ruled on #14010; no - `runAs` key is added. -- **A caller who cannot read the row is refused before anything is written** (404 - `RECORD_NOT_FOUND`, the platform's one existence-non-disclosing envelope), by consuming the - caller-scope load's verdict rather than re-deriving it from the stamped `record.id` — the - #14143 class: a swallowed load must never become an implicit grant. -- **The write is `{ ...patch, ...collectedParams }`** — static values under the dialog's, so a - param of the same name wins. Nothing else from the action is merged, and the ADR-0104 D2 param - contract still bounds what the wire can add. -- **No current record ⇒ a located refusal**, never a silent no-op: no `recordId` on the route or - in the body, an action addressed at the object-less key, or an empty write bag each answer 400 - naming the action and the fix. -- **`undoable: true`** returns `undo: { type, objectName, recordId, undoData, redoData }` — the - prior values of exactly the fields written, `null` for a field the row did not carry, so the - existing Undo readers can restore. The three remaining `UndoableOperation` keys (`id`, - `timestamp`, `description`) stay the client's. -- `visible` is deliberately unread here: it is a per-record renderer predicate, and the - authorization is the point above. - -`operation` is read BEFORE `type` at every reader, so the HTTP door and the MCP bridge agree: -`isHeadlessInvokableAction` now accepts a declarative update (it has neither `target` nor `body` -by construction), `headlessActionTypeError` hands it no client-side-type prescription, and -`summarizeAction` reports `operation` and `requiresRecord: true`. - -Unchanged: a handler-less `type: 'script'` action WITHOUT `operation` still gets today's -not-registered 404 — the script path is not widened. diff --git a/.changeset/runtime-domain-gates-tenancy-posture-loud.md b/.changeset/runtime-domain-gates-tenancy-posture-loud.md deleted file mode 100644 index 053b8e64c7..0000000000 --- a/.changeset/runtime-domain-gates-tenancy-posture-loud.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -The `/keys` mint gate and the install-wide activation-write gate classify a tenancy resolution failure instead of reading it as "no wall" - -Both gates derived the effective tenancy posture through `DomainHandlerDeps.resolveService`, the dispatcher's capability **probe**: every step of its fallback chain absorbs every rejection and answers `undefined`. So a `tenancy` service that was registered and **failed to build** arrived at both gates as the same value a deployment that never registered one produces, and both read that as "there is no wall". Measured on the pre-fix tree against a real kernel whose `tenancy` is registered through a throwing factory: `POST /keys` answered **201** and minted an organization-less key, echoing the raw secret once, where a walled posture refuses one; and an organization administrator's install-wide activation write answered **200** and wrote the row, where ADR-0126 §5 requires the platform operator. - -The identity step already read this fact through the classified lookup, so one deployment held two readings of its own wall question at once — 503 at the identity step, admitted at the door bodies these gates guard. The gates now read the same classification, taken from the registry's own brand and never from message text: a service that was **never registered** stays quiet and behaves exactly as before (an org-less key is still minted, and a single-organization deployment's own admin can still flip an install-wide switch — with no tenancy service, install-level and org-level are one scope under ADR-0093 D4/D5), while a service that is **registered and unable to answer** raises `AuthzStoreUnavailableError` — 503 `SERVICE_UNAVAILABLE` — instead of degrading to "no posture". Nothing is minted and nothing is permitted on a posture that was never read. The activation gate is one body behind **two** routes, so three routes change: `POST /keys`, `POST /actions/_activation/:object/:action` and `POST /automation/:name/toggle`. Every gate reads the posture in the request's own environment scope, as the identity step does, so a `tenancy` registered `ServiceLifecycle.SCOPED` is resolved rather than reported as an outage. - -`resolveService` keeps its probe contract for every other name and every other domain: the classified read is a second, opted-into member — `DomainHandlerDeps.resolveServiceOrLoud` — that a gate calls one site at a time, so no gate outside the three routes above changes behaviour. **Minor** rather than patch: this grows the exported `DomainHandlerDeps` interface with a required member, which is a published-surface addition — the same shape the three `DomainHandlerDeps` growths in 17.0.0 shipped as minor changes. diff --git a/.changeset/runtime-gate-stored-metadata-universe.md b/.changeset/runtime-gate-stored-metadata-universe.md deleted file mode 100644 index fe25983f27..0000000000 --- a/.changeset/runtime-gate-stored-metadata-universe.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -A dashboard bound to a dataset you just saved now publishes, without restarting the runtime. - -The author-time gate that runs on every `active` metadata publish resolves a widget's `dataset` (and a `type: 'page'` view's `pageName`, and the sibling collections the cross-collection security rules compare against) against a resolution universe the host gathers per write. That gather read the SchemaRegistry alone. The registry is filled at boot by code packages, and for every metadata type except `object` a runtime write does not reach it — so a dataset saved through `PUT /api/v1/meta/dataset` was invisible to the gate until the process restarted, while `GET /api/v1/meta/dataset` returned it in the same instant with `_diagnostics.valid: true`. - -Measured on the reported shape, in one process with no restart between the steps: the row is in `sys_metadata`, the read API lists six datasets, the registry lists the five code-package ones, and a three-widget board bound to the new dataset was refused `422` with three `widget-dataset-unknown` issues whose hint enumerated every dataset except the one just authored. The same request answered `200` after a restart, nothing else changed. - -The gather now folds the stored half onto the registry half for every collection it carries. What that does and does not do: - -- **Additive.** A stored row contributes a name the registry does not already carry and never displaces a registry entry — an object's registry copy is its resolved schema (base plus `extend` contributors) and a raw `sys_metadata` row is the base layer alone, so replacing it would trade this phantom for a subtler one. Where an org overlay redefines a code-package item, the gate still judges that item's content from the registry's version. -- **Active rows only.** A draft does not resolve. The refuse-at-publish ruling exists so an author can write the widget first and the dataset second; a draft dataset that satisfied a published board would invert it. -- **Scoped to the write's own partition** — environment-wide rows plus, when the write has one, its own organization. No other organization's overlays are visible to the gate, on any kernel. -- **A failed store read is reported, not swallowed.** Context gathering still never fails a write, but a read that fails for any reason other than an unprovisioned `sys_metadata` now says so once, naming the consequence — a gather that silently shrinks is how a phantom refusal is manufactured in the first place. - -The rules themselves are unchanged: a reference that resolves in neither home is still refused, with the same code, status and key path. diff --git a/.changeset/runtime-job-timeout-ms.md b/.changeset/runtime-job-timeout-ms.md deleted file mode 100644 index d63d3be90d..0000000000 --- a/.changeset/runtime-job-timeout-ms.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): `AppPlugin` threads the authored `job.timeoutMs` to the scheduler as `timeoutMs` (#14478) - -The declarative job door passes `{ retryPolicy, timeoutMs }` to -`IJobService.schedule`, following the `@objectstack/spec` rename of the -authored key and of the `JobScheduleOptions` contract key that carries it. Same -value, same per-attempt limit. diff --git a/.changeset/runtime-state-file-project-key.md b/.changeset/runtime-state-file-project-key.md deleted file mode 100644 index 7bb9751ae3..0000000000 --- a/.changeset/runtime-state-file-project-key.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os serve`'s runtime state file is keyed by the PROJECT, not by the environment id alone — so two projects on one machine stop overwriting each other's supervision record. - - - -**BREAKING** for anything that opens the runtime state file by its old name. Shipped as `minor` under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. The file `os serve` writes under the ObjectStack home was named `runtime..json` and is now named `runtime...json`. - -`os serve` publishes `{ pid, port, url, environmentId, startedAt }` to a file under the ObjectStack home, so a supervisor can answer *"is my server running, and where?"*. That file was named `runtime..json`, and both halves of where it lived were machine-global: `resolveObjectStackHome()` takes no arguments (it reads `OS_HOME`, else `~/.objectstack`), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary `local` environment, therefore wrote one file. - -Driven with two real boots, two project roots and one home, that produced two failures with one cause: - -- project B's boot replaced project A's record, so a reader asking about A's server was answered `pid`/`port`/`url` belonging to **B** — confidently, while A's own server was still alive and still listening elsewhere; -- project A's shutdown then deleted the file that by that point described **B**, leaving a running server with no supervision record at all. - -The file is now `runtime...json`, where the project component is a sanitised basename plus a short digest of the served app's root — the same root `serve` already resolves for host-anchored package loads. The payload is unchanged: no new key, and in particular no database path (which #15374 ruled out deliberately, because it would turn a best-effort supervision file into an identity contract). - -**If you read this file:** a reader that hard-codes `runtime..json` now gets `ENOENT` rather than a stale or foreign record — a loud, correct answer to "is my server running", where the old name could only give a confident wrong one. Readers that glob `runtime.*.json` inside a home they pinned themselves (as `scripts/publish-smoke.sh` does) are unaffected. A `runtime..json` left over from an earlier version is no longer written or cleaned up by `os serve`; delete it once. - -**Which root the project component is taken from**, for a supervisor that has to reconstruct the name out of tree: it is the app root `serve` anchors at, which is the config file's own directory when that file exists and that directory carries a `package.json`, and the process's working directory otherwise. Two boundaries follow, stated rather than fixed: the same app served from two working directories without a manifest keys two files, and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key differently — each spelling gets its own file, and each is internally consistent. - -Two boots of the *same* project from the *same* anchor still share one file, which is the same-project case and unchanged here. diff --git a/.changeset/runtime-tenancy-posture-failure-discrimination.md b/.changeset/runtime-tenancy-posture-failure-discrimination.md deleted file mode 100644 index 546f00ce42..0000000000 --- a/.changeset/runtime-tenancy-posture-failure-discrimination.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The runtime dispatcher door no longer admits a request on a tenancy posture it could not read. - -`resolveExecutionContext` reads the effective tenancy posture from the kernel's `tenancy` service, and both posture-conditional API-key refusals (`organization_required`, `organization_membership_ended`) run only when that posture is present. The read used to swallow every failure into "no posture", so a `tenancy` service that was **registered and failed to build** answered exactly like a deployment with no tenancy at all: the wall was skipped, and an API key stamped with an organization its owner had left — or carrying no organization — was admitted with full grants. - -The seam now carries the same discrimination the REST door already applies (#13906 decision 1, option A), by the registry's own brand rather than by message text: - -- **never registered** — the supported no-tenancy composition. Absorbed as before: no posture, no posture-conditional refusal, nothing changes for single-organization embedders. -- **registered and failed to build** — re-raised as `AuthzStoreUnavailableError`, so the door answers `503 SERVICE_UNAVAILABLE` ("the authorization store could not be read"), which is an existing member of the closed error vocabulary. A posture that could not be read is not a posture that is absent. - -Two nets between the resolver and the transport envelope are told the same thing, in the one shape `@objectstack/core` already prescribes for such seams (`rethrowAuthzStoreUnavailable`): the dispatcher's service facade hands the resolver the classified rejection for `tenancy` instead of collapsing it to `undefined`, and the identity step's catch re-raises only the branded outage while every other fault still degrades to an anonymous request. A consequence worth knowing: an authorization-store read failure (`AuthzStoreUnavailableError` from the permission tables) now also reaches this door as 503 instead of being served as an anonymous request. diff --git a/.changeset/sandbox-writeback-entry-snapshot-normalised.md b/.changeset/sandbox-writeback-entry-snapshot-normalised.md deleted file mode 100644 index 247a4a7410..0000000000 --- a/.changeset/sandbox-writeback-entry-snapshot-normalised.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): a sandboxed hook body no longer launders an untouched `readonly` field onto the row - -A `beforeUpdate`/`beforeInsert` body running in the sandbox made the engine believe it had -written payload keys it never named, and a `readonly` field the caller supplied then survived -the readonly strip and landed. Measured end to end: with `locked_at` declared -`{ type: 'datetime', readonly: true }` and seeded to `2020-01-01`, a caller sending -`locked_at: new Date('2099-12-31…')` alongside a body whose whole source is -`ctx.input.touched_by = 'hook'` stored the caller's 2099 value — while the same object's -readonly `text` field was correctly stripped in the same request. - -The cause was a comparison of unlike things. The write-back decides whether a body wrote -*through* an object-valued key by comparing the host payload value against the VM's exit dump, -and the dump has been through `JSON.stringify`/`JSON.parse` while the host value has not. A -`Date` therefore never compared equal to its own ISO projection, took the documented -"cannot prove equal ⇒ carry it back" path, and was re-asserted onto the proxy that records -which keys a hook wrote. The class was every object-valued value a JSON round-trip cannot -prove equal — an object carrying an `undefined` member included, a `Date` being only its most -reachable member. - -The entry value is now normalised through the same round-trip the VM saw before it is -compared. The same change ends a fidelity loss on non-readonly fields: an untouched key is no -longer carried at all, so a host `Date` is no longer replaced by an ISO string on its way to -the driver. - -Fail-open behaviour is unchanged for values the round-trip genuinely cannot evaluate: a cyclic -or bigint-bearing payload value is still reported as changed and still carried, per key. diff --git a/.changeset/scaffold-blank-template-ci-workflow.md b/.changeset/scaffold-blank-template-ci-workflow.md deleted file mode 100644 index 6dd09b327f..0000000000 --- a/.changeset/scaffold-blank-template-ci-workflow.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"create-objectstack": minor ---- - -Scaffolded projects now ship a CI workflow, and a `lint` script for it to run. -The blank template carries `.github/workflows/ci.yml` — one job, on `push` and -`pull_request`: checkout, pnpm, Node 22, `pnpm install --frozen-lockfile`, then -`pnpm validate`, `pnpm lint` and `pnpm typecheck`. - -`lint` is new to the template (`objectstack lint`). It is not a second spelling -of `validate`: the two share the authoring-rule engine but only `lint` runs the -hook-body lowering check, which catches handlers that have silently stopped -lowering to metadata-only bodies — a change of deployment shape produced by a -refactor that looks like tidying. - -The scaffolder already created `.github/` at runtime for a single file -(`copilot-instructions.md`) while the template's gates shipped as npm scripts -nothing ever ran, so a fresh project started with no CI at all — and ObjectStack -metadata mistakes fail silently at runtime, which makes `objectstack validate` -the only place they surface early. That gate is now unskippable for a human and -for an AI agent authoring metadata in the project, instead of advisory. - -Existing projects are unaffected; copy the file from a fresh scaffold to adopt -it. diff --git a/.changeset/scaffold-emission-policy-one-definition.md b/.changeset/scaffold-emission-policy-one-definition.md deleted file mode 100644 index c62f323ba8..0000000000 --- a/.changeset/scaffold-emission-policy-one-definition.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`objectstack init` and `objectstack create` now read one emission policy instead of each restating it. - -Both commands write a `tsconfig.json` and a set of third-party dependency ranges into a new project. Each had written those in its own words, and the words had come apart. Measured on the tree: the TypeScript range — the value that decides whether a scaffolded project type-checks at all — was written in six places across three scaffolders and had split into three values (`^5.3.0`, `^5.8.0`, `^6.0.0`); the vitest range into two. Dated off `git log -G` as of 2026-09-05: the two CLI values were written in the same commit and stayed apart for 210 days, and the third value is 53 days old — the bundled template landed at `^5.3.0` like the others and was moved to `^6.0.0` later, in a commit that records no reasoning about TypeScript. - -The control for that reading was already in the same file: `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are imported by the second scaffolder rather than restated, and across the same five emissions, the same window and the same authors, they had not drifted at all. So the policy moved to where those already live — `renderScaffoldTsconfig()` and one `SCAFFOLD_*_RANGE` constant per dependency, in `init.ts`, imported by `create.ts`. - -Two emitted values had to survive the merge, and both are argued rather than picked: - -- **TypeScript `^5.3.0`.** `TypeScript 5.3+` is already this project's published floor — `content/docs/getting-started/index.mdx` says so, and `content/docs/deployment/troubleshooting.mdx` repeats it. `^5.8.0` matched no statement anywhere, and `^5.3.0` was already what three of the five emissions carried. Measured rather than assumed: TypeScript 5.3.3 type-checks every shape these two commands emit with results identical to 6.0.3. -- **vitest `^4.0.0`.** Neither value was a recorded decision and both were written in the same commit; `^4.0.18` claimed a patch-level floor nothing justifies and was strictly the narrower of the two. - -**Nothing a scaffolded project installs changes.** `^5.3.0` and `^5.8.0` both resolve to typescript 5.9.3, and `^4.0.0` and `^4.0.18` both to vitest 4.1.11 — what moves is the floor each project declares, which is a support promise, so the surviving one is the promise the docs already make. Driving all five emissions and hashing the trees before and after: every `tsconfig.json` is byte-identical, `os init -t app` and `os init -t empty` are byte-identical in full, and exactly three `package.json` files change by exactly the one line each. - -`npx create-objectstack` is deliberately untouched. It cannot import from `@objectstack/cli` — the dependency edge runs the other way — and its `^6.0.0` is a different question: unifying it would change which major of TypeScript a scaffolded project installs. diff --git a/.changeset/scaffold-on-ramp-emission-policy-generated.md b/.changeset/scaffold-on-ramp-emission-policy-generated.md deleted file mode 100644 index b21a950309..0000000000 --- a/.changeset/scaffold-on-ramp-emission-policy-generated.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"create-objectstack": minor ---- - -`npx create-objectstack` now declares the same TypeScript range as `os init` and -`os create`, and the value is generated rather than restated. - -Three scaffolders write a new project's `package.json`, and the range that -decides whether that project type-checks at all had split: `os init` and -`os create` emitted `typescript: ^5.3.0` from a shared emission policy, while -this package's bundled template carried `^6.0.0`. Two projects created the same -day got different TypeScript **majors** depending on which documented entry -point the reader followed. - -- **What changed for a scaffolded project.** Its declared `typescript` - devDependency floor moves from `^6.0.0` to `^5.3.0`. Both resolve to the same - installed compiler on a fresh install; what moves is the floor the project - **declares**, and a floor is a support promise. `^5.3.0` is the promise the - docs already make — "ObjectStack works with TypeScript 5.3+" on the getting - started page, "TypeScript 5.3.0 or later" in the deployment troubleshooting - page — and it is measured rather than assumed: TypeScript 5.3.3 type-checks - every shape these scaffolders emit with results identical to 6.0.3. The repo's - own `typescript@^6.0.3` devDependency is deliberately not this value; the same - doc sentence states both halves ("…but the project itself is built and tested - against TypeScript 6.x"). `engines.pnpm` was already in agreement and is now - held there by the same mechanism. -- **Why the value is generated.** This package cannot import from - `@objectstack/cli`: the dependency edge runs the other way, and the `npx` - package must not pull the CLI's package closure. So the values are stamped - into the bundled template at build time by - `scripts/sync-scaffold-emission-policy.mjs`, read out of the same - `SCAFFOLD_*` constants the other two scaffolders import, and - `pnpm check:scaffold-emission-policy` reddens the moment the inlined values - disagree with that source. Editing the two into agreement by hand would have - left them free to diverge again on the next move, silently, for the same - structural reason — which is how they diverged the first time. diff --git a/.changeset/scaffold-skills-single-committed-copy.md b/.changeset/scaffold-skills-single-committed-copy.md deleted file mode 100644 index 5d3b6113a7..0000000000 --- a/.changeset/scaffold-skills-single-committed-copy.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"create-objectstack": minor ---- - -Scaffolded projects now install the AI skills bundle for **one** agent runtime -instead of every runtime the skills CLI knows, so the bundle is committed once. - -**Route B of the two the card offered was taken**, and the choice was measured -rather than argued. Against `skills@1.5.23` and the 11-skill catalog, the old -`--all` (shorthand for `--skill '*' --agent '*' -y`) wrote the same bundle to -three destinations — `.agents/` (46 real files, 604,102 B), `agent/` (46 real -files, 602,682 B, identical bodies with re-serialised frontmatter) and -`.claude/` (11 symlinks into `.agents/`). The template's `.gitignore` excluded -none of it, so a new project's first `git add -A` staged 22 `SKILL.md` paths -plus 11 symlinks. That reached the initial commit of a real app before anyone -noticed. - -The scaffolder now runs -`npx skills add objectstack-ai/objectstack/skills --skill '*' --agent claude-code -y`, -which writes 46 real files to `.claude/skills/` and nothing else: 11 staged -`SKILL.md` paths, no symlinks, and a clone of that commit has readable skill -files on every platform. - -Route A (keep `--all`, exclude the duplicates in the template `_gitignore`) was -built and cloned, not reasoned about, and both of its shapes were rejected. -Ignoring `.agents/` and `agent/` while committing `.claude/` gives a fresh -cloner 11 dangling symlinks and zero readable `SKILL.md`. Ignoring only -`agent/` works on POSIX but commits 11 symlinks that a `core.symlinks=false` -clone — git-for-Windows' default — materialises as ordinary files whose whole -content is the link target. `--all --copy`, the other way to make `.claude/` -real, fans out to 56 destination directories totalling 33.8 MB. A denylist is -also the wrong shape regardless of which paths it names: this package does not -choose the destination set, the skills CLI does, and it moves with that -package's releases. - -The cost is the multi-runtime default, and it is paid in the open: the closing -summary now always prints an **AI Skills** block naming where the bundle landed -and the one-line command for any other runtime, one agent at a time. The -bundle is identical whichever agent is named. - -Existing projects are unaffected. To shrink one that already carries the -triplicate, delete `.agents/` and `agent/` and re-run the single-agent command -above; `skills-lock.json` records source and hash, not paths, so it does not -change. diff --git a/.changeset/schedule-flow-dispatch-claim-ledger.md b/.changeset/schedule-flow-dispatch-claim-ledger.md deleted file mode 100644 index 9e30c5c01f..0000000000 --- a/.changeset/schedule-flow-dispatch-claim-ledger.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/trigger-schedule": minor -"@objectstack/service-automation": minor -"@objectstack/service-job": minor ---- - -A scheduled (cron) flow is now delivered once per tick window, and replaying a window that was already delivered is refused instead of silently sent again. - -A `time_relative` flow has taken a persisted dispatch claim per `(flow, window, record)` since #10220, so per-record once-only delivery is free for it. A `schedule` flow runs once per tick with no record and had no claim surface at all, so "this batch already went out" fell back to whatever each app remembered for itself. A scheduled digest that was replayed by an operator, or whose process restarted inside its window, delivered twice. - -Scheduled flows now claim `(flow, tick-window)` in the same `sys_flow_dispatch` ledger, and settle that claim with what the run turned into: - -- **A second fire inside one window does nothing.** The window key is a pure function of the schedule descriptor and the clock — the previous occurrence of the very same cron expression in the very same timezone, computed with the same library the job adapter schedules with — so a restart inside the window computes the same key and hits the same claim. -- **`IJobService.replay()` refuses a delivered window**, with the ADR-0112 envelope its contract declares: `code: 'RESOURCE_CONFLICT'`, `status: 409`, and a message naming the window and the claim that refused it. The promise rejects — an operator who presses replay and sees nothing happen is exactly the outcome this replaces. -- **`replay(name, data, { force: true })` sends anyway.** The duplicate is the operator's, taken knowingly. -- **A window whose claim is absent, failed or unsettled re-runs** on a plain `replay()`, with no force needed. A job that takes no claim at all — every job that is not a scheduled flow — is the absent row and behaves exactly as before. -- **`succeeded` is absorbing.** A replay that repairs a failed window records `succeeded`, so the next unforced replay is refused. A *forced* replay that throws leaves the window recorded delivered rather than rewriting it to `failed` — otherwise a failed re-send would silently reopen the unforced re-delivery door. An operator whose forced replay failed forces again. -- **A `once` schedule now has a tick window too** — the single instant it is due, which is one window for the job's whole life. The visible consequence is on replay: an operator who replays a one-shot job *before* its due instant claims that single window, so the real fire then finds the claim and does nothing. Previously both ran. - -The error-isolation `catch` that keeps a throwing flow from crashing the ticker is unchanged and still swallows. What it no longer does is leave the run indistinguishable from a delivered one: the throw settles the window's claim as `failed`, so a replay repairs it. - -`sys_flow_dispatch` gains two optional columns, `outcome` and `settled_at`. Rows written before this release read as unsettled, which reads as not delivered — the safe direction, since a replay of one re-runs rather than being refused. Only `schedule:` claims are ever settled; a `time_relative` sweep's rows stay `null` by design. - -⚠️ **If you manage this table's DDL out of band** — anything other than letting the platform sync `sys_flow_dispatch` from its object definition — add `outcome` (text) and `settled_at` (datetime) yourself before upgrading. Without them every `settle()` throws against the driver. Dispatch dedup still works and no flow fails (the settle is best-effort and logged), but no claim ever records an outcome, so the replay refusal never fires and this release's headline change is silently absent. - -Interface changes for hosts that implement the ledger themselves: - -- `FlowDispatchStore` gains **optional** `settle()` and `read()`. A store without them still deduplicates; it announces once that the refusal cannot fire. -- `FlowDispatchStoreEngine` — the narrow ObjectQL slice the bundled store demands — now **requires** `update` alongside `find` and `insert`. A custom engine adapter typed against it must add the method. -- New exported types: `FlowDispatchClaim` and `FlowDispatchOutcome` from `@objectstack/service-automation`; `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/service-job` (the parameter type of `DbJobAdapter.setReplayGuard`, exported so it can be named); `ScheduleDispatchLedger`, `ScheduleDispatchClaim`, `ScheduleDispatchOutcome`, `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/trigger-schedule`. -- `croner` moves from a devDependency to a **dependency** of `@objectstack/trigger-schedule`, which now imports it at runtime to compute the cron tick window. It is already a runtime dependency of `@objectstack/service-job` at the same range, so the platform's dependency set does not grow. diff --git a/.changeset/schema-drift-single-value-json-column.md b/.changeset/schema-drift-single-value-json-column.md deleted file mode 100644 index 4095cd124f..0000000000 --- a/.changeset/schema-drift-single-value-json-column.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -Schema drift now reports a SINGLE-VALUE JSON-class column that a stale `varchar`/`text` column is holding — the population the detector could never see. - -The driver decides a field's column type with `JSON_COLUMN_TYPES.has(type) || !!field.multiple`: `createColumn` gives a json column to every JSON-class TYPE, and `isJsonField` — the read-side deserializer — asks the same question. The drift detector asked only `field.multiple === true`. So a single-value `file` / `image` / `location` / `address` / `record` / `vector` / `json` field (and the option families) sitting on a `varchar` or `text` column was written as JSON by the writer and did not exist to the differ. Because the additive sync never migrates a column's type, that column stayed wrong permanently and nothing reported it. Measured on the previous tree, one call per type: all fifteen JSON-class types the spec declares returned zero findings over a `character varying(2048)` column on `postgres` and `mysql`, while the same column under a `multiple: true` field returned one in the same run. - -The detector now reads the writer's own predicate, so the two halves can no longer disagree about which declarations get a json column. `SQLite is unchanged and still reports nothing`: its read path parses a textual column regardless of what the column calls itself, re-measured on an in-memory cell as a byte-identical round-trip between the stale column and the driver's own. - -**The remedy is offered to the array-valued half only.** `os migrate multi-value-columns` repairs a stale column by wrapping each stored value in a one-element JSON array, which is the right repair for a field whose value is a list and the wrong one for a field whose value is a scalar or an object. Findings for array-valued fields (`multiple: true`, and the inherently-multi option types) keep their message character for character, so that command keeps recovering the dialect from it and keeps working exactly as before. Findings for single-value JSON-class fields carry a message of their own that names neither the command nor its statement, explains why the automated route is withheld, and describes the by-hand conversion; the command refuses such an entry (`remedy_not_recognized`) instead of running array SQL over scalar rows. - -Also fixed by the same predicate: a single-value JSON-class field declaring a `maxLength` over a wider `varchar` column used to be reported as `narrow_varchar` at category `destructive` — inviting `os migrate apply --allow-destructive` to rewrite the column to a narrower varchar, the opposite of the repair it needs. It is now reported once, as the base-type divergence. diff --git a/.changeset/scope-less-booted-row-attribution.md b/.changeset/scope-less-booted-row-attribution.md deleted file mode 100644 index 5d02576295..0000000000 --- a/.changeset/scope-less-booted-row-attribution.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/runtime": patch -"@objectstack/metadata-protocol": patch ---- - -docs(runtime,metadata-protocol): correct the `writable` verdict's illustration — the scope-less booted row is a marketplace / offline import, never a multi-package artifact's module (#14803) - -Comment and prose only. No predicate, no assertion and no served shape changes; -every pin behind the `writable` verdict stays green as written. - -The `writable` verdict shipped in 17.3.0 with a **false attribution** in its own -explanation, and this corrects it at every site that repeated it. The claim was -that the scope-less booted row `isWritablePackage` answers `false` for is *the -`type: module` sub-package a multi-package artifact carries*. It is not, and it -never was: - -- `defineStack` parses every `packages[]` entry through `ManifestSchema` - (`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), whose `scope` is - `.default('project')` (`spec/src/kernel/manifest.zod.ts`), so **no** package of - a compiled artifact is ever scope-less — `dist/objectstack.json` and both - served rows carry `scope: "project"`. -- A genuinely scope-less row arises only where a manifest reaches the registry - **without** that parse, because `installPackage` stores a key-by-key copy that - applies no defaults: a marketplace install / offline file import - (`manifestService.register(rawBody)` to `ql.registerApp`) for the **booted, - read-only** half, and `POST /api/v1/packages` (`body.manifest || body` to - `installPackage`) for the **database base, writable** half. - -Measured: `ManifestSchema.parse` of the `app-multi-package` orders body turns an -unauthored `scope` into `scope: "project"`, while `SchemaRegistry.installPackage` -of the same unparsed body yields a record with no `scope` key at all. - -What stays, because it is true and load-bearing: a scope-less **booted** package -is read-only while a scope-less **database base** is writable, and only -`engine.manifests` tells them apart — which is why the server owns the verdict. diff --git a/.changeset/score-metadata-lint-crash-visible.md b/.changeset/score-metadata-lint-crash-visible.md deleted file mode 100644 index 1b2de23d85..0000000000 --- a/.changeset/score-metadata-lint-crash-visible.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`scoreMetadata` no longer scores a stack whose linter crashed as a perfect one. - -The metadata rubric is two halves: a schema parse and the lint sweep. When `lintConfig` threw, the scorer caught the throw and continued with `issues = []` — so the penalty was 0 and a stack half of whose rubric never ran came back as **100 / grade `A` / `valid: true`, every count zero, `issues: []`** — byte-for-byte the verdict a genuinely clean stack gets. "The linter found nothing" and "the linter never ran" collapsed into the better-looking one. - -The crash is reachable on a schema-valid stack: a localized `label` (`{ en: 'Todos', 'zh-CN': '待办' }`) on an app, or on a view's `list`, parses clean and makes the label-case rule throw a `TypeError`. That rule's crash is a separate defect, filed on its own; what changes here is that the scorer stops publishing a clean verdict it did not earn. - -A crashed lint run is now recorded in every carrier a consumer might read, because reading any one of them has to be enough: - -- **`lintError`** — a new optional string on `MetadataScore`, carrying the thrown message. Set only when the linter could not run; absent when it ran and reported errors, which is a lint verdict rather than a missing one. It reaches the CLI's published payload through `os lint --eval --json`, on `results[].score`. -- **A synthetic `error` issue** (`rule: 'rubric/lint-crashed'`, exported as `LINT_CRASHED_RULE`) — so `issues`, `counts.errors` and `valid` carry the failure too. This is what makes the eval harness fail the case: its `passed` reads `counts.errors`, and would never have seen a new field. It still fails at `--eval-min 0`, where the score alone stops discriminating. -- **`score: 0` / grade `F`** — the only channel `os lint --score --json` publishes, and the same refusal `unscorableScore()` already gives an eval case there was nothing to judge. - -The schema half is untouched and still reported: `schemaErrors` and `counts.schemaErrors` say exactly what the parse found, which was the defensible half of the original intent. diff --git a/.changeset/screen-flow-headless-satisfaction.md b/.changeset/screen-flow-headless-satisfaction.md deleted file mode 100644 index eb138b9d93..0000000000 --- a/.changeset/screen-flow-headless-satisfaction.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/runtime": minor ---- - -A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names. - -An `ai.exposed` action whose target is a **screen flow** could be started over MCP and never finished. `run_action` seeded the flow's `isInput` variables from the caller's `params` — correctly — and the screen node suspended anyway, because the only inputs to that decision were "does the node declare fields" and the author's `waitForInput` flag. The MCP tool set has no verb to resume a parked run, so `ai.exposed` meant "the agent can invoke this", not "the agent can complete this". The fallback an agent took instead — re-implementing the flow's tail with `create_record` + `update_record` — bypasses whatever business rules the flow encapsulated. - -Two independent halves: - -- **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`). -- **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing. - -**Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen. - -**Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `Id`, or when its value equals what the bag carries under `recordId`, `Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too. - -⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause. - -⚠️ This does **not** make every screen flow completable over MCP. A call that omits the inputs still parks, and nothing on that surface can resume it; that half is a resume verb and is not this change. diff --git a/.changeset/screen-provenance-record-leg-by-value.md b/.changeset/screen-provenance-record-leg-by-value.md deleted file mode 100644 index 4b96f248e0..0000000000 --- a/.changeset/screen-provenance-record-leg-by-value.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A wizard screen is no longer skipped after a durable pause because a record column happens to be an array. - -`judgeHeadlessScreen` decides a screen was already answered by proving the negative: a field is **not** caller-supplied when the subject record carries that key and `params` holds the same value — necessary because the params bag a flow action arrives with is `{ ...record, recordId, Id, ...params }`, so every column of the launched row is in there whether the caller named it or not. - -That comparison was reference identity (`Object.is`), which is real in memory and does not survive persistence. A suspended run stores its context as JSON and resumes from the parsed copy — and the store is preferred over the in-process cache whenever one is wired, so no restart is needed. After that round trip an **array or object** column is equal but no longer identical: the record leg could not disprove it, the field read as caller-supplied, and a later screen with no required fields of its own was **skipped on a run that had supplied nothing**. An interactive user pressed a button and never saw a form they should have been shown; the run completed carrying the row's own value as if they had typed it. Reproduced end to end against a wired store, not inferred. - -The record leg now compares by value (`isDeepStrictEqual`), which survives serialisation. That predicate compares primitives with `Object.is` itself, so this is a strict widening of the "not caller-supplied" set — every pair the old check called equal it still calls equal, plus the structurally identical non-primitives. More screens render, never fewer, which is the direction this module resolves every ambiguity in. - -**Accepted cost, precisely.** A caller that genuinely re-sends a value structurally identical to the row's column is no longer distinguishable from the dispatcher's seed, so it now gets the screen rendered instead of skipped — a lost skip on a headless call, never a lost run, and the same trade the module's other legs already make. Scalar columns behave exactly as before, on both sides of a pause. The row-id leg keeps identity comparison deliberately: a row id is a scalar by construction, so serialisation cannot defeat it and there is nothing there to widen. Measured overhead is a deep compare per declared screen field at screen entry: ~1.5 µs added for a deliberately maximal screen that declares a field for every one of a ten-column row, which is about 38% of one `JSON.stringify` of the run context — a cost the durable store already pays on every suspend. - -This closes the gap the same release's screen-flow headless-satisfaction note records as known. diff --git a/.changeset/seed-apply-read-back-decorations.md b/.changeset/seed-apply-read-back-decorations.md deleted file mode 100644 index 07f7fa8b47..0000000000 --- a/.changeset/seed-apply-read-back-decorations.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The package-publish door's route-level seed apply can consume the platform's own read-back envelope again. - -`POST /packages/:id/publish-drafts` reads each just-published `seed` body back through `protocol.getMetaItem` before handing it to the seed loader. That read exits through `decorateMetadataItem`, which stamps `_diagnostics` on every body whose metadata type has a registered schema — `seed` has one — and `SeedSchema` has been closed since protocol 17. So the door refused the document it had just been served: `unrecognized_keys: ["_diagnostics"]`, minted as a 422 and delivered on a **200** as `seedApplied.error`. Zero rows loaded, and the author was told their seed body failed spec validation when nothing about it was wrong. - -The read-back is now passed through `stripReadDecorations` at the unwrap — the same helper, for the same reason, that the dataset query, the cold-boot flow bind and `saveMetaItem`'s verbatim persist already call. `METADATA_READ_DECORATIONS` is the declared list of keys the read path derives from a document and attaches to the *response*, so removing them restores the document the author actually wrote. - -Nothing is widened to accept them: `SeedLoaderRequestSchema` stays closed, and the publish response keeps its declared shape. The strip is deliberately **not** a blanket `startsWith('_')` sweep — the ADR-0010 protection envelope (`_packageId`, `_provenance`, …) is not a read decoration, and the metadata schemas allowlist it precisely so a served document keeps its provenance when it is parsed again. - -Only protocols that do not self-apply seeds inside `publishPackageDrafts` reach this path; the shipping protocol self-applies and was never affected. diff --git a/.changeset/seed-diagnostic-nul-joiner.md b/.changeset/seed-diagnostic-nul-joiner.md deleted file mode 100644 index 1054dd7605..0000000000 --- a/.changeset/seed-diagnostic-nul-joiner.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Seed loader: a composite `externalId` no longer puts a raw NUL byte in a -diagnostic line. - -`SeedLoaderService` joins a composite natural key's parts with U+0000 on -purpose — that byte cannot occur in a natural-key value, so `('a','b')` and -`('a\0b','')` never collide. The map key is unchanged. What changes is that -the key string is no longer interpolated into human-readable messages: the -`Failed to write record #N (=)` parenthetical and pass -2's `on record ''` lines now render a composite value as a JSON array of -its parts (`(employer+user=["emp-1","usr-2"])`). - -A single-field `externalId` renders byte-identically, so non-composite -diagnostics do not move, and the structured `errors[].attemptedValue` still -carries the real key. - -Why it mattered: one raw NUL makes `grep` classify the whole server log as -binary, so every later `grep -n` / `grep -c` over it silently returns nothing -until the reader adds `-a` — the reader's main instrument disabled by one byte, -at the moment someone is diagnosing a failed boot. diff --git a/.changeset/seed-locale-axis.md b/.changeset/seed-locale-axis.md deleted file mode 100644 index 246d614809..0000000000 --- a/.changeset/seed-locale-axis.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor ---- - -Seed datasets gain a `locale` filter axis, composed with `env` by the loader. - -An app shipping demo data for two language markets — the same records, different display strings — had no declarative way to say which dataset applies. `SeedSchema` is a `strictObject`, so the app could not add the key itself; the selection had to happen in application code while the config was assembled. That is the wrong layer twice over: the choice is cached in the build output (switching markets means deleting `dist`), and because every profile is an `upsert` and the loader only writes, the other market's rows stay resident in the database. - -- **`Seed.locale?: string[]`** — BCP-47 tags scoping the dataset to one or more language markets. **Omitted means every locale.** Unlike `env`, whose three environments are a closed set that can be spelled out as a default, locales are open-ended tags with no enumerable universe — so absence, not a default array, is what carries "unrestricted". An empty array is rejected: a dataset that applies nowhere is an authoring mistake, the same reasoning that already governs a composite `externalId`. `locales`, `language` and `languages` are aliased onto it, matching the existing `environment` / `environments` → `env` pair. -- **`SeedLoaderConfig.locale?: string`** — the tag the load filters on. -- **The loader composes both axes by conjunction.** A dataset is loaded when it passes `env` **and** `locale`; neither axis can rescue a dataset the other excluded. `filterByLocale` mirrors `filterByEnv` down to the reporting posture — skipping is the declared, intended outcome, so it logs at `info`, but it always names what it dropped. Tags compare case-insensitively (BCP-47 casing is a convention, not part of a tag's identity) and otherwise exactly: `['zh']` does not match `zh-CN`, and widening that would be the lenient consumer-side fallback the contract-first rule forbids. - -The platform still translates nothing and merges nothing. The app authors both record sets; this adds only the axis that selects between them. - -**What is not wired yet, stated plainly.** The locale axis is evaluated against `config.locale`, and no first-party call site supplies one — the runtime wiring that would resolve it from the stack's configured locale is a separate change in `packages/runtime`. An embedding host that passes `config.locale` itself gets the full behaviour today; on the default boot path the axis is inert. That is the shape `Seed.env` was in before framework#4704, so it is not left silent: a load carrying locale-scoped datasets and no `config.locale` warns naming each dataset it let through and the config key that would make the scope take effect. The liveness ledger records `seed.locale` as `experimental` for exactly this reason, with the consumer side cited and the producer gap spelled out, rather than claiming `live` on a correct-but-insufficient consumer pointer. diff --git a/.changeset/seed-read-drops-dead-org-rung.md b/.changeset/seed-read-drops-dead-org-rung.md deleted file mode 100644 index 7c249b06fe..0000000000 --- a/.changeset/seed-read-drops-dead-org-rung.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The package-publish seed read-back no longer runs a two-attempt org-then-env ladder whose rungs resolve the same row. - -`applyPublishedSeeds` — the route-level seed apply behind `POST /packages/:id/publish-drafts`, which runs for protocols that do not self-apply seeds inside `publishPackageDrafts` — read each just-published `seed` body twice when the session had an active organization: once naming the organization, then once env-wide. The comment above it said the first attempt tried the active org and the second fell back, "and resolving the wrong scope here is what silently produced `0 rows loaded`". - -That was true when it was written and is not true now. `seed` declares `allowOrgOverride: false`, and `getMetaItem` resolves `organizationIdForMetaRead(request.type, request.organizationId)` once at its top and spends that binding — never the raw argument — on every read beneath it. The predicate answers `undefined` for every non-overridable type, so both rungs asked the engine the same predicates and served the same answer. Measured rather than reasoned: against the shipping protocol over one store, the two requests produce byte-identical engine reads and byte-identical answers on both the hit and the miss branch, and neutering the second rung reddens nothing on a pinned publish-then-read path (a `view` control confirms the same comparison does separate the two rungs for an org-overridable type). - -The read is now a single call naming no organization, and the comment states that the scope is decided by the registry flag and the gate inside `getMetaItem` rather than by this call site — matching the sentence the `app` flip in the same file already carries. - -One observable changes, and only on the failure branch: `getMetaItem` answers a wrapper rather than a falsy value for a name it cannot resolve, so the second rung was in practice reached only when the read *threw* — where it repeated the identical failing read and appended the same sentence to the client-facing `seedApplied.errors[]` twice. A failed read-back is now reported once. Nothing about which row a publish resolves, or whether its rows load, moves. diff --git a/.changeset/serve-unlinked-database-file-watch.md b/.changeset/serve-unlinked-database-file-watch.md deleted file mode 100644 index 7a85c645f2..0000000000 --- a/.changeset/serve-unlinked-database-file-watch.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os serve` now says so when the SQLite file it is serving is no longer the file at its configured path. - -Deleting the data directory under a running server — `rm -rf .objectstack/data`, which is what a `demo:reset` script does and what a fresh-database repro starts with — unlinks the inode without touching the process. SQLite keeps reading and writing the now-invisible file, health keeps answering `200`, and a later boot creates a brand-new database at the same path. From that moment every filesystem inspection of that path describes a *different* database than the running server answers from, and nothing anywhere says so: a row edited there has no observable effect on the live server, and a user who authenticates against the live server is not in that file. Both readings are true, both look like a broken write path, and one investigation that reported them as evidence cost a full P0 cycle. - -A boot that serves an on-disk SQLite file now records that file's identity once the boot is complete and re-checks it on a 30-second interval. When the file is gone, or the path holds a different file, it reports **once** at `error` — naming the path, the consequence (every external observation of this deployment is now false, and it will keep looking healthy) and the fix (restart the server so it opens the file that is at that path now). - -It refuses nothing and retries nothing: the running server is still correct, merely invisible, and breaking a working dev loop to fix a reporting gap would trade a bad hour for a worse one. Nothing is added to any payload, endpoint or state file. Silence from the check is not a claim that the file is intact — every uncertainty in it resolves toward staying quiet, because a false report would send an operator to restart a server whose database is fine. diff --git a/.changeset/service-analytics-text-operator-non-text-column.md b/.changeset/service-analytics-text-operator-non-text-column.md deleted file mode 100644 index 90bd3fe9b1..0000000000 --- a/.changeset/service-analytics-text-operator-non-text-column.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -The three SQL compilers in this package — the RLS read-scope lowering (`compileScopedFilterToSql`), `NativeSQLStrategy`'s own `where` and the `ObjectQLStrategy` SQL echo — compile a text operator over a column whose declared type stores no text to the contract's declared answer. - -`compileScopedFilterToSql(filter, alias, options?)` takes a new optional `nonTextColumn(field)` predicate; when it answers `true`, a positive text operator compiles to `1 = 0` and `$notContains` to `1 = 1` instead of a `LIKE` that coerces on SQLite (`5` renders `'5.0'`) and is refused at query time on Postgres (SQLSTATE 42883 — a 500 on a read scope the platform accepted). The service answers the predicate from the field metadata hook it already holds (`sourceFieldMeta`), exposed to strategies as `DatasetScopedStrategyContext.declaredFieldType`, and the two strategies pass it for the read scope and for the query's own text filters, so a query and its RLS scope answer one cell one way and the echo prints the statement that ran (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05). A host that wires no field metadata keeps the `LIKE` it always got, and every comparand refusal still runs ahead of the constant. diff --git a/.changeset/service-datasource-turso-timeout-ms-reader.md b/.changeset/service-datasource-turso-timeout-ms-reader.md deleted file mode 100644 index cd2e32c621..0000000000 --- a/.changeset/service-datasource-turso-timeout-ms-reader.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -fix(service-datasource): the shared libSQL config builder reads the canonical `config.timeoutMs` (#16023, follow-up on #15680) - -`buildTursoDriverConfig` — the ONE seam both libSQL loaders go through (#7314) — -still consulted `config.timeout` after #15680 renamed that authored key to -`timeoutMs` and tombstoned the old spelling. A turso datasource authored the -canonical way therefore reached the seam, matched nothing, and had its timeout -**silently dropped**: no diagnostic in any channel. - -The reader now consults `config.timeoutMs`. The DRIVER key it lands on is -unchanged and still spelled `timeout` — `TursoDriverConfig.timeout` is -published-but-inert (#16024), and renaming an inert key would ratify it as real, -which is what ADR-0049 exists to prevent. So this seam is the one place the -authored and driver spellings differ, and it now says so. - -## No fallback arm for the retired spelling — the seam's own precedent - -Both sibling arms in `default-datasource-driver-factory.ts` already answer this -in the same words: sqlite's "`filename` is the whole contract … so no `??` -tolerance survives here", mongo's "`url` is the one spelling". A renamed -datasource config key reaches a reader already canonical from two directions — -authoring refuses the retired spelling at the door (`retiredKey()`: `tsc` -`never` plus a parse-time prescription), and a stored `sys_metadata` row replays -the full ADR-0087 chain including `retiredFromLoadPath` entries at -`loadDatasourceRows` / `loadDatasourceRow`, so the D2 conversion -`turso-config-timeout-to-timeout-ms` has rewritten the key before this table -sees it. A `??` arm would be a consumer-side dialect (Prime Directive #12) for a -spelling both doors have closed. - -`authToken`'s legacy arm is not a counter-precedent: it is kept for a LIVE route -(host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it constructs -itself, which never meets the authoring schema), not for a retired spelling. - -## Why the covering test did not catch it, and what replaces it - -`TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a -rename through it — the tombstone's type channel, which caught the alias tables -elsewhere in this stack, does not reach here. And the covering test authored the -**retired** spelling at all three of its turso `config` sites, so it was green -for exactly the behaviour that had become wrong. A test that pins the retired -spelling cannot notice this class of bug. - -The three sites now author the canonical spelling, and the file gains cases -DERIVED from the authoring contract rather than written against today's key -list: they read `TursoConfigSchema`'s own `retiredKey()` tombstones and assert -that (a) every canonical replacement is consulted by some reader, and (b) no -retired spelling is — probed at every JS type a reader could type-test, with a -vacuity guard so a mis-derived empty list fails instead of passing. They hold -for the next rename without being edited. - -The two sibling pins that author the same spec — `packages/cli`'s driver -correspondence check and `packages/runtime`'s cross-loader convergence check — -move to the canonical spelling with it; their assertions read driver keys and -are unchanged. diff --git a/.changeset/service-job-timeout-ms.md b/.changeset/service-job-timeout-ms.md deleted file mode 100644 index d8a48f708f..0000000000 --- a/.changeset/service-job-timeout-ms.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-job": patch ---- - -fix(service-job): `runWithPolicy` and the DB job adapter read `JobScheduleOptions.timeoutMs` (#14478) - -The per-attempt time limit is read from `options.timeoutMs`, following the -`@objectstack/spec` rename of both the authored `job.timeoutMs` and the -`JobScheduleOptions` contract key that carries it. Same value, same per-attempt -race, same `JobTimeoutError`; `withoutPolicy` strips the renamed key so the -timer adapter downstream never runs a second budget. diff --git a/.changeset/service-storage-test-tsc-program.md b/.changeset/service-storage-test-tsc-program.md deleted file mode 100644 index 0879cbebd1..0000000000 --- a/.changeset/service-storage-test-tsc-program.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/service-storage": patch ---- - -fix(service-storage): put the test layer in front of tsc, and repair what it was hiding (#15050) - -`packages/services/service-storage` had **no `typecheck` script at all** — its -scripts were `build` and `test` — so no tsc program anywhere read this -package's test layer, and its errors were carried instead as a 51-error DEBT -entry in `scripts/check-type-check-coverage.mjs`. Gives it the #14062 / -#14181 "checked test zone" shape: a sibling `tsconfig.test.json` (module -semantics only — `esnext` / `bundler` / `lib: ES2022` — matching how vitest -actually executes these files; strictness inherited and untouched) plus a -`tsconfig.scripts.json` for `scripts/i18n-extract.config.ts` (the ninth -instance of #11351, previously excluded from that ledger only because this -package had no `typecheck` script to hang it on), both named by a new -`typecheck` script. - -Measured before repair: 51 errors under BUILD semantics (`tsc --noEmit -p -tsconfig.json`, which already includes the tests — matching the DEBT entry's -recorded number exactly), 10 under the split. Unlike `service-cluster` -(#14181), this package's BUILD reading was *not* already clean, so both -programs needed genuine repair, not just the test-only split: 23 `TS2835` -(relative imports missing their `.js` extension, required under BUILD's -NodeNext resolution) were fixed by *adding* the extension — which resolves -correctly under both NodeNext and the split's bundler mode — and clearing -that also cleared all 15 `TS7006` "implicitly any" as a downstream cascade -from the same unresolved imports (the shape `@objectstack/core` reported at -98 → 4). The remaining 3 `TS2550` (`Array.prototype.at` needing `lib` -es2022) are rewritten to indexed access rather than widening the shared -BUILD `tsconfig.json`. The 8 code-tier errors (`TS2339` × 4 — a test -helper's object-spread dropped its `Record` index -signature, fixed with an explicit return-shape annotation; `TS2347` × 4 — a -fake `ctx: any`'s `getService(...)` calls converted to `getService(...) -as T`, the pattern one call site in the same file had already adopted for -exactly this reason) are genuine test-file fixes. Both readings now agree at -0/0 — the same result `service-cluster` reported, reached by a longer road. - -The package's DEBT entry (51 errors) is **deleted**, not lowered — the -graduation this ratchet's invariant requires. No `test-typecheck-debt.json` -is added: residue is 0, so none is owed (#5286, maintainer-only to open). -`check:type-source-resolution` went red from onboarding the two new -programs (the documented onboarding-limb case): a registry entry is added -rather than `paths`, measured both ways — `paths` takes this package's test -layer from 0 errors to 306, all in other packages' source. - -No runtime code changes: `src/**` excluding tests is byte-identical, so no -shipped behaviour moves. The `patch` level reflects the published -`package.json` gaining `typecheck` / `check:test-typecheck` scripts and a -`tsx` devDependency. diff --git a/.changeset/session-payload-positions-security-axis.md b/.changeset/session-payload-positions-security-axis.md deleted file mode 100644 index 97ca7023fb..0000000000 --- a/.changeset/session-payload-positions-security-axis.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -"@objectstack/plugin-auth": minor -"@objectstack/spec": minor ---- - -fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136) - - - -**BREAKING** meaning change on a published payload — `user.positions` in -`GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's -launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on -#15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one -name, one meaning. - -`customSession` built the array from the better-auth `sys_user.role` scalar -split on commas, plus the active membership mapped to `org_*`, plus -`platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057 -D4 table that is the source of truth for custom positions. The Console binds -that array straight through as the CEL root `current_user`, so an -`action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed -by a business position answered FALSE for **everyone**, including the user who -genuinely held it. - -⭐ It failed **silently and in the invisible direction**: the root was bound and -the key was present, so `has(current_user.positions)` was true, CEL raised -nothing, and the predicate simply returned FALSE. A predicate that *faults* -fails OPEN in the shell and would have shown the button; a successful FALSE -shows nothing and reports nothing. The documented example -(`'org_admin' in current_user.positions`) kept working throughout, because -`org_admin` is the one name that sits on **both** axes. - -This was a **declared** contract being violated, not an ambiguous name: -`EvalUserSchema` already specified `positions` as "built-in identity names + -position names", exposed to "every predicate surface (server formula, server -RLS, client UI gates) ... with an identical shape" so that a predicate -"evaluates identically wherever it is written". `/auth/me/permissions` and -every server-side evaluator (`ExecutionContext.positions`) already resolved the -security axis; only the session payload did not. - -**What changes** - -- `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**, - not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE - authority (`core/security/resolve-authz-context.ts`, whose header forbids - every entry point from re-reading the `sys_*` grant tables itself), scoped to - the session's active organization. The payload therefore carries the - `sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and - agrees with `/auth/me/permissions` set for set. Same move - `isPlatformAdminUserId` made at #10348. -- `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as - an alias of `'platform_admin' in positions`), so one authority answers both. -- `packages/spec` — `EvalUserSchema` states which axis `positions` is, and - states that the better-auth role scalar is not it. - -**No key is renamed, and none is added.** The ruling anticipated a renamed -auth-role array; measured against the tree, it has no content to carry and no -consumer. Everything the old union contributed beyond the security axis was the -`sys_user.role` scalar's own tokens — and that scalar is **already published, -unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban -carves out, for third-party schema this platform does not own). Minting a -`roles` array would revive that banned word to publish information the payload -already carries. (Precisely: `check:role-word` ratchets the reserved word in -`content/docs` and `skills/` PROSE, while the identifier ban over authored -metadata lives in `packages/lint`; a TypeScript payload key trips neither -mechanically until it is documented. The ADR-level prohibition is what rules -here, not a gate that would have caught it.) A consumer that wants the -better-auth role reads `user.role`. - -**What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2); -`platform_admin` still derives from the unscoped `admin_full_access` grant with -its ADR-0091 validity window and ADR-0049 active flag intact — -`platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over -those shapes. - -⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.** -`positions.includes('platform_admin')` is the form -`resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position` -row may spell that very name — and this card is what made that reachable, by -moving `positions` onto an axis a tenant admin can write. Reading the name would -have let a tenant mint platform standing and pass the `/admin/*` mount gate. -`platform-admin-gate.ts` drops its positions leg for the same reason. -`session-platform-admin-rung-agreement.test.ts` requires the payload alias, that -gate and `hasPlatformAdminStanding` to agree, driven with such a row present and -a genuine grant as the control. - -**Upgrade.** If you gate on the better-auth role scalar, read `user.role` -instead of looking for its tokens in `user.positions`. Predicates written -against real position names, built-in identity names, or `everyone` need no -change — they start working. Deployments that stored business role names in -`sys_user.role` rather than assigning positions should assign them through -`sys_user_position` (the governed ADR-0090 D12 channel). - -A name in `sys_member.role` is still projected, **with one carve-out**: for a -session carrying NO active organization, membership names are now *added*, from -**every** membership the user holds — the resolver projects them all when no -tenant scopes it, where the old derivation contributed none. Measured on the -real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`): -`[]` before, `[org_admin, everyone]` after, pinned by -`session-positions-security-axis.test.ts`. With an active organization the -projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so -membership-derived names there are unchanged. diff --git a/.changeset/session-unbacked-org-claim-dropped.md b/.changeset/session-unbacked-org-claim-dropped.md deleted file mode 100644 index 43502e0f68..0000000000 --- a/.changeset/session-unbacked-org-claim-dropped.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/core': patch ---- - -A session whose active organization is no longer one the user belongs to now resolves with no active organization instead of that one's data. - -Under a wall-enforcing tenancy posture (`isolated` / `group`), `resolveAuthzContext` took a browser session's stored `activeOrganizationId` as the request tenant without ever comparing it to the user's current memberships — the framework's only such comparison was gated on an API-key principal. A session whose owner had been removed from an organization therefore kept reading that organization's rows and writing into it until the session expired on its own (7 days by default), including when the removal went through the product's own offboarding path. - -That claim is now vetted: if it is not in the caller's `accessible_org_ids`, it is dropped and the context resolves with no active organization at all, which the tenant wall already fails closed on (reads resolve to nothing; a tenant-scoped write is refused by ADR-0123 D2). The principal is **not** refused — a session is a person who may hold memberships elsewhere, so they stay signed in and can switch to an organization they are actually in. The API-key arm is unchanged: a key is its organization binding and is still refused outright. The wire is unchanged; the drop is reported to the operator as a single server-side `warn`. diff --git a/.changeset/session-user-language-retired.md b/.changeset/session-user-language-retired.md deleted file mode 100644 index 36d9459e7d..0000000000 --- a/.changeset/session-user-language-retired.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `SessionUser.language` — the session contract's never-produced "preferred language" (#14788, ADR-0049) - - - -**BREAKING** key removal on a published session type, landing after the -v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the -prescription is registered under protocol major 18 — `api/SessionUser:language` -in `RETIRED_KEYS_BY_MAJOR[18]` plus the D3 semantic entry -`session-user-language-retired` — where `os migrate meta` users will look). - -`SessionUserSchema.language` (`api/auth.zod.ts`) was declared -`z.string().default('en')` and described as "Preferred language", and had no -producer and no consumer anywhere: no session endpoint ever wrote it, no client -ever read it (objectui measured at its pinned sha: zero readers; the only -in-repo mentions were the schema's own unit test). A reader trusting the -published contract got a constant that was not the user's language — while the -user's real preference had just landed as the first-class column -`sys_user.locale` (#13881), which the session type could not see. Three -spellings of one concept on the published surface, none of them right. The -maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 -enforce-or-remove and make `GET /auth/me/localization` the ONE read face for -the signed-in user's language. No replacement field joins the session contract -until a session endpoint really produces one — no dual-spelling window. - -FROM → TO: - -- `SessionUser.language` / `SessionUserParsed.language` → *(removed)*. Read - the signed-in user's language from `GET /auth/me/localization` → `locale`, - which now resolves the user's own `sys_user.locale` when set → the request's - `Accept-Language` → the deployment default (`@objectstack/plugin-hono-server` - in the same release). - -One-line fix: delete the key. A producer still writing it fails `tsc` -(`never` input type) and fails to parse with this prescription; a reader still -keying on it now reads `undefined` instead of a permanent `'en'`, and should -read `locale` off `/auth/me/localization` instead. - -The retirement kit: - -- **`retiredKey()` tombstone** (the schema is a non-strict `z.object`, so a bare - delete would have stripped the key silently — ADR-0104): writing `language` - is a `tsc` error and a parse error carrying the prescription, on - `SessionUserSchema` and through both envelopes that embed it - (`SessionResponse.data.user`, `UserProfileResponse.data`). -- **ADR-0087 registration**: `api/SessionUser:language` under major 18 plus - the D3 semantic entry `session-user-language-retired`. A RESPONSE surface — - the server mints a `SessionUser`, nobody authors or persists one — so there - is no source for a D2 conversion to rewrite (the - `api/AuthFeaturesConfig:passkeys` disposition). -- **generated baselines**: `authorable-surface/api.json` carries the - `[RETIRED]` row; `authorable-defaults/api.json` drops the `= "en"` default; - `spec-changes.json`, the upgrade guide and `content/docs/references/api/auth.mdx` - regenerated. -- **pins** in `api/auth.test.ts`: the prescription on parse, absence (no default - minted) on a clean parse, both envelopes refusing the key, and a - `packages/spec/src`-scoped scan for any reader of `.language` off a - `SessionUser`. -- zero in-tree producers or readers, so no in-repo source changes ride along - beyond the endpoint change shipped with it. diff --git a/.changeset/settings-admission-tenancy-posture.md b/.changeset/settings-admission-tenancy-posture.md deleted file mode 100644 index 4b33e3e28d..0000000000 --- a/.changeset/settings-admission-tenancy-posture.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Fix: the settings REST doors now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply here — and the tenant this seam hands onward is a vetted one. - -Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran at this door, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. This gate does not merely admit the principal: it returns that tenant onward as the resolved settings tenant, so an unvetted claim became the verdict the read/write path acted on. A browser session whose stored active organization is no longer backed by a membership now has that claim dropped here too, rather than passed through. - -The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage rather than a quiet admission. diff --git a/.changeset/settings-door-value-domain-shared-predicate.md b/.changeset/settings-door-value-domain-shared-predicate.md deleted file mode 100644 index 9ce175ffdb..0000000000 --- a/.changeset/settings-door-value-domain-shared-predicate.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/service-settings": minor ---- - -fix(service-settings): the settings door answers from the ONE shared value-domain predicate, and refuses a non-member with `value_domain` (#15162) - - - -**BREAKING** for a client that branches on the refusal code. Landing inside -the launch window, so it ships as `minor` (the lockstep convention forbids -`major`); the banner is the carrier, not the bump. - -The services half of the maintainer's ruling of 2026-09-02: **one closed -vocabulary and one membership predicate shared by settings specifiers and -object fields**. The spec half declared them in `@objectstack/spec/shared`; -this package had been carrying a second copy of all three definitions since -`Specifier.valueDomain` shipped. The copies are deleted and the door now asks -`isValueDomainMember` — the call the record write path will make when the -engine half of the same ruling lands (PR #15316, still open). - -**The wire change**, measured on `PUT /api/settings/localization` with -`{"timezone": "Mars/Olympus"}`, base `a56baa2bd` vs this branch: - -| | before | after | -|:--|:--|:--| -| `fields[0].code` | `invalid_value` | `value_domain` | -| `fields[0].message` | `Default timezone must be a valid IANA time zone identifier (e.g. 'Europe/Zurich'). Received 'Mars/Olympus'.` | `Default timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")` | - -Everything else is byte-identical: HTTP 400, the envelope code -`SETTINGS_VALIDATION`, `field`, `label`, `constraint: { valueDomain: … }` and -the echoed `value`. A client that reads `constraint.valueDomain` — the -machine-readable half ADR-0114 asks it to read — is unaffected. A client that -branches on `code === 'invalid_value'` for a domain breach must move to -`value_domain`. - -Why the code moved: ADR-0114's rule is that the code is the **constraint's own -name**, the way `max_length` names the bound it breached. This branch took -`invalid_value` — the catalog's slot for "rejected for a reason no other -member names" — only while no member named a standard-domain breach. The -field-level card's spec half added one, so the slot no longer applies. The -message now renders the published catalog template -`value_domain_` in `en` — the catalog the record write path will render -from once PR #15316 lands, so the two doors under one ruling will describe one -domain in one set of words instead of each composing its own sentence. For an `encrypted` specifier the offending value is still never -echoed: the template's value placeholder takes the same mask the REST boundary -uses (`fields[0].value` stays absent, as before). - -**No value changes verdict.** The accept sets were measured, not assumed, on -the repo's Node 22 baseline (v22.22.2): - -- `iso_3166_alpha2` — the two 249-code lists diffed mechanically before either - was deleted: identical, including order; symmetric difference 0. -- `iso_4217_currency` — this one changes DEFINITION: a run-time - `Intl.supportedValuesOf('currency')` probe becomes the key set of the - checked-in CLDR snapshot `CURRENCY_FRACTION_DIGITS`. 162 codes vs 162, - symmetric difference 0 in both directions (`CHF` in both, `XYZ` in neither). - The behaviour that changes is that the verdict no longer varies with the - host's ICU build — the direction the shared module argues for. A door-level - test now re-measures it: every code the run-time probe admits must still be - admitted. -- `iana_time_zone` — the identical `Intl.DateTimeFormat` probe on both sides, - unmoved. - -A ratchet pin (`value-domains.shared-predicate.pin.test.ts`) reddens if any -non-test source in this package re-acquires a membership table, an `Intl` -enumeration probe, or a second caller of the predicate. diff --git a/.changeset/share-link-admission-tenancy-posture.md b/.changeset/share-link-admission-tenancy-posture.md deleted file mode 100644 index d66f7f7767..0000000000 --- a/.changeset/share-link-admission-tenancy-posture.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -The share-link REST surface now derives the tenancy posture before it resolves the caller, so an API key stamped with an organization its owner has left can no longer mint links into that organization. - -`resolveAuthzContext` gates every posture-conditional refusal on a `tenancyPosture` its **caller** supplies. `SharingServicePlugin`'s share-link door supplied none, so none of them ran: `organization_required` (`core/security/api-key.ts`), `organization_membership_ended` (`core/security/resolve-authz-context.ts`), and the session arm beside it that drops an `activeOrganizationId` claim no `sys_member` row backs. An API key's tenant is `sys_api_key.active_organization_id` copied verbatim — the caller's own stored claim, never vetted against current membership — so under a wall-enforcing posture (`isolated`, `group`) a key belonging to an ex-member was admitted carrying that organization, and `createLink` minted a capability token on a record inside it. The same door carried the session half: a browser session whose owner had been removed kept its organization claim until the session expired. - -Measured at the door, under `isolated`: the ex-member's key went from `200` / `201` with the link landing in the store to `401` / `401` with nothing landing; an organization-less key went from admitted to `401`; an ex-member's *session* now has its stale claim dropped and is refused by Layer 0 at `403` while staying signed in. A current member and an anonymous caller are unchanged in every wiring. - -A `tenancy` service that was **never registered** stays a supported composition and resolves quietly to "no posture" — behaviour on an embedding without `plugin-auth` is exactly what it was. A `tenancy` service that **was registered and failed to build** now raises `AuthzStoreUnavailableError`, which reaches the wire as `SERVICE_UNAVAILABLE` / 503 rather than being laundered into a `401`: admission was never decided, so it must not be answered. diff --git a/.changeset/sharing-rule-evaluation-result-grants-refused.md b/.changeset/sharing-rule-evaluation-result-grants-refused.md deleted file mode 100644 index a700304e5d..0000000000 --- a/.changeset/sharing-rule-evaluation-result-grants-refused.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `SharingRuleEvaluationResult` declares `grantsRefused?: number` — the optional seventh key the sharing-rule evaluate route already answers (#14969) - -`minor`, derived: a new key on a published contract interface is additive public -API (semver "backwards-compatible functionality"), and not `major` because the -key is **optional** — every existing `ISharingRuleService` implementer, in-tree -and out, keeps compiling unchanged, and every consumer typed against the six -counts keeps reading them. - -`POST /api/v1/sharing/rules/:idOrName/evaluate` (ledgered `sdk`, -`shares.rules.evaluate`) passes the service's return value through unfiltered, -and `@objectstack/plugin-sharing` has counted refused grants on its own subtype -since #14754 — so the wire carried `grantsRefused` while the declared client -type (`client.shares.rules.evaluate`, typed `Promise`) -could not name it without a cast. The client gains the key through its spec -import with no edit of its own. - -What the key means, and what its absence means: it counts the grants the -engine **refused** during the pass (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on -an organization-less insert into a tenant-scoped `sys_record_share`); the pass -continues past a refusal, so `grantsRefused > 0` is not a failed pass. The key -is **absent — not `0`** — from any implementation that does not count -refusals. A consumer branching on it must read "unset" as "this implementation -does not report refusals", never as "no grant was refused"; only a present `0` -says the latter. Do not `?? 0` it. - -Optional in the spec composes with the plugin-local narrowing: an -implementation that counts refusals may require the key on its own subtype -(`SharingRuleReconcilePassResult extends SharingRuleEvaluationResult`), a legal -covariant narrowing that still satisfies `ISharingRuleService`. diff --git a/.changeset/signup-existing-address-explicit-refusal.md b/.changeset/signup-existing-address-explicit-refusal.md deleted file mode 100644 index f642783331..0000000000 --- a/.changeset/signup-existing-address-explicit-refusal.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/plugin-auth": minor -"@objectstack/spec": patch ---- - -`POST /sign-up/email` for an address that already has a `sys_user` row is refused explicitly, instead of answering 200 for a row that is never written (#15587) - -**This is a wire-behaviour change on one lane**: a call that answers `200 {"token":null,"user":{…}}` today answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` after this change. Nothing is newly admitted — the response that changes is one that reported a creation that never happened. - -### What was measured - -Under audience posture `email_domain` (domain allowlisted, `selfRegistrationPermissionSet` resolvable), a sign-up for an address that already carried a `sys_user` row answered **200 with a freshly minted user id** and persisted nothing: no new `sys_user`, no `sys_account`, and the next sign-in a `401` with nothing anywhere explaining it. The same call on the same population under the `invite_only` default was refused honestly with `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`. An operator, a provisioning script or the console reading the status code concludes the account exists — and this sits directly on the recovery path a locked-out deployment walks, where widening the posture to let a seeded person register is exactly the remedy an operator is pointed at. - -### The mechanism - -better-auth's sign-up route computes `shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false` and, when it is on, answers a duplicate with a synthetic in-memory user instead of throwing. **No insert is attempted and nothing is swallowed**: the vendor's `findUserByEmail` short-circuits ahead of `createUser`, which is why no row and no credential appear. - -The posture is not itself the cause — it is only what arms the shield: a posture that permits self-registration **forces** `requireEmailVerification` on. Holding the posture constant at the `invite_only` default and moving only that flag reproduces the divergence exactly, which also means the defect was never confined to the widened postures: `emailAndPassword.autoSignIn: false` arms the same shield under any posture. - -### The fix - -The uniqueness refusal is raised on the `/sign-up/email` before-hook, the same seam and the same reason the audience-posture refusal is already raised there, and built from better-auth's own `BASE_ERROR_CODES` entry so both lanes answer byte-identically. - -**Order is load-bearing: it runs only for a caller the posture already admitted.** Asking uniqueness first would hand an uninvited stranger an account-existence oracle under the `invite_only` default (422 for a real address versus 403 for an unknown one). After the gate, `invite_only` is untouched — a stranger still gets `SELF_REGISTRATION_CLOSED` and learns nothing. - -**Operators of `open` / `email_domain` should know what the honest refusal costs:** on those postures a caller the audience gate admits can now distinguish an address that has an account from one that does not, where the synthetic 200 previously hid it. That is the disclosure the `invite_only` lane has always made to an invitation holder, and the platform's answer for a widened posture is now the same fact rather than a false receipt. - -`USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` is registered in the ADR-0112 error-code ledger under `@objectstack/plugin-auth`: the platform now **emits** it rather than only passing it through, and an emitted-but-unregistered code is the silent fourth state that ledger exists to prevent. diff --git a/.changeset/single-kernel-tenancy-posture-provider.md b/.changeset/single-kernel-tenancy-posture-provider.md deleted file mode 100644 index 684f6b19c1..0000000000 --- a/.changeset/single-kernel-tenancy-posture-provider.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/rest": patch -"@objectstack/core": patch ---- - -fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200 - -Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an -organization its owner is no longer a member of **read and wrote that -organization's rows** on the wiring the open core actually builds. Not a silent -empty set — a GET that returned the other organization's records, and a POST -that landed a row read back from the store carrying that organization's id and -the ex-member as its creator. An organization-less key on the same deployment -read `200` with an empty set, which is the silent failure the wall exists to -replace. - -The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the -effective tenancy posture from a per-request kernel, and on the single-kernel -wiring there is no per-request kernel — so the posture was `undefined` on every -request, and both posture-conditional API-key refusals are gated on it: -`organization_required` in `api-key.ts` and `organization_membership_ended` in -`resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was -active the whole time; it compares against the caller's active organization, -and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim -— the holder's own stored claim. Enforcing the wall is what let the ex-member -through, because the one fact that would expose the ended membership was not an -input to the layer that could act on it. - -The single-kernel branch now derives the posture from a provider `rest-api-plugin` -wires to the lone local kernel's `tenancy` service, in the same shape as the -auth-service provider beside it. A host that registers no `tenancy` service is -unchanged and still admits: there is no wall on such a deployment, so there is -nothing for an organization-less key to be walled out of. A `tenancy` service -that was registered and **failed to build** is an outage and answers `503`, not -an admission — a posture that could not be read is not a posture that is absent. - -Refusals are now also said out loud on the server side, at `warn`, where each -one is decided: the key's row id (never the credential or its hash), the -principal, the organization and the reason. **The wire is unchanged** — both -refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the -body, so a holder of someone else's key learns nothing a plain 401 does not -already tell them. The operator, who previously had a key that was neither -revoked nor expired and a 401 that said nothing, now has a line to find. - -Behaviour that does not move: a current member's key on the same route still -returns its rows and still writes; a request with no credential still answers -401; and an unknown, revoked or expired key is not a refusal at all, so a key -scanner produces no log volume. diff --git a/.changeset/sourcemap-drop-sources-content.md b/.changeset/sourcemap-drop-sources-content.md deleted file mode 100644 index fa01c785e2..0000000000 --- a/.changeset/sourcemap-drop-sources-content.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/cli": patch -"@objectstack/core": patch -"@objectstack/types": patch -"@objectstack/metadata": patch -"@objectstack/metadata-core": patch -"@objectstack/metadata-fs": patch -"@objectstack/metadata-protocol": patch -"@objectstack/objectql": patch -"@objectstack/runtime": patch -"@objectstack/lint": patch -"@objectstack/platform-objects": patch -"@objectstack/plugin-auth": patch -"@objectstack/plugin-webhooks": patch -"@objectstack/service-cluster": patch -"@objectstack/service-cluster-redis": patch -"@objectstack/service-datasource": patch -"@objectstack/account": patch -"@objectstack/setup": patch -"@objectstack/studio": patch ---- - -Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. - -`sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. - -`@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. - -A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. diff --git a/.changeset/spec-comparand-door-array-parenthetical.md b/.changeset/spec-comparand-door-array-parenthetical.md deleted file mode 100644 index 4d13b62f6d..0000000000 --- a/.changeset/spec-comparand-door-array-parenthetical.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -docs(spec): the comparand door's array parenthetical states what the drivers do today - -`filter-comparand-type.ts`'s list of the cases its door deliberately does not rule described the array cell as "`driver-sql` refuses it with its own message; the document stores give it array-equality semantics". Measured on this tree, that second clause is no longer a true statement about `@objectstack/driver-memory`: its reference matcher compared an array comparand by REFERENCE (matching nothing), while its live query path deep-equalled it (matching the row) — one package, two answers, neither of them a stable "array-equality semantics" a reader could build on. With that driver's cell now refused, the sentence names the two dispositions that exist: `driver-sql` and `driver-memory` refuse it, each with its own message, and `driver-mongodb` hands it to MongoDB and inherits that engine's array semantics. - -The paragraph's point is unchanged and deliberately kept: the door does not rule this position, the matrix did not measure it, and it is left to the layers that already answer it. Only the description of what those layers do is corrected — a stale factual clause is how the next reader re-derives "the spec promises array-equality" from a passage that explicitly promises nothing. The neighbouring pass-through pin's comment carried the same stale characterisation and is corrected the same way; its assertion (that `parseFilterAST` leaves `{ tags: ['a','b'] }` untouched) is unchanged and still pins the door not judging. diff --git a/.changeset/spec-compose-key-dispositions-export.md b/.changeset/spec-compose-key-dispositions-export.md deleted file mode 100644 index dcf6c878b1..0000000000 --- a/.changeset/spec-compose-key-dispositions-export.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): export `COMPOSE_KEY_DISPOSITIONS` and `STACK_DEFINITION_KEYS` — the artifact envelope's top-level key set and each key's composition rule, derivable from one source instead of hand-copied per consumer (#14877) - -`minor`, additive: two new named exports and two new exported types on the -root entry; nothing renamed, narrowed or removed. Every existing import keeps -compiling and every behaviour of `composeStacks` is unchanged — the table it -reads is the same object, now frozen and public. - -- `COMPOSE_KEY_DISPOSITIONS` — a frozen, read-only record from every top-level - key `ObjectStackDefinitionSchema` declares (`manifest`, `packages`, - `requires`, `objects`, … `onEnable`) to its composition rule: `'concat'` - (an array collection, concatenated in stack order), `'single'` (identical - declarations pass through, differing ones refuse naming the key), - `'manifest'` (picked by the `manifest` option), `'objects'` (the - `objectConflict` strategy) or `'functions'` (merged by handler name). - Literal-typed, so `(typeof COMPOSE_KEY_DISPOSITIONS)[K]` is K's disposition, - not the union. -- `STACK_DEFINITION_KEYS` — the top-level key set, derived from that table by - `Object.keys` (never a second literal), frozen. -- `StackDefinitionKey` and `ComposeDisposition` — the key union and the - disposition union, for a consumer that types its own seam against them. - -Why: the collection half of this key set was already derivable downstream -(`PLURAL_TO_SINGULAR`, `METADATA_ALIASES`), but the non-collection keys — -`manifest`, `requires`, `packages`, and whatever comes next — had to be -hand-copied by every consumer that walks an artifact's top level, and that -copy drifted silently twice: objectstack-ai/cloud#897 (`roles` → `positions` -dropped every hosted `positions[]`) and objectstack-ai/cloud#1888 (`packages[]` -dropped by an artifact merge, recreating downstream the duplicate-ownership -state #14599 had repaired at the door). A seam that derives its key set from -`STACK_DEFINITION_KEYS` — and asks `COMPOSE_KEY_DISPOSITIONS[key] === 'concat'` -whether a key may be concatenated across artifacts — picks up the next key -(#14865's `grantedPermissions`) the day the schema declares it, with no edit of -its own. - -Pinned (`compose-key-dispositions-export.pin.test.ts`): the exported key set -equals `ObjectStackDefinitionSchema`'s declared top-level key set in both -directions, the view is frozen, every value is a declared disposition, every -`'concat'` key is what `composeStacks` concatenates and every `'single'` key is -what it passes through or refuses, and the key list is `Object.keys` of the -table. diff --git a/.changeset/spec-export-object-refinement-checks.md b/.changeset/spec-export-object-refinement-checks.md deleted file mode 100644 index 2f3c1453c6..0000000000 --- a/.changeset/spec-export-object-refinement-checks.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): export the object-level refinement checks the mirrored UI schemas run — `checkListViewPageMount`, `checkListViewCalendarVisualization`, `checkPageSourceCompleteness`, `checkGlobalFilterDateDefaultValue` (#16489, the spec half of objectui#7715) - -objectui derives its zod schemas from the spec's `.shape` (`specFieldsExcept(SpecListViewSchema.shape, …)`, six sites at the pinned build). That carries the spec's FIELDS by reference and drops every check attached to the spec OBJECT (`superRefine` / `refine`), so at 17.3.0 objectui's authoring door accepted `appearance.allowedVisualizations: ['calendar']` with no `calendar:` block while the spec's publish door refused it. Contract-first: the rule is written once, in the spec, and a mirror attaches that same rule instead of re-implementing it. - -Every spec object that carries an object-level refinement and is mirrored downstream now exports its check as a named, typed function (`(value, ctx: z.RefinementCtx) => void`) from `@objectstack/spec/ui`, alongside the schema — one function per refinement, no bundled "all checks" blob, so a mirror attaches exactly the ones whose fields it carries: - -| Schema | Export | Refuses | -|:--|:--|:--| -| `ListViewSchema` | `checkListViewPageMount` | `type: 'page'` with no `pageName`; `pageName` on a view that is not `type: 'page'`; a page mount declaring `columns` | -| `ListViewSchema` | `checkListViewCalendarVisualization` | `'calendar'` in `appearance.allowedVisualizations` with no `calendar:` block | -| `PageSchema` | `checkPageSourceCompleteness` | an `html` / `react` / `jsx` page with no non-empty `source` | -| `GlobalFilterSchema` | `checkGlobalFilterDateDefaultValue` | a `type: 'date'` filter whose `defaultValue` is neither a preset name, an ISO date, nor a date-macro token | - -The population is measured from the schemas themselves (`_zod.def.checks`) against the six objectui derivation sites: `ListViewSchema` (2 checks), `PageSchema` (1) and `GlobalFilterSchema` (1 — mirrored by a `.shape` spread rather than `specFieldsExcept`) carry object-level refinements; `NavigationAreaSchema`, `AppSchema`, `DashboardWidgetSchema` and `DashboardSchema` carry none, so nothing is exported for them. The two `ListViewSchema` checks were already named module-private functions and are now exported; the `PageSchema` and `GlobalFilterSchema` checks were inline `superRefine` bodies, extracted verbatim into named functions the schema now attaches by identifier. - -Additive, and the schemas are unchanged: every schema attaches the very function it exports, so no accept set moves — every ListView, Page and GlobalFilter document that parsed before parses identically, with identical issues. `minor` because four new symbols land in the published `dist/*.d.ts`. Each export is pinned to be the check its schema runs (`object-refinement-check-exports.test.ts`: parity over every failure path between the direct call, the schema's own check object and the schema's parse; the schema carries exactly as many checks as are exported for it; the module attaches each by identifier). Attaching them at the derivation sites is the objectui half, objectui#7715. diff --git a/.changeset/spec-i18n-default-locale-authored-label.md b/.changeset/spec-i18n-default-locale-authored-label.md deleted file mode 100644 index c5239cbbd1..0000000000 --- a/.changeset/spec-i18n-default-locale-authored-label.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711) - - - -**BREAKING** (launch-window convention: ships as `minor`; this entry is the signal) — the second facet of the #15711 ruling moves a published default of the `@objectstack/spec/system` label resolvers. A caller that passes no `fallbackChain` used to get a literal `['en']`; it now gets `[]`, "requested locale, then the authored label". Nothing silently falls to `en` because a literal said so: a chain is consulted only when someone declared it. In this repo the blast radius is zero production callers (the REST serving layer has declared its chain since #14882; one pin flips); out-of-repo hosts unmeasured. A host that relied on the implicit `en` declares it as `fallbackChain: ['en']`. - -## The ruling (#15711, recorded 2026-09-05) - -A workspace that authors its metadata labels in its default locale (`i18n.defaultLocale: 'zh-CN'`, inline `label: '填报单'`) and ships a courtesy `en` bundle used to serve `Entry Sheet` to a `zh-CN` request whenever its declared chain named `en` — a reflexive `fallbackLocale: 'en'` in an AI-authored config was enough. `os i18n check` already counted the authored text as the default locale's coverage; the runtime did not. Ruled A: **the authored label IS the default locale's text**. - -- `ResolveOptions` gains an optional `defaultLocale?: string` — the deployment's default locale, the language its labels are authored in. When the requested locale names it (BCP-47 tags compare case-insensitively, the same rule `resolveBundleLocale` applies), the resolvers consult the requested locale's own bundle and then answer with the authored label; the fallback chain is not walked. -- `fallbackChain` keeps its full meaning for every non-default request: a `fr` request still walks the `fr` bundle, then the declared `en` bundle, then the authored label. -- A bundle entry for the default locale still wins when one is shipped, so `os i18n extract --locales=zh-CN` keeps working — optional now, not required. -- `II18nService.getDefaultLocale()` documents that it is also what the serving layer threads into `ResolveOptions.defaultLocale`; `@objectstack/rest` passes it through its single `translateOptionsFor` seam (that package's own changeset). - -Unchanged: `os i18n check`; both boot paths (`os serve` and the dev plugin still collapse the declaration to `fallbackLocale || defaultLocale || 'en'` before constructing the service); every request whose locale is not the default. - -Not taken, ruled out on the card: the rule living only in `packages/rest` (every other host would re-implement it and spec could not pin it); requiring every supported locale to ship a bundle (a generated bundle that duplicates the app's own source text, the stale-translation class already closed); documenting the divergence. diff --git a/.changeset/spec-notification-event-migration-id.md b/.changeset/spec-notification-event-migration-id.md deleted file mode 100644 index 786e4da2a7..0000000000 --- a/.changeset/spec-notification-event-migration-id.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`@objectstack/spec/system` now names the ADR-0030 notification cut-over, so "has this deployment run it?" has a place to be answered. - -`sys_migration` is the ledger a deployment writes to record that a data migration ran against its own database, and consumers read it instead of the platform version. Its well-known ids were `adr-0104-file-references` and `adr-0104-value-shapes` — the two ADR-0104 scans, both driven by an `os migrate` command that records the row. `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) had none. It is destructive and one-way, operators are handed the call verbatim in `docs/handoff/adr-0030-notification-convergence.md`, and it recorded nothing when it ran: a deployment that performed the cut-over and one that never did are indistinguishable from the ledger. A row can only be keyed by an id, so without one the question had nowhere to be answered even in principle. - -Added: `NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'`, exported from `@objectstack/spec/system`. Purely additive — no existing export, schema or predicate changes, and nothing reads the new id yet. - -Deliberately NOT decided here, and the constant's docblock says so rather than leaving its silence to be read as an answer: what a `sys_migration` row under this id means. The two ADR-0104 ids get their `last_run_at` / `applied_at` / `verified_at` / `blocking` semantics from a command that scans, self-checks and only then records; this migration has no command and no self-check, and reports `migrated` / `already_done` / `not_applicable` / `error` to its caller instead. Which of those columns one of its runs may claim, whether anything may gate on the row, and whether a datastore created after the cut-over belongs in `CREATION_ATTESTED_MIGRATION_IDS`, are contract questions on this surface and are left open. diff --git a/.changeset/spec-preset-comparand-message-tsdoc.md b/.changeset/spec-preset-comparand-message-tsdoc.md deleted file mode 100644 index 8b543ce70f..0000000000 --- a/.changeset/spec-preset-comparand-message-tsdoc.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`bareDateRangePresetComparandMessage`'s TSDoc — published in `dist/*.d.ts` — now states both moments the wording is reported at: the field-agnostic schema door in `data/filter.zod.ts` (ordering positions only: without a field type, equality on a select column is legitimate) and `@objectstack/lint`'s `filter-preset-comparand` rule, which with the field type in hand refuses every comparand position on a declared `date` / `datetime` field (#16106). The message text itself is unchanged. diff --git a/.changeset/spec-prose-carriers-state-the-shipped-contract.md b/.changeset/spec-prose-carriers-state-the-shipped-contract.md deleted file mode 100644 index 9145bc2e3a..0000000000 --- a/.changeset/spec-prose-carriers-state-the-shipped-contract.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Three published prose carriers in `packages/spec` now state what the tree does, instead of a premise the same package's own code contradicts. No predicate, schema arm, key or export moves; every accept set is byte-identical. - -All three reach consumers: `@objectstack/spec` ships `src/**/*.zod.ts` in its published `files[]`, so a TSDoc comment in one of these files is a published byte, and one of the three is a `.describe()` that additionally lands in the JSON Schema and the generated reference page. - -- **`FILE_REFERENCE_TYPES` said the stored schema "deliberately admits both until D3 lands".** ADR-0104 D3 wave 2 landed: `valueSchemaFor` returns `FileReferenceIdValueSchema` ALONE for `form === 'stored'` and the id-or-object union only for `'expanded'`, and `field-value.test.ts` pins both directions. The docblock now states the shipped contract — the stored value is an opaque `sys_file` id, the inline metadata object is the expanded READ form — and names where legacy stored values are actually admitted: the warn-first ADR-0104 value-shape path in `@objectstack/objectql`'s record validator, never this schema. The sentence promised a future that had already arrived, which is exactly how it kept reading as current. -- **`CreateManyDataResponseSchema.droppedFields` justified its aggregated shape with "schema-uniform, so every row drops the same set".** Ruling C moved the static-`readonly` strip INSIDE `engine.insert`, after the `beforeInsert` hooks, and exempts keys a hook itself wrote — tracked per row (`rowHookWrittenKeys`). A hook that stamps a protected key on some rows and not others therefore makes those rows drop different sets, so the premise is false. The shape is unchanged and stays right for the reason the producer already gives: this response is `{ object, records, count }` and has no per-row slot to hang a drop set on, so a union is the only view it can represent. The description now says that, and tells a reader how to read a name in the array — "at least one row dropped this field", not "every row did". Callers needing row precision still use the per-row `insertMany`/`batch` results. -- **`FieldReferenceSchema`'s `@example` spelled the #14104 shape as the relation path `duty.grace_days`.** Nineteen lines below it, in the same block, the Execution support prose states that SQL push-down compiles same-table columns only and refuses a dotted path with `INVALID_FILTER`. An author copying the example gets a filter that passes in memory — `matchesFilter` walks dot paths — and 400s on SQL. The example is now the same-table spelling both execution paths compile, matching the wording the hand-written query-syntax guide already publishes; the block's own prose remains the statement of what a dotted path does. diff --git a/.changeset/sql-driver-init-objects-indexes-param.md b/.changeset/sql-driver-init-objects-indexes-param.md deleted file mode 100644 index 79c3dd097f..0000000000 --- a/.changeset/sql-driver-init-objects-indexes-param.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/driver-sqlite-wasm": minor ---- - -`SqlDriver.initObjects()` and `SqlDriver.registerObjectMetadata()` now declare the `indexes` key they have always read. - -Both entry points took `Array<{ name; fields?; tenancy? }>`, with no `indexes` in the type. The key was read out of those very objects one call deep anyway, through an `as any`, in `registerManagedObjectMetadata` — and the map it fills, `managedObjectIndexes`, is what `syncDeclaredIndexes` renders every declared UNIQUE from. So the driver's whole index-sync path was driven by a key its own signature said did not exist, while the sibling `detectManagedDrift` on the same class had always declared `indexes?: any[]`: the two halves of one class disagreed about the shape of the same input. - -That is the shape #4311 already addressed for `tenancy`, one key over, and the comment it left above `initObjects` described `indexes` word for word. - -**Why nothing tripped over it.** TypeScript's excess-property check fires on a fresh object literal and not on one bound to a variable first, so the same object was accepted or rejected by nothing but where it was spelled — `await driver.initObjects([{ ...bare, indexes: [] }])` was rejected with TS2353, `const o = { ...bare, indexes: [] }; await driver.initObjects([o])` was accepted, and the index was synced either way. Every caller happened to bind first, so the package typechecked green for a reason unrelated to correctness. - -**Why this matters beyond a compile error.** The loud symptom was a rejected correct call. The quiet one is the reachable branch: an author — or an AI — reading the signature concludes `indexes` is not accepted and drops the key, and a declared UNIQUE is then never synced, with no error at authoring time and no error at boot. The schema says those rows cannot collide; they can. - -What changed, all inside `SqlDriver`: - -- `registerObjectMetadata(objects)`, `initObjects(objects)` and the shared `registerManagedObjectMetadata(obj)` helper each gained `indexes?: any[]`, spelled exactly as `detectManagedDrift` already spells it. -- Every `(obj as any)` cast reading `indexes` off those parameters is gone — the one at the `managedObjectIndexes.set` site and the two inside `initObjects`' own create/alter path. The cast was the evidence that the declaration and the read disagreed; leaving any of them would have fixed the signature while keeping the "the type does not admit me but I read it anyway" path alive. That path is now closed on this parameter. - -**What the accept set does, precisely — it moves in both directions.** For a **fresh object literal**, which is what an author writes and what the excess-property check judges, this is purely a widening: `{ ...bare, indexes: [...] }` was rejected and is now accepted. For a **variable-bound** argument, which bypasses that check and is judged by ordinary assignability, it is a narrowing: `indexes` spelled as a record, as a `readonly` tuple (`as const`), or as `null` compiled under the previous signatures and is now rejected with TS2322. Measured in both directions, all three shapes, on this package's own `tsc`. - -That narrowing is deliberate, and the three shapes did **not** all behave the same way before it — the difference is worth stating exactly, because only one of them ever worked: - -- A **record** and **`null`** never survived the `Array.isArray(obj.indexes)` guard the driver has always applied. That author got no index and no diagnostic — silently, at run time. Rejecting those two at compile time is precisely the failure this change exists to make impossible. -- A **`readonly` tuple (`as const`)** is a different case, and the only one with anything to lose. `as const` is type-only: at run time the value is a plain array, `Array.isArray` returns `true`, and the index **was** synced. That caller compiled and worked, and is now rejected at compile time. Nothing about its run-time behaviour changed — the rejection is entirely on the type surface. - -No migration is owed even so. No caller in this repository is affected, and the shape could never have reached `detectManagedDrift` on the same class either, which publishes the very same `any[]` spelling for the very same key — so a `readonly` caller was already unable to use half of this driver's declared-index surface. A caller in that position spells the array without `as const`, or widens it at the call site. - -The disposition on that corrected ground, recorded here because the ground itself moved: **no `BREAKING` banner and no ADR-0087 disposition**, resting on grounds (i) and (iii) alone — zero affected callers, and the `any[]` spelling already published on `detectManagedDrift` for the same key on the same class. The ground that every newly rejected shape had already been discarded at run time is **not** among them: it is false for the `readonly` tuple, and nothing here leans on it. - -`@objectstack/driver-sqlite-wasm` is named because `SqliteWasmDriver extends SqlDriver` and overrides neither method, so both widened signatures land in its own published `.d.ts` and its consumers see the identical change. The two packages are in the same fixed version group, so this is a CHANGELOG effect rather than a version one. - -The `IDataDriver` contract itself did not move: `registerObjectMetadata?(schemas: unknown[])` in `@objectstack/spec` already accepted `unknown[]`, and `SqlDriver` narrowed it on its own. What grew is `SqlDriver`'s own published accept set. diff --git a/.changeset/stack-cross-reference-refusal-envelope.md b/.changeset/stack-cross-reference-refusal-envelope.md deleted file mode 100644 index 98d2f78249..0000000000 --- a/.changeset/stack-cross-reference-refusal-envelope.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/runtime": patch ---- - -fix(spec): `defineStack`'s cross-reference refusal carries an ADR-0112 envelope, so the five REFUSED ADR-0130 item classes are machine-readable (#14552) - -`validateCrossReferences` — reached through `defineStack` — refuses a stack whose items name an object the stack does not define. That refusal was `new Error(message)` with `code` and `status` both `undefined`, so all five REFUSED item classes of the ADR-0130 matrix (action `objectName`, view `data.object`, permission-set `objects`, seed dataset `object`, import mapping `targetObject`) plus the `hooks[].object` rule (#14122 §4 rule R4) were distinguishable only by MESSAGE TEXT. It now throws `StackCrossReferenceError`, carrying `code: 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, and one entry per finding in `issues`. The message text is byte-for-byte unchanged: this adds fields rather than rewriting a sentence, and five message-substring pins in the tree read that prose. - -ADR-0112 makes `code` / `status` the machine-readable half of every refusal. Without them `os validate`, `os build` and any AI author reading the refusal could only pattern-match prose — the fragile shape the envelope exists to remove, made worse here because the message had already become load-bearing for those pins. - -Why ONE code rather than five: there is exactly one raise site. `validateCrossReferences` returns every finding as a `string[]` and `defineStack` throws the collected set at once, so a single refusal can carry findings from several classes together and a per-class code would have to pick one of several true answers. The classes stay machine-readable in `issues`. The family is also wider than "undefined object" — the same aggregate carries the duplicate-action-key, global-`update`-action and mapping `javascript`-transform findings — so a `…_UNDEFINED_OBJECT` spelling would have been false for those. - -Not narrowed, not widened: no accept-set changes and no export changes. `defineStack` accepts and refuses exactly the inputs it did before, and `StackCrossReferenceError` is deliberately module-local — `packages/spec/src/index.ts` re-exports that module with `export *`, so exporting the class would widen the published api-surface of the contract package, and the ADR-0112 contract is the `code` / `status` fields, which every reader reads structurally rather than by `instanceof`. No ledger registration either, for the same reason its two precedents (`ObjectOwnershipConflictError` #14367, `NamespaceConflictError` #14474) carry none: no wire door raises it. `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it. - -`@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `boot-refusal`, door `none` — the measured verdict, not the expected one). diff --git a/.changeset/stack-refusal-envelopes.md b/.changeset/stack-refusal-envelopes.md deleted file mode 100644 index 46d116d221..0000000000 --- a/.changeset/stack-refusal-envelopes.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/runtime": minor ---- - -fix(spec): every `defineStack` refusal carries an ADR-0112 envelope — six new `STACK_*` codes beside `STACK_CROSS_REFERENCE_INVALID` (#15963) - -`defineStack` has seven refusal sites. After #14552 one of them — the cross-reference refusal — carried `code` / `status`; the other six still threw `new Error(message)` with both `undefined`. A consumer that had learned to branch on `error.code` from the cross-reference refusal read `undefined` from its six neighbours, which reads as "not a validation refusal" rather than "a refusal with no code yet" — the silent-tolerance shape ADR-0112's envelope exists to remove. Every site now throws an envelope, `status: 422`, one code per refusal, the findings the site collected on `issues`: - -| Refusal (header text, unchanged) | Raiser | `code` | -|---|---|---| -| `defineStack validation failed` | `ObjectStackDefinitionSchema.safeParse` | `STACK_SCHEMA_INVALID` | -| `defineStack capability validation failed` | `validateKnownCapabilities` | `STACK_CAPABILITY_UNKNOWN` | -| `defineStack cross-reference validation failed` | `validateCrossReferences` | `STACK_CROSS_REFERENCE_INVALID` (#14552, unchanged) | -| `defineStack namespace-prefix validation failed` | `validateNamespacePrefix` | `STACK_NAMESPACE_PREFIX_INVALID` | -| `defineStack single-app validation failed` | `validateSingleApp` | `STACK_SINGLE_APP_VIOLATION` | -| `defineStack hierarchy-scope capability validation failed` | `validateHierarchyScopeCapability` | `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` | -| `defineStack trigger capability validation failed` | `validateTriggerCapability` | `STACK_TRIGGER_CAPABILITY_REQUIRED` | - -Message text is byte-for-byte unchanged at every site — this adds the machine-readable half, it does not reword a sentence; the message pins across the tree still read the prose they always did. One code per site rather than one shared `STACK_VALIDATION_FAILED`: the dispatcher vocabulary's `boot-refusal` class was already at one-row-per-refusal granularity (14 rows), and `STACK_CROSS_REFERENCE_INVALID` is an instance of that granularity, not an exception to it. - -The schema arm was judged separately rather than copied from the five semantic cross-checks, because it is an aggregate of zod issues against the schema the stack declares, not a rule evaluated on a parsed stack. The reading: `@objectstack/spec` has no zod-failure envelope to reuse (`formatZodError` / `safeParsePretty` return prose); the two zod-shaped refusals the ledger already carries are both spelled `*_SCHEMA_INVALID` — `METADATA_SCHEMA_INVALID` (metadata-core's `SchemaValidationError`, the `issues`-carrying precedent; nothing in the tree assigns it a status) and `FLOW_INPUT_SCHEMA_INVALID` (answered 422 by the runtime's flow-dispatch table) — and the zod-shaped refusal `metadata-protocol` actually stamps at 422 is `INVALID_METADATA`; the two other channels a zod failure travels on — `400 VALIDATION_ERROR` (request syntax) and `VALIDATION_FAILED` + `fields[]` (record validation, duck-typed on `name === 'ValidationError'`) — would each file an authored stack as something it is not. So it is its own code at 422, and its `issues` carries the zod issues structurally (path, code, message per entry) rather than the formatted lines the message already renders. `issues` is therefore heterogeneous across the seven: strings for the six semantic refusals, zod issue objects for the schema arm; a reader branches on `code` first. - -Not narrowed: `defineStack` accepts and refuses exactly the inputs it did before, and no export changes — the error classes stay module-local, as `StackCrossReferenceError` did, because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112 contract is the `code` / `status` pair read structurally. All seven are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec` — the six new codes and the `STACK_CROSS_REFERENCE_INVALID` precedent alike — not by this change but by #16449 (PR #16652), which landed after this changeset was written and registered them under the #16404 ruling that every code shipped in `dist` is registered, door or no door. That registration records the same reachability reading this change measured rather than contradicting it: no wire door raises them — `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it (re-measured: every non-test `defineStack` occurrence under `packages/runtime/src` and `packages/rest/src` is a docstring, a comment or the vocabulary table's own prose). - -**Why `minor`, not the `patch` the #14552 precedent took.** Six new `STACK_*` spellings ship in `packages/spec/dist/index.js` and `dist/index.mjs`; nothing reaches `.d.ts`, but once shipped a consumer's `catch (e) { switch (e.code) … }` depends on them and they cannot be renamed without breaking it. That is a purely additive widening of a published package's public surface, and the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) requires at least `minor` for it: the commit type may raise a bump but never lower it below what the act requires, so a `fix(` that widens the surface is `minor`. The `patch` precedent is pre-rule; this level is required by that ruling, not chosen by taste. - -`@objectstack/runtime` carried one classification row per new code in the dispatcher error-code vocabulary (`door: 'none'`, `verdict: 'boot-refusal'` — the measured verdict) when this changeset was written, and carries none now: the same #16449 (PR #16652) landing that registered the seven codes ratcheted all seven rows back out of `packages/runtime/src/dispatcher-error-vocabulary.ts`, because a registered code drops out of that gate's scan and the reachability each row recorded now lives on its ledger row. `pnpm check:dispatcher-error-vocabulary` still reconciles sites and rows in both directions, and under the same ruling it now refuses any verdict but `foreign-vocabulary` / `runtime-pinned` for a stamp site under `packages/spec/src/**` — so these seven cannot take a `boot-refusal` row again. diff --git a/.changeset/storage-file-read-tenancy-posture.md b/.changeset/storage-file-read-tenancy-posture.md deleted file mode 100644 index 73289f24a7..0000000000 --- a/.changeset/storage-file-read-tenancy-posture.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -The storage download door derives the tenancy posture before resolving the caller - -`buildFileReadAuthorizer` resolved every gated download with `resolveAuthzContext({ ql: engine, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran at this door. Its headers come from the real request, so `x-api-key` is accepted, and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim: the caller's own stored claim, never vetted against current membership. Under a wall-enforcing posture a key stamped with an organization its owner had left therefore authenticated for downloads and was judged by the ownership and record-reachability checks — checks evaluated for a principal the wall should have refused at the door. - -The posture is now read off the kernel's `tenancy` service, per download, and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Under `isolated` and `group` an ex-member's stamped key is now refused and no download capability is minted; an organization-less key is refused under `isolated` and stays admitted under `group`, whose union scope makes it legitimate. Under `single` nothing changes. Patch rather than minor: no accept set widens, and a declared guard returns to enforced. diff --git a/.changeset/storage-routes-host-mount.md b/.changeset/storage-routes-host-mount.md deleted file mode 100644 index 449e045a0f..0000000000 --- a/.changeset/storage-routes-host-mount.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-storage": minor ---- - -feat(storage): `mountStorageRoutes` — mount the storage routes on a host-owned HTTP surface, composed from a kernel that has no `http-server` service (#15169) - -`StorageServicePlugin` mounts `/api/v1/storage/*` itself, at `kernel:ready`, on the kernel's `http-server` service. A hosted per-environment tenant kernel registers no such service, so the storage service, `sys_file`, the lifecycle hooks and the reap guards were all present while every `/api/v1/storage/*` request answered 404 — an app with an attachment field could not upload. The settings service already had a working host bridge because `registerSettingsRoutes` and everything it needs are public; storage could not be bridged the same way because `registerStorageRoutes` needs three package-internal seams: the upload session resolver, the ADR-0104 D3 download authorization gate, and the tombstone holder predicate. - -**New export: `mountStorageRoutes(http, kernel, options?)`** (with `MountStorageRoutesOptions`, `StorageRouteKernel`, `StorageRoutesMountReport`). One entry point that takes the host's `IHttpServer`-shaped surface and the environment kernel, binds the three seams from that kernel's own `auth` service and data engine, and registers the full route table — the composition the plugin's own mount now calls too, so a host's storage door and the plugin's are one code path. The options carry wire knobs only (`basePath`, `presignedTtl`, `sessionTtl`, `downloadTtl`, `logger`): the three gate seams are not accepted in any form, so a consumer cannot substitute, omit or bypass the download gate, and the platform keeps exactly one definition of it. The return value reports which gates bound, as booleans. A kernel with no `storage` service throws naming the remedy; a kernel with no `auth` service or no data engine mounts with the matching gate off and warns — the plugin's existing bare-kernel behaviour, said out loud. - -Deliberately NOT published: `buildAuthSessionResolver`, `buildFileReadAuthorizer` and `findFileHolder` stay package-internal. The narrower surface serves the one consumer that exists (a host mounting the door) and is easier to walk back than three loose functions. - -Nothing existing changes shape or behaviour: `registerStorageRoutes` and `StorageRoutesOptions` are untouched, and `StorageServicePlugin` mounts exactly what it mounted before. diff --git a/.changeset/strand-verdict-survives-bookkeeping-throw.md b/.changeset/strand-verdict-survives-bookkeeping-throw.md deleted file mode 100644 index 06fd07ab70..0000000000 --- a/.changeset/strand-verdict-survives-bookkeeping-throw.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -Keep a resume's `status: 'stranded'` verdict when the bookkeeping after the repair journal throws. - -`resumeInternal`'s catch arm journals the consumed suspension — the snapshot `restoreConsumedSuspension` puts back — and only then stamps `status: 'stranded'`. Two statements sat between them and could throw out of the whole arm: `recordLog`'s terminal run-summary line, and a store whose `recordTerminal` throws synchronously (the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection). `failAncestors` follows them. - -A throw in that window left the run genuinely repairable while the verdict never shipped, and every consumer derives repairability from the verdict — `plugin-approvals` computes its operator-facing `repairable` as `status === 'stranded'` — so the approvals decision door reported `repairable: false` about a run that `restoreConsumedSuspension` answers `restored: true` for. That is a false negative on a repair instruction: it tells an operator not to attempt a repair that works. - -The window is now guarded. The bookkeeping may still fail — and says so loudly, at `error`, naming the run, what did not land, and the verb that repairs the strand — while the verdict still ships. Measured: with a store whose terminal write throws, `resume` now returns `{ success: false, status: 'stranded' }` instead of throwing, the door reports `repairable: true`, and the repair verb succeeds on that same run. - -The guard opens **after** the journal, so only a run that demonstrably has a snapshot can reach the stamp: a throw from the journal itself still propagates, every exit above the consumption point still carries no status at all, and cascade-failed ancestors — which journal nothing — are untouched and still correctly non-repairable. - -⚠️ This change also makes a pre-existing fault **visible** rather than creating it. The completion path's history write sits inside the same `try` as the node-failure arm, so a run that **completed** — every node succeeded — is journalled and reported `stranded` when its `completed` history row throws, and repairing such a run **re-runs the flow**. That phantom, its repair snapshot and the double run were all measurable before this change; what changes here is only that more store failures now report the verdict instead of throwing over it, so an operator can now be told to repair a completed run. Filed as #15944, with the measurement on both trees. - -⚠️ `repairable` remains a point-in-time fact, and this change does not make it durable: the run in the case above has no terminal history row (that write is what failed), so the repair rides on the in-memory journal and a restart loses it. The verdict reports what an operator can do now, which is exactly what was being denied. diff --git a/.changeset/stranded-inspection-undifferentiated-rows.md b/.changeset/stranded-inspection-undifferentiated-rows.md deleted file mode 100644 index b0c87cd1e6..0000000000 --- a/.changeset/stranded-inspection-undifferentiated-rows.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -`inspectStrandedRequests` no longer drops a row it could not differentiate, and no longer lets one misbehaving host abort the whole scan (#16709, items 2 and 3). - -Both are the same mistake at two altitudes: the method exists to **enumerate** the terminal approval requests whose flow run cannot advance, so a failure to read the #15358 third oracle must never remove a row from the answer — and never remove the *other* rows either. - -- **A thrown third read now leaves its row in `stranded`, as `'failed'`.** It used to be counted `undetermined` and skipped, exactly as a thrown `hasSuspendedRun` or `getRun` is. Those two are not the same question: a throw from either leaves it unknown *whether* the row is stranded at all, and a storage outage must not be published as a lost run. By the time the third oracle is asked, both have answered — no live pause, terminal `failed` — and it is asked only *which* of the three shapes the row is. A read that could not be made is therefore the textbook "could not differentiate", which is what `'failed'` already means (`StrandedRunState`, #15358 ruling item 1). Dropping the row let `stranded: []` read as "nothing stranded" while a row was in fact stuck, with a log line as its only trace; for a report, fail-closed means showing the row. -- **A host that violates `ApprovalResumeSurface` no longer aborts the scan.** `refineFailedRunState(verdict)` ran outside the `try` that wrapped the read, so an implementation resolving `undefined` where a verdict is declared threw a `TypeError` out of `inspectStrandedRequests` itself and the scan enumerated **nothing**. The refinement now runs inside that `try`; a malformed verdict costs its own row the differentiation, is counted `undetermined`, and costs every other row nothing. - -⛔ No new `StrandedRunState` member and no widened export: both cases map onto the existing undifferentiated `'failed'`. The `undetermined` counter is kept as telemetry and now **overlaps** `stranded` by design — a row can be both reported and counted — so neither number alone sizes the scan's blind spot. diff --git a/.changeset/stranded-run-state-discriminator.md b/.changeset/stranded-run-state-discriminator.md deleted file mode 100644 index 4dbfb8ed1f..0000000000 --- a/.changeset/stranded-run-state-discriminator.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/service-automation": minor -"@objectstack/plugin-approvals": minor ---- - -The stranded-request inspection tells a repairable strand from a cascade-failed run — through a dedicated read-only engine member, not through the wire (#15358, ruling B′). - -`ApprovalService.inspectStrandedRequests` keyed on `run.status === 'failed'`, which over-reports in one direction: a **cascade-failed** run — an ancestor `failAncestors` failed while it was parked at its `subflow` node, whose pause `failSuspendedRun` consumed and journalled nothing — has the same terminal `failed` row as the #13909 strand, so both came back `runState: 'failed'`, and `restoreConsumedSuspension` re-arms one and refuses the other (`NO_CONSUMED_SUSPENSION`). The engine's discriminator (the consumed-suspension snapshot on the durable `RunRecord`) is deliberately NOT on the `ExecutionLogEntry` that `getRun` answers, because `GET /automation/:name/runs/:runId` serves that object verbatim — so the plugin could not read it, and reading its absence as "not a strand" would have called the repairable row dead. - -**`@objectstack/service-automation` — additive, `minor`.** `AutomationEngine.inspectConsumedSuspension(runId)` answers whether `restoreConsumedSuspension` would have a consumed suspension to put back, from the SAME two witnesses that verb reads (this process's hot journal and the durable row, reconciled by the same `rowSupersedesJournal` / `persisted` / drop-notice rules — the read is now one private method both call), and re-arms nothing. Four answers, none folded: `repairable: true` (with the pause it would re-arm and which witness answered); `SNAPSHOT_DROPPED` (the strand happened, the store could not persist the snapshot, and this process holds no hot copy — repairable only by the replica that stranded it, while it lives); `NO_CONSUMED_SUSPENSION` (cascade-failed or never paused); `RUN_SUSPENDED` (already resumable). It REJECTS when a store cannot be read — an outage is unknown, not "nothing to restore". The result type is exported as `ConsumedSuspensionInspection`. `restoreConsumedSuspension` behaves exactly as before; nothing on `ExecutionLogEntry`, the run-detail route, or `@objectstack/spec` changes. - -**`@objectstack/plugin-approvals` — additive on two published types, `minor`.** - -- `ApprovalResumeSurface` gains the optional `inspectConsumedSuspension?(runId)`, declared the way `listSuspendedRunsDurable` is: a method `AutomationEngine` already implements, widening no engine surface. -- `StrandedRunState` splits `'failed'` three ways and keeps `'missing'` untouched: `'repairable'` (the #13909 strand — restore, then `continueRestoredRun`), `'snapshot_dropped'` (its own class: as `repairable` it over-reports, as `unrepairable` it is a false negative), and `'unrepairable'` (the cascade-failed / never-paused run, #15222's shape — nothing re-arms it). **`'failed'` stays a member, on purpose**: it is what a `failed` row is reported as when the attached surface has no `inspectConsumedSuspension` (an engine build older than this plugin, or a host double). Absence of the discriminator is fail-closed for a report — the row is reported, undifferentiated, never labelled `unrepairable` and never dropped. A thrown read counts `undetermined`, as the other two oracles' do. - -A consumer switching exhaustively over `StrandedRunState` gains three arms; nothing it matched before stops arriving. The inspection's summary log adds `runRepairable` / `runSnapshotDropped` / `runUnrepairable` beside the existing counts. diff --git a/.changeset/stranded-run-status-stamp.md b/.changeset/stranded-run-status-stamp.md deleted file mode 100644 index 167063169e..0000000000 --- a/.changeset/stranded-run-status-stamp.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -feat(automation): a resume that consumed the pause and then failed downstream answers `status: 'stranded'` (#13937) - -The services half of the #13937 shape-4 ruling (maintainer 2026-09-01): -`resumeInternal`'s consumption order is kept — the suspension is consumed -before downstream nodes run, which is what buys exactly-once across a crash — -and the state that order leaves behind when a downstream node throws now -carries the platform-level name #14384 put on the contract. - -`AutomationEngine.resume()` (and every engine continuation that reaches the -same catch arm) returns `{ success: false, status: 'stranded', … }` where it -returned no `status` at all. Stamped on that one exit only: the pause a -durable decision was waiting on is gone, the run is recorded `failed`, and it -can be re-armed only by the explicit operator verb -`restoreConsumedSuspension` (#13909 slice 2, already published) — never by -`resume` (which answers `RUN_NOT_FOUND`) and never automatically. Distinct -from `'failed'` on purpose: that one says the run ran and was rejected; this -one says a recorded continuation stopped mid-flight and an operator has -something to repair. The result's verdict and the restore verb are held to -agree by test: a stranded result is exactly a restorable run. - -Not changed: the run's RECORDED status (the run log, `getRun`, `listRuns`, the -durable `sys_automation_run` history row) stays `failed` — that vocabulary is -`ExecutionStatus` in `@objectstack/spec`, which the ruling did not widen; the -durable discriminator for the condition remains the snapshot the terminal row -carries. No resume semantics move for any pausing node type; shapes 2 and 3 -of the decision stay excluded. - -Also in this change, under the same ruling's exactly-once guarantee, two -repairs to how `restoreConsumedSuspension` finds a stranded run's snapshot: - -- The durable run-history row of a stranded run now records the PAUSE node in - `node_id`. It recorded the node that threw — the run's last step — and the - object store read that column back as the snapshot's node, so a restore - from the row (after a restart, or on another replica) re-armed the run at - the failed node and the next resume skipped it while reporting the run - completed. The throwing node stays in the row's step log and `error`. - Visible on the Runs surface: `sys_automation_run`'s row title and highlight - set are built from `node_id` (`titleFormat '{flow_name} · {node_id}'`), so a - stranded run's row now names the PAUSED node — the one an operator can - re-arm — where it named the node that threw; ordinary completed / failed - rows are unchanged. The `node_id` and `variables_json` field descriptions - carry this carve-out, the way `node_type`'s already did. -- The verb reads the durable row and its own per-process journal as two - witnesses of one strand instead of trusting either alone. The hot copy is - preferred when both describe the same pause (it is the verbatim object the - failure was journalled from). A row that carries no snapshot is read as - "the run moved on" only when this process's own history write landed — - the replica that stranded a run used to keep a hot copy that could re-arm - the run after another replica had restored, resumed and finished it, and - the next resume re-ran every node after the pause. A snapshot the object - store could not persist (over its 256 KiB row budget) is now recorded in - the row as dropped, with the pause it belonged to, so the replica holding - the hot copy still restores and any other replica is refused with a reason - that names the budget and the remedy. - -In-memory and store-less deployments observe no behaviour difference. On the -object store, same-replica restores re-arm the pause node on every path, and -restores from the row alone do too; restores across replicas of a run that -finished elsewhere are refused. diff --git a/.changeset/structural-condition-shape-refused.md b/.changeset/structural-condition-shape-refused.md deleted file mode 100644 index a357b8759b..0000000000 --- a/.changeset/structural-condition-shape-refused.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/lint": minor ---- - -A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. - -`evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author. - -- The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`. - -**This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too. - -**Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved. - -An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. diff --git a/.changeset/studio-object-field-ref-refusal.md b/.changeset/studio-object-field-ref-refusal.md deleted file mode 100644 index bbed1c08b0..0000000000 --- a/.changeset/studio-object-field-ref-refusal.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack. - -`list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all. - -The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this. - -- **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`). -- **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default. -- **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier. -- **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing. - -## Migration - -**A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id `object-field-ref-unknown` and the offending path, name-keyed on the wire — for example `objects.proj_task.highlightFields[1]` — plus the string that was written and the fields the object actually has. - -To fix a dangling reference, do one of: - -- rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or -- drop the entry from the list. - -`os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them. diff --git a/.changeset/subflow-bubble-stranded-parent.md b/.changeset/subflow-bubble-stranded-parent.md deleted file mode 100644 index dc14066cb6..0000000000 --- a/.changeset/subflow-bubble-stranded-parent.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -automation: a subflow parent left STRANDED by a failed up-bubble is reported at `error`, not `warn` - -When an approval (or any pause) sits inside a subflow child, resuming the child -bubbles up to the parent. If the parent's own continuation then fails on the -engine's stranded exit — its suspension consumed, a repair snapshot journalled, -the run recorded `failed` — nothing but a `warn` said so, while the child's -resumer (an approvals decision door, a wait timer) was told the resume -succeeded. Persisted state and runtime state disagree and nothing looks broken -from the outside, which is the durability class. - -`bubbleToParent` now grades that record by the engine's own -`AutomationResult.status` discriminator: `'stranded'` is reported at `error`, -naming the parent run and the `restoreConsumedSuspension` verb that repairs it. -Every other parent-resume failure — a concurrent resume, an unreachable store, -a thrown resume — stays at `warn` unchanged, on a narrower ground: those exits -carry no `'stranded'` discriminator. `'stranded'` is the one exit that journals -a repair snapshot, so it is the one an operator can act on, and grading by the -engine's own verdict is what keeps `error` readable. - -⚠️ That is a statement about what this seam can KNOW, not a guarantee that -every other exit left the parent healthy. Two exits are known not to be: - -- a **thrown** parent resume carries no discriminator at all, and #15555 - documents a window in which a throw between the journal and the stamp hides a - parent that IS stranded. Left at `warn` deliberately, for that card; -- the **claim-path** store failure reports, in its own envelope text, that - whether the suspension was consumed is UNKNOWN — it relies on a retry to - settle it, and an up-bubble has no retrier. ("Not consumed" is the guarantee - of the strict-load store failure only, not of every store failure.) - -⚠️ This is the log half only. What the child's resumer is told is unchanged. diff --git a/.changeset/summary-backfill-recompute-undefined-on-empty.md b/.changeset/summary-backfill-recompute-undefined-on-empty.md deleted file mode 100644 index 4ab5fa0ed8..0000000000 --- a/.changeset/summary-backfill-recompute-undefined-on-empty.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/cli": minor ---- - -feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064) - -A roll-up value has three producers — the insert-time seed, the child-write -recompute, and the one-off backfill — and **declaring a summary field on an -object that already has rows reaches none of them**. For `count`/`sum` the -backfill repairs that as a side effect (every `NULL` is a hole to it). For -`min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the -function alone, so "never computed" and "no child rows" were indistinguishable, -the column stayed `NULL` on every pre-existing parent, and the report said -`filled: 0` — a false all-clear that a timed flow built on the column then -turned into "matches nothing" (the customer case behind cloud#1908). - -**What changes** — maintainer ruling on #15064, option A: the caller who holds -the fact gets a way to say it; the predicate and the default run do not move. - -- `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]` — `object.field` - roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is - walked like a `count`: every `NULL` parent is recomputed through the same - `aggregateSummaryValue` the engine writes. A parent whose aggregate is the - empty-set reading (`null` — no child rows) already holds the engine's own - value, so it is neither counted as a hole nor written; the scoped run is - therefore idempotent in the same "re-run until it reports zero" sense. - Naming a `count`/`sum` is accepted and changes nothing, so a publish path can - pass every column it just declared without knowing the empty-set list. -- A name that resolves to no roll-up owned by an object the run walks — a typo, - a plain field, or an object `objects` left out — is **refused before any row - is read**, dry run or apply, with an ADR-0112 envelope (`code: - 'INVALID_FIELD'`, `status: 400` — the code the projection and write axes - that name a field already answer, while sorting keeps `INVALID_SORT`; - `field` names the first unresolved entry, `fields` all of them). A silent - no-op there would be the same false all-clear this option exists to end. -- `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement - of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an - unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'` - to every roll-up function, since a named `max` now appears in `fields`. -- `os migrate summary-nulls --recompute-undefined-on-empty object.field` - (repeatable) passes the scope through; the confirmation prompt names the - columns; `formatSummaryBackfillReport` lists them under "Recomputed on - request" and explains a `NULL` that remains. - -**What does not change:** without the option the walk, the writes, every -counter and the human-readable report are byte-for-byte what they were (pinned -against output captured on `main` before this change); `min`/`max`/`avg` stay -out of scope and keep being reported under `skippedUndefinedOnEmpty`; the -predicate `summaryNullIsBackfillable` is untouched, so `os migrate -summary-nulls` keeps its meaning on every deployment. The only visible delta on -an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`. - -`minor` for both packages: an optional parameter on a published exported -function, a new report key, and a new CLI flag are each a purely additive -widening of a published surface, which takes at least `minor` (bump-level rule, -2026-09-04); the `fix`-shaped motivation does not lower it. diff --git a/.changeset/suspend-snapshot-aliasing.md b/.changeset/suspend-snapshot-aliasing.md deleted file mode 100644 index 4394da4ba2..0000000000 --- a/.changeset/suspend-snapshot-aliasing.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A restored suspension now carries the state the run was paused with, including nested values. - -`restoreConsumedSuspension` is the operator exit from a run whose resume consumed the pause and then failed downstream: it puts the suspension back so the run is resumable again. What it put back was documented as the pause "verbatim", and was — for the top-level variables only. - -The flow scope a resume hands the downstream nodes was rebuilt as `new Map(Object.entries(run.variables))`: that copies the keys and shares every value object with the parked snapshot. An executor that keeps state in the scope and updates it **in place** — `map` tracks its progress in `.$mapState` — therefore wrote straight through into the snapshot, and the journal recorded the result as the pause. An operator repairing a stranded `map` run got a snapshot claiming progress made by the attempt that failed, not the progress the run actually had when it paused. - -Measured, not inferred: the durable row held `started: 1` at the pause and the restore put back `started: 99`. - -The pause's variables are now copied before the failed attempt runs, on the line that already captures the pause's step count for the same reason. No later placement works — the node mutates and then throws, so a copy taken when the journal is written copies the mutation. Nothing else changes: the running flow still sees exactly the scope it saw before, the resume ordering is untouched, and a value that cannot be copied falls back to the previous behaviour with a warning rather than costing the operator the repair. diff --git a/.changeset/suspended-run-cache-consumed-elsewhere.md b/.changeset/suspended-run-cache-consumed-elsewhere.md deleted file mode 100644 index 1475441cb7..0000000000 --- a/.changeset/suspended-run-cache-consumed-elsewhere.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms (#15832) - -`AutomationEngine` had exactly one eviction site for its `suspendedRuns` -map, inside `forgetSuspendedRun` — and that runs in whichever process -**consumes** the suspension. In a multi-replica deployment that is routinely -not the process that parked it: replica A parks a run, replica B resumes it, -and nothing ever removes A's entry. There is no invalidation channel from B -to A. - -The card that found this located the leak on `resumeInternal`'s -`claim.kind === 'lost'` branch, which returns before that choke point. That -branch does leak, but it is not the common shape: the **no-race** variant -leaks identically — A parks, only B ever resumes, A never attempts a claim -and there is no `'lost'` anywhere in the sequence — so an eviction hung on -`'lost'` alone would have left the ordinary deployment untouched. - -The retained snapshot was **not only memory**. Two readers handed it back: -`listSuspendedRuns()` (synchronous, cache-only, and the one listing on the -`AutomationService` spec contract) and `listSuspendedRunsDurable()` (which -deliberately appends map entries the durable list lacks). Once the other -replica **completed** the run, both reported a phantom — a finished run -listed as suspended, whose `getSuspendedScreen()` answers `null`, so a -consumer that listed and then opened got an entry it could not act on. - -An entry is now dropped whenever this process holds a store-authoritative, -per-id "no row" answer for it: the strict loader's store miss (which reaches -`resume`, `hasSuspendedRun`, `cancelRun` and `getSuspendedScreen`), a lost -advance claim, and a bounded per-id reconcile for the map-only entries of -`listSuspendedRunsDurable()`. - -**Nothing here moves the cache-only listing's contract.** The fix only ever -*removes* entries. The spec says `listSuspendedRuns()` lists "the currently -suspended (paused) runs awaiting a resume"; the engine's own docblock adds -only that it may OMIT runs (those parked in a previous process lifetime), -because it reads the cache alone. Under-reporting is therefore already -inside the declared latitude, and over-reporting was never inside the -promise. Neither listing becomes store-backed, and `listSuspendedRuns()` -stays synchronous. - -Three shapes are deliberately **never** evicted, each pinned by a control: -no store attached (the map IS the authority); a run whose durable save -failed (`cacheOnlySuspensions` — the store was never handed the row, so its -silence says nothing about it); and a store read that THROWS (an outage -means the run's existence is unknown, not gone). A failed `list()` -enumeration likewise triggers no per-id reconcile — during an outage that -would ask about every live run in the process. - -**Residual, stated rather than implied.** Eviction is demand-driven: a -phantom is cleared when this process next obtains the per-id answer for that -run — any `resume` / `hasSuspendedRun` / `getSuspendedScreen`, or a -`listSuspendedRunsDurable()` reconcile. A process that never looks at the -run again keeps the entry until it does. With no invalidation channel -between replicas, closing that last gap needs either a background sweep or a -store-backed listing, and both are decisions above this change; the boundary -is pinned by a `RESIDUAL` test rather than left to be discovered. - -Note 2 of the same card — the `'unsupported'` branch deciding on the shape of -a value the conditional delete has **already** been issued to obtain — is -**not** addressed here: its honest fix is a declared return contract for the -engine's multi-row delete, which lands in another package. diff --git a/.changeset/sys-email-error-description-widen.md b/.changeset/sys-email-error-description-widen.md deleted file mode 100644 index e27ce70c96..0000000000 --- a/.changeset/sys-email-error-description-widen.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): `sys_email.error` field help now covers pre-delivery rejections, not only transport failures - -`sys_email.error` was declared as *"Transport error message when status=failed"*. -Since `EmailService.recordRejectedMessage` landed, the same column also carries -the reason a message was rejected by `normalizeMessage` **before** it reached a -transport (an unsendable `from`, no recipient, no subject, no body) — those rows -are written with `status: 'failed'` too, prefixed `rejected before delivery: `. - -Nothing was misleading in the *data*: the row prefixes its own reason, so an -operator reading a failed row is never sent chasing an SMTP host for a message -that never reached one. What was stale was the field's declared `description`, -which Studio surfaces as the field's help text — it named only the transport -case, narrower than what the column has held since that change landed. - -The description now reads: *"Why the message failed — a transport error, or the -validation that rejected it before delivery."* It stays true under both row -shapes and deliberately does not name the row's own `rejected before delivery:` -prefix, so it will not go stale again if that prefix's wording changes. diff --git a/.changeset/sys-email-highlight-fields-to-addresses.md b/.changeset/sys-email-highlight-fields-to-addresses.md deleted file mode 100644 index 9caa18e9bb..0000000000 --- a/.changeset/sys-email-highlight-fields-to-addresses.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -`sys_email.highlightFields` names the recipient column that exists, so the platform's own email log stops rendering one column short (#15629) - -The list read `['subject', 'to', 'status', 'sent_at']`. Three of those four resolve; `to` does not — `sys_email`'s recipient column is `to_addresses`. It now reads `['subject', 'to_addresses', 'status', 'sent_at']`, and nothing else about the object moved. - -`highlightFields` is the object's ordered "most important fields" pointer (ADR-0085): it drives the default list columns, record cards, previews and the detail highlight strip. Every consumer **silently skips** an entry it cannot resolve — nothing throws and nothing logs — so each of those surfaces rendered one field short, and the field missing from the platform's own outbound-email log was the recipient. - -There was a second, louder consequence that nobody could reach by accident. Since `object-field-ref-unknown` crossed onto the object write door (#15254), this body could not be republished through `PUT /api/v1/meta/object` or a package publish: the door answers `422 INVALID_METADATA`. `sys_email` reaches the runtime as a code-shipped registry object instead — `EmailServicePlugin` hands it to the manifest service, a path that runs no authoring gate — so boot was never affected and no deployment was failing. It was a trap laid for whoever next edited the object through a door rather than the file. - -`sys-email.highlight-fields-resolve.test.ts` pins it through that real door rather than by comparing the array against `Object.keys(fields)`: it runs `runRuntimeAuthoringRules({ type: 'object' })` over the shipped declaration with the audit module's other objects as resolution context, and a control case restores the old entry and requires the same call to refuse it — so a green result means the door read this object and accepted it, never that nothing looked. diff --git a/.changeset/system-duration-keys-unit-in-key-name.md b/.changeset/system-duration-keys-unit-in-key-name.md deleted file mode 100644 index c77b197fab..0000000000 --- a/.changeset/system-duration-keys-unit-in-key-name.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the fifteen `system/` duration keys carry their unit in the key name (#15679, ruling B on #14478) - - - -**BREAKING** — fifteen published `system/` duration keys are renamed and -tombstoned. Shipped as `minor` under the repo's launch-window convention for -breaking changes; the hand-migration prescriptions are registered under protocol -major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, -「同意」). - -`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit -in the key NAME, never only in its `.describe()` prose, and grandfathers no -existing offender. Stack card 1/6 (#15676) landed the rule's two structural -exemptions, card 2/6 (#15677) cleared `api/` and card 3/6 (#15678) cleared -`kernel/`; this card clears `system/`. Measured with the gate itself: -`src/system/**` goes from 15 offenders to **0**, and the whole-tree count falls -**22 → 7**. - -## FROM → TO - -| key | replacement | unit | -|:--|:--|:--| -| `CacheTier.ttl` | `ttlSeconds` | seconds | -| `CacheAvalanchePrevention.circuitBreaker.resetTimeout` | `resetTimeoutSeconds` | seconds | -| `CollaborationSessionConfig.idleTimeout` | `idleTimeoutMs` | milliseconds | -| `CollaborationSessionConfig.snapshot.interval` | `intervalMs` | milliseconds | -| `FailoverConfig.healthCheckInterval` | `healthCheckIntervalSeconds` | seconds | -| `MetricAggregationConfig.window.size` | `durationSeconds` | seconds | -| `ServiceLevelIndicator.window.size` | `durationSeconds` | seconds | -| `ServiceLevelObjective.period.duration` | `durationSeconds` | seconds | -| `AccessControlConfig.maxAge` | `maxAgeSeconds` | seconds | -| `StorageConnection.timeout` | `timeoutMs` | milliseconds | -| `RegistryUpstream.syncInterval` | `syncIntervalSeconds` | seconds | -| `RegistryUpstream.timeout` | `timeoutMs` | milliseconds | -| `RegistryConfig.cache.ttl` | `ttlSeconds` | seconds | -| `Span.duration` | `durationMs` | milliseconds | -| `QueueConfig.rateLimit.duration` | `durationMs` | milliseconds | - -**Every value is unchanged** — only key names move, and every default moves with -its key (`CacheTier` still defaults to 300, `CollaborationSessionConfig` to -300000, `FailoverConfig` to 30, `RegistryUpstream.timeoutMs` to 30000, -`RegistryConfig.cache.ttlSeconds` to 3600). Bounds move with their keys too, so -`syncIntervalSeconds` still refuses anything under 60 and `timeoutMs` anything -under 1000. Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` -at the authoring site (input type `never`) and fails the parse with the rename -prescription rather than a bare unrecognized-key error. - -## ⚠️ Two `maxAge` keys, opposite sides of the line — do not harmonise them - -`AccessControlConfig.maxAge` (bucket CORS) is **renamed** to `maxAgeSeconds`. -Its twin `shared/CorsConfig.maxAge` (HTTP CORS) is **not**, and keeps its bare -name under an `externalVocabulary` marker. - -The asymmetry is the whole point. Every bucket-CORS standard the first value is -forwarded to already spells the unit — S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, -Azure `MaxAgeInSeconds` — so marking that key would have exempted a *deviation -from* the cited standard rather than a mirror of it. The Fetch response header -the second mirrors, `Access-Control-Max-Age`, genuinely carries no unit token. -A find-and-replace across both leaves no gate red: the marker exempts the twin -either way. A pin test in `object-storage.test.ts` is the only guard. - -## ⚠️ `window.size` becomes `durationSeconds`, not the mechanical `sizeSeconds` - -The gate prints `sizeSeconds` for the two `window.size` keys, and that name is -wrong on its face. `size` means a byte or row count everywhere else in this spec -— `CacheTier.maxSize` is megabytes, `RegistryConfig.cache.maxSize` is bytes, and -`MetricExportConfig.batch.size` on the very same file is a record count — so -`sizeSeconds` would have kept the misleading half of the name and bolted a unit -onto it. `windowSeconds` was rejected for a plainer reason: the parent key is -already `window`, so it would read `window.windowSeconds`. - -`durationSeconds` names what the number is, and the file supplied its own -precedent: `ServiceLevelObjective.period.duration` already called a period -length a duration. After the rename all three read alike. The prescription says -so explicitly, so the next author does not read the departure as a slip and -"correct" it back to the mechanical name. - -## Dispositions — eight semantic entries, no D2 conversion - -Justified per key rather than defaulted, and this card's answer is uniform: -**none of the fifteen gets an ADR-0087 D2 conversion.** A D2 conversion runs -over a stack document, and `stack.zod.ts` declares no `cache`, `collaboration`, -`disasterRecovery`, `metrics`, `objectStorage`, `registry`, `tracing` or -`worker` root — none of these twelve defs is a stack collection member or a -registered metadata kind stored as a `sys_metadata` row, so the conversion chain -has no seam that would see one. They are host configuration (`CacheTier`, -`FailoverConfig`, `StorageConnection`, `RegistryUpstream`, `RegistryConfig`, -`QueueConfig`), call arguments (`CollaborationSessionConfig`) and -runtime-emitted measurements (`Span`). Each therefore carries a **semantic** -entry, which is what ruling B prescribes for a key that is not authorable stack -metadata. All fifteen are registered by exact key in `RETIRED_KEYS_BY_MAJOR`, -nested spellings included. - -## Keys deliberately left alone - -`FailoverConfig.dns.ttl` is a declared `externalVocabulary` mirror of the DNS -resource-record TTL field (RFC 1035 §4.1.3) and keeps its bare name. -`CacheAvalanchePrevention.lockout.lockTimeoutMs` was already correct — and it is -milliseconds where its `resetTimeoutSeconds` sibling is seconds, so the two must -not be migrated as if they were one unit. `MetricExportConfig.batch.size` is a -record count and `QueueConfig.rateLimit.max` is a task count: neither is a -duration, so neither has a unit to carry. `ServiceLevelObjective.errorBudget`'s -burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit -anywhere in their prose, so both are outside the gate's population entirely. -Pin tests assert each of these, so a later sweep cannot read this card as -"every duration-shaped number on these files". diff --git a/.changeset/template-dialect-placeholder-contract.md b/.changeset/template-dialect-placeholder-contract.md deleted file mode 100644 index 2324c28b94..0000000000 --- a/.changeset/template-dialect-placeholder-contract.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`TemplateExpressionInputSchema` documents what the `template` dialect actually accepts, instead of illustrating it with a grammar it does not judge. - -The docblock introduced the dialect as "anything with `{{var}}` interpolation". Nothing in the schema enforces that: `TemplateExpressionInputSchema` judges the dialect tag and non-emptiness and nothing else, and the same docblock already said so one clause later. Read as a declaration, it made every single-brace `titleFormat` value in the wild look like it was crossing a gate. There is no gate. - -The corrected prose states where the placeholder grammar really lives — with the renderer that consumes the slot — and that the two spellings in circulation are not interchangeable everywhere: - -- `{{var}}` is the canonical form and the only one the registered `template` engine reads (`@objectstack/formula`'s `templateEngine`); the messaging renderer, the email plugin and the i18n adapters match it alone and leave a `{var}` in their output verbatim. -- `titleFormat` is the exception: its renderers accept `{{var}}` and `{var}` as equivalent, normalizing the double form down to the single one before substituting. Single-brace `titleFormat` values are legal by construction, not a grammar the schema failed to enforce. - -`titleFormat`'s `.describe()` says the same thing at the slot. Documentation only — the schema's judging logic, its accept-set and its exports are untouched, so nothing an author can write changes meaning. Both strings publish (`dist/*.d.ts` and the bundled `.describe()`), which is why this is a `patch` rather than a `skip-changeset`. diff --git a/.changeset/throw-arm-error-refresh-symmetry.md b/.changeset/throw-arm-error-refresh-symmetry.md deleted file mode 100644 index 2f9804dbef..0000000000 --- a/.changeset/throw-arm-error-refresh-symmetry.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -`$error` now names the most recent failure in a flow run, whichever way that failure arrived. - -The automation engine has two failure arms. When a node FAILS BY RETURNING `{ success: false }`, the engine rewrote the run-wide `$error` (and `.error`) and then decided whether a `fault` edge could route it. When a node FAILED BY THROWING — a `timeoutMs` firing, a dying nested container, a thrown guard — it did both **inside** the `fault`-edge branch, so a thrown failure with no `fault` edge of its own left `$error` holding an earlier, unrelated failure's value. - -A node inside a structured region never has a `fault` edge of its own: the region's synthetic sub-flow carries only the region's own edges. So every thrown failure inside a `try_catch`, `loop` body or other region hit this. The result was not a crash but a plausible-looking wrong value: **the message and the code came from two different failures** — `{ code: 'DUPLICATE_RECORD', message: "Node 'mk' timed out after 20ms" }` — and a catch region branching on `{$error.code}` swallowed a store failure as "the row is already there" while the run reported success. - -The throw arm now publishes `$error` and `.error` before deciding whether the failure routes, exactly as the returned-failure arm does. What a thrown failure publishes is `{ nodeId, message }`: there is no node result on that path, so no `output` and no classified `code` exist to carry — and that absence is the right answer for a throw rather than a reason to leave a stale `code` standing. - -Routing is unchanged. A guard refusal that throws (ADR-0049's unscoped-run refusal, for one) is still un-routable, still fatal, and still reports its own message; the thrown value itself is rethrown exactly as caught. diff --git a/.changeset/tidy-cars-repeat.md b/.changeset/tidy-cars-repeat.md deleted file mode 100644 index 4123980eba..0000000000 --- a/.changeset/tidy-cars-repeat.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Stop the published CLI from dying of an uncaught `write EPIPE` when its caller's stderr read end is gone. - -`bin/run.js` — the file `bin.objectstack` / `bin.os` point at, and the only thing under `bin/` npm packs — now attaches the same no-op `error` listener to `process.stderr` that the in-repo dev shim has carried since the original finding. `process.stderr` is an `EventEmitter`, so an `error` event with nothing listening is an uncaught exception. - -Measured on the published entry with the read end destroyed (`stdio: ['ignore','ignore','pipe']`, then `child.stderr.destroy()`), traced with an observer that installs no listener and wraps no write: - -``` -uncaughtException code=EPIPE msg=write EPIPE - at afterWriteDispatched (node:internal/stream_base_commons:159:15) -exit code=1 -``` - -3 of 3 runs, 3049-3433 ms in, on `os serve` over `examples/app-todo`. Read by a draining parent the same child boots and serves and exits 0, having written 7926 bytes over 16.6 s — so the crash was costing the run at its first diagnostic line and 20 of its 21 stderr writes. Failing invocations do not reach it: everything they put on stderr is written after `run()` has settled, by a handler that exits on top of its own report. - -Behaviour change worth knowing about: a long-running command (`os serve`, `os dev`, `os start`) whose reader has gone now keeps running and reports its own exit status, instead of dying on its first diagnostic write. A supervisor that destroyed the read end and relied on that crash to end the child needs to end it itself. diff --git a/.changeset/tidy-cups-smile.md b/.changeset/tidy-cups-smile.md deleted file mode 100644 index 9741d78089..0000000000 --- a/.changeset/tidy-cups-smile.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/metadata-protocol': minor -'@objectstack/service-automation': patch -'@objectstack/lint': patch -'@objectstack/spec': patch -'@objectstack/service-settings': patch ---- - -**BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. - -Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. - -- `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. -- `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. -- Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. -- `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. -- `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. - - diff --git a/.changeset/tidy-jars-shake.md b/.changeset/tidy-jars-shake.md deleted file mode 100644 index 7c08b8249e..0000000000 --- a/.changeset/tidy-jars-shake.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch -'@objectstack/rest': patch ---- - -Correct the out-of-package comments that still described `SqlDriver#formatOutput`'s -two timestamp passes as gated on `if (this.isSqlite)`. - -Since ADR-0053 D-F1 (#13973) both passes — the `AUDIT_TIMESTAMP_COLUMNS` pass and the -`normalizeSqliteDatetimeOutput` pass over `datetimeFields` — run on every dialect, so a -declared `Field.datetime` and the builtin audit columns are presented as canonical -ISO-8601-`Z` text on Postgres and MySQL as well as SQLite. The `rest-server.ts` comment -went further than staleness: it warned future authors that "a declared `Field.datetime` -is therefore NOT protected on Postgres/MySQL", inviting exactly the tolerant consumer-side -coercion ADR-0053 forbids. - -Comments only — no runtime behaviour, no exported symbol and no public type changes; the -published `.d.ts` of both packages is byte-identical. These two packages are named because -their bundled `dist/index.js` / `dist/index.cjs` carry the amended comment text verbatim, -so the published output does change. `@objectstack/metadata` carries the same correction -in `database-loader.ts` but is deliberately NOT named: its edits are all JSDoc blocks, -which its bundle strips, so its published output is unchanged. - -Two carve-outs are preserved rather than flattened: `withPostgresCalendarDayAsText` is -untouched by that ruling (D-F2 — the client library still materialises `timestamptz` / -`DATETIME(3)` as a `Date`; the driver now folds it at its own read boundary), and the -Invalid `Date` residue still stands (D-F3 — the one `Date` shape with no canonical text -leaves the read door unchanged). diff --git a/.changeset/today-offset-one-calendar.md b/.changeset/today-offset-one-calendar.md deleted file mode 100644 index 60babce8b2..0000000000 --- a/.changeset/today-offset-one-calendar.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -Flow templates: `{TODAY() + n}` and `{TODAY() - n}` now do their day arithmetic on the same calendar they render on (UTC), so the resolved date no longer lands a day off across a DST transition. - -The offset branch of the template resolver shifted the day on the **local** calendar (`getDate` / `setDate`) and then rendered the result on the **UTC** one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the underlying instant by exactly n x 24 hours only while every local day in the window is 24 hours long. Across a spring-forward the window is 23 hours and across a fall-back 25, and when that one hour of slack crosses a UTC midnight the rendered date comes out a day early (spring-forward) or a day late (fall-back). - -The window is narrow — roughly one hour per DST-observing zone, twice a year — but the values written through it persist: a quote expiration, a follow-up date, a close date. Measured across 34 zones at every 30 minutes of 2026 for offsets `+1` and `-1` (1,191,360 instant-offset pairs), the old spelling disagreed with the UTC day in 190 of them, spread over 24 DST-observing zones; the new spelling disagrees in none. - -The same branch serves `{NOW() + n}`, which likewise now moves the instant by exactly n x 24 hours instead of preserving a wall-clock time across the transition. - -Nothing else moves. The bare `{TODAY()}` and `{NOW()}` forms never entered this branch and are byte-for-byte unchanged — they already resolved on UTC, and the offset forms now agree with them. This is not a timezone feature: these tokens remain timezone-unaware by design, and whether they should be is a separate question. diff --git a/.changeset/transaction-error-code-constants.md b/.changeset/transaction-error-code-constants.md deleted file mode 100644 index 8a5a054256..0000000000 --- a/.changeset/transaction-error-code-constants.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -Both transaction-seam refusals publish their error `code` as an importable constant. - -`packages/objectql/src/transaction-errors.ts` opens by telling the reader that the errors in it "identify themselves by a `code` field rather than by `instanceof`, for the reason `DriverConnectError` already records: the check has to survive crossing a package boundary, where two copies of this module can exist" — and neither of them offered anything to import. The only way to FOLLOW that published instruction was to re-spell the wire string in your own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. - -Two new exports from `@objectstack/objectql`, each graded on its own: - -- `TRANSACTION_UNSUPPORTED_CODE` — `TransactionUnsupportedError`'s ADR-0112 `code`. Thrown by `transaction(cb, base, { require: true })` when the datasource's driver has no `beginTransaction`, refused before the callback runs so nothing has been written. **Additive widening, `minor`.** -- `CROSS_DATASOURCE_TRANSACTION_WRITE_CODE` — `CrossDatasourceTransactionWriteError`'s ADR-0112 `code`. Thrown when a business write inside an open `transaction()` resolves to a driver that transaction does not cover. **Additive widening, `minor`.** - -**The second one is a refusal callers are meant to recover from.** Its own message prescribes the remedy — split the work into per-datasource units and reconcile them explicitly — which is code a caller writes *around* this refusal, and therefore code that has to recognise it first. That recognition now has something to import. - -**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary, which is what this module's header has been telling readers to do. - -**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; every refusal throws the same `code` and the same message as before. Consumers that spell the strings themselves keep working unchanged — this adds affordances, it removes nothing. - -**Both classes were already exported and stay exported**, and neither is published from the lean `./core` entry, so the constants join them on the one entry point that publishes them: class and constant are reachable from exactly the same place. diff --git a/.changeset/translation-actions-convention-docblock-keys.md b/.changeset/translation-actions-convention-docblock-keys.md deleted file mode 100644 index 54b9abb1b6..0000000000 --- a/.changeset/translation-actions-convention-docblock-keys.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -Documentation: the `_actions` and `globalActions` convention lists in `translation.zod.ts` now name every key the schema accepts. - -Both docblocks are hand-written prose copies of what one factory declares. `actionTranslationSchema(...)` builds both surfaces — the file says so outright, "Shared by object `_actions` and `globalActions`" — so the two lists carried the identical four addresses (`label`, `confirmText`, `successMessage`, `resultDialog.*`) and the identical two omissions. An author reading either list to learn which keys exist saw a strict subset of what the schema has accepted all along. - -Added to both lists, in the order the factory declares them and matching the spelling already landed in `i18n-resolver.ts`'s own header: - -- `description` — the explanatory line under the title in the action's param dialog, resolved at `objects.._actions..description` with a `globalActions..description` fallback. -- `params..{label, helpText, placeholder, options.}` — the per-parameter translations for an action's param dialog. - -Prose only. No schema, factory or resolver changed: the keys were already declared and already accepted, so nothing about what a bundle validates to moves. `packages/spec` publishes `src/**/*.zod.ts`, which is why documentation-only text still ships and still earns a changeset. diff --git a/.changeset/translation-liveness-group-boundary.md b/.changeset/translation-liveness-group-boundary.md deleted file mode 100644 index 88205381d0..0000000000 --- a/.changeset/translation-liveness-group-boundary.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness ledger: `translation`'s `_note` states the live/planned boundary instead of a hand-maintained total - -The header claimed "11 of 12 groups live; the twelfth, `datasets`, …". No reading of the -file's own `props` produces that pair. Measured on this commit, `props` holds fourteen -entries — the eleven translation groups `translationDataShape()` declares, plus `locale` -and the item-identity keys `name` / `label` — of which exactly one row, `flows`, is not -`live`, and `datasets` is one of the groups rather than a twelfth. Counting all of `props` -gives thirteen live of fourteen; counting groups only gives ten of eleven. Neither is -eleven of twelve. - -This is the second wrong total the same sentence has carried. It previously read "10 of 11 -groups live; the one dead group (`validationMessages`) …", describing a group removed in -17.0.0 (#4667) — prose outliving its subject in the header of the very file whose rows warn -about that. So the integers are deleted rather than re-derived, on the #7377 precedent that -moved this ledger family's other hand-maintained counts out of prose and into a generated -artifact: the sentence now names the BOUNDARY ("every group but `flows` is live"), which the -per-prop rows below it carry and `state-counts.md` totals, and it records why a total taken -over `props` is not a total of groups. Both former totals are kept, quoted, as the -sentence's own correction record. - -Published data, prose only: `liveness/` is in this package's `files` array, so these ledgers -ship in the npm tarball. No `status` value moves, no schema changes and no gate verdict -changes — every non-`live` row in the file, at every nesting level, is `flows` or one of its -children. diff --git a/.changeset/translation-liveness-walk-boundary.md b/.changeset/translation-liveness-walk-boundary.md deleted file mode 100644 index 6d6d283657..0000000000 --- a/.changeset/translation-liveness-walk-boundary.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -liveness ledger: `translation`'s `_note` WALK BOUNDARY sentence excepts `settingsCommon` and states that group's own boundary - -The header said "every group is a z.record keyed by target names — the drill sees each -record's VALUE shape one level". Measured on this commit straight off -`translationDataShape()`, ten of the eleven groups are `z.record`s and `settingsCommon` is a -plain strictObject: a fixed shape whose one member `sourceLabels` is itself a fixed -strictObject keyed by the ADR-0010 resolution layers (`env`, `global`, `tenant`, `user`, -`default`) — a closed set, with the retired spellings (`org`, `workspace`, `system`, -`fallback`, `environment`) rejected and pointed at the layer each meant. Nothing about it is -keyed by target names, so the universal was false for one group, and false in the direction -that matters: the sentence is the file's declaration of how far down its rows reach. - -The sentence now excepts `settingsCommon` and states what the boundary is for it: the -walk's one level lands on the named member `sourceLabels` (exactly what -`check:liveness --undrilled` prints for `translation/settingsCommon`), the layer keys -beneath it sit below the boundary and are read as one unit by `resolveSettingsSourceLabel` -and objectui's `useSettingsLabel`, and the blanket verdict over them is the declared kind — -`translation/settingsCommon` is already a row of `undrilled-containers.baseline.json`, so -no new pinned artifact is added. The `datasets` row's "WALK BOUNDARY as for every other -group here" inherited the same universal by reference and now cites the record groups only. - -Published data, prose only: `liveness/` is in this package's `files` array, so these -ledgers ship in the npm tarball. No `status` value moves, no schema changes and no gate -verdict changes. The header's summary-count sentence (#15775) is untouched. diff --git a/.changeset/tree-reference-self-only.md b/.changeset/tree-reference-self-only.md deleted file mode 100644 index a5eceee52d..0000000000 --- a/.changeset/tree-reference-self-only.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a `tree` field's `reference`, when present, must name the declaring object — any other target is refused at parse (#14892) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention). Maintainer ruling 2026-09-05 on #14892, option A. - -**What changes.** `ObjectSchema` (and `ObjectExtensionSchema`, judged against -the object it extends) now refuses a field declared `type: 'tree'` whose -`reference` names any object other than the declaring one. The refusal is a -located parse issue at `fields..reference` whose message names both -objects and the three ways out: drop `reference` (it is optional on a `tree`), -name the object itself, or declare a `lookup` if a link to a different object -was meant. `FieldSchema` alone is unchanged — a field does not know which -object declares it, so the judgment lives on the object door. - -**Why.** A hierarchy is parent/child within one object, and that is what every -reader of the type already assumed: the tree renderer's parent-pointer -auto-detection takes the first `tree` field as the object's own parent column, -four prose surfaces said self-reference, and `deleteBehavior` materialises on -`tree` beside `lookup` because a self-referential hierarchy is a relation whose -cascade is exactly the intended semantics. The designer's shared `reference` -input reused one "Target object name" help text for three types, and the one -shipped `tree` example pointed at another object under a hedging label — two -spellings parsed silently, and an example taught a third. The key is now -enforced with one meaning; `reference` stays optional on a `tree` as a -redundant self-annotation, which is also what makes a reference-less `tree` -being classified `relation` (and materialising `deleteBehavior`) coherent. - -**Alongside.** `checkViewCompleteness`'s parent-pointer predicate reads the -same rule: a `tree` field is a detectable parent pointer only when its -`reference` is absent or the object's own name, so a `tree` view bound to an -object whose only `tree` field points elsewhere is reported `view/tree-without- -parent-field` rather than blessed. The designer help text for the shared -`reference` row now says so for `tree`, the showcase `showcase_field_zoo.f_tree` -is a self-reference, and the data-modeling docs say "optional and, if given, -must be this object". - -```ts -// accepted — a self-reference, or no reference at all -parent: { type: 'tree', reference: 'category' } -parent: { type: 'tree' } -// refused at parse — `fields.parent.reference` on object `category` -parent: { type: 'tree', reference: 'department' } -``` - -**Not measured.** Out-of-repo cross-object trees are NOT MEASURED: no customer -application was surveyed for a `tree` field pointing at a different object. -In-repo, every other `tree` author is a self-reference or carries no -`reference`; the objectui pin's unit fixtures are outside this schema's reach -and are listed on the card. diff --git a/.changeset/trigger-packages-repository-directory-15478.md b/.changeset/trigger-packages-repository-directory-15478.md deleted file mode 100644 index e738f25c0d..0000000000 --- a/.changeset/trigger-packages-repository-directory-15478.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/trigger-record-change": patch -"@objectstack/trigger-schedule": patch ---- - -`@objectstack/trigger-record-change` and `@objectstack/trigger-schedule` now declare a `repository.directory` that resolves to the directory they actually live in. - -Both manifests declared a path under `packages/plugins/` that no longer exists in the repository: - -| package | declared | actual | -|---|---|---| -| `@objectstack/trigger-record-change` | `packages/plugins/plugin-trigger-record-change` | `packages/triggers/trigger-record-change` | -| `@objectstack/trigger-schedule` | `packages/plugins/plugin-trigger-schedule` | `packages/triggers/trigger-schedule` | - -`repository.directory` is what npm uses to build the **Repository** deep link on a package page, and what tooling uses to locate a monorepo package's source from its tarball. Pointing it at a path that does not exist sends a reader to a 404 instead of to the source — on packages published today at `17.3.0`. The value ships inside the tarball, so this correction only reaches npm by being published; that is why it carries a changeset rather than `skip-changeset`. - -The residue came from a three-commit sequence on 2026-06-12, and only one of those commits was a pure rename. `f15d6f6f6` **copied** the two packages to `packages/plugins/trigger-*` (26 files, +2222/-19, with all four directories briefly coexisting) and edited exactly one line of each copied manifest — its `name` — leaving `directory` pointing at the path it was copied from; `290c62514` deleted the originals five minutes later; and `ea4941ad8` then promoted `packages/plugins/trigger-*` to a first-class `packages/triggers/` directory as a pure 16-file rename with zero content changes, which made the declared value wrong in a second segment. Six weeks after that, `9a43e042f` (#3380) rewrote `repository.url` and `bugs` in both of these manifests, with the stale `directory` line sitting as unchanged context one line below the edited `url`. So the field was not merely never in anyone's way: one commit edited its immediate neighbour inside the same object, and a later reviewed hunk had the wrong line on screen. Nothing caught it because nothing reads it. - -Scope of this change, stated as a measured set rather than a general claim: over all **81** tracked `package.json` files in the repository, **57** declare `repository.directory`; before this change **55** resolved to the manifest's own directory and **2** did not — the two above. After it, **57 of 57** resolve. No other manifest field is edited, and no package's code, exports or behaviour is touched. The remaining **24** manifests declare no `repository.directory` at all; that population is deliberately left alone here and is reported separately, because whether declaring the field is mandatory is a policy question rather than a correction. diff --git a/.changeset/truthful-stranded-decision-envelope.md b/.changeset/truthful-stranded-decision-envelope.md deleted file mode 100644 index 1f8a3facda..0000000000 --- a/.changeset/truthful-stranded-decision-envelope.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/types": minor -"@objectstack/plugin-approvals": minor -"@objectstack/rest": minor ---- - -An approval decision that lands while its flow run strands now says so in fields, not only in prose. - -`POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all. - -The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said: - -- **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status. -- **`repairable` carries the engine's own discriminator** — `AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise. -- **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built. - -`@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope. diff --git a/.changeset/try-catch-error-value-code-key.md b/.changeset/try-catch-error-value-code-key.md deleted file mode 100644 index 0fbbeed2da..0000000000 --- a/.changeset/try-catch-error-value-code-key.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `TryCatchErrorValueSchema` declares the `code` key the `try_catch` engine binds (#14954) - -`TryCatchErrorValue` — the ONE shape the catch region's author, the engine and the run log share for the value a `try_catch` binds to `errorVariable` (default `$error`) — gains an optional `code: string`: the platform-classified error code (ADR-0112) the failing node's own result carried, e.g. `create_record`'s `DUPLICATE_RECORD`. The engine has bound it since `@objectstack/service-automation`'s #14419 change; the schema was a plain `z.object` that did not declare it, so a round-trip through the declared shape silently STRIPPED the key the engine had put there, and the generated reference page documented four keys where the runtime binds five. The `errorVariable` description on `TryCatchConfig` names `code` too, so the authorable surface documents branching on `$error.code`. - -Typed as an open `string`, deliberately not `StandardErrorCode` and not the ledger union: ADR-0112 D3/D4 with the #9106 amendment make the code vocabulary `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes, and `NodeExecutor` is third-party-registrable, so a closed type would be false the moment anyone registers an executor that throws its own code. The closed-at-every-door rule governs `ApiErrorSchema.code` at an HTTP door; this value is bound in-process and never crosses one. - -Additive and optional: every value that parsed before parses byte-identically, and a binding without a classified code still carries no `code` key — absent means "no classified code", never "nothing failed". Semver: a new optional key on a published schema widens the accept set and the exported `TryCatchErrorValue` type without retiring or renaming anything ⇒ `minor`; no ADR-0087 entry is owed because there is nothing an upgrader must migrate. diff --git a/.changeset/typed-expression-envelope-dialect.md b/.changeset/typed-expression-envelope-dialect.md deleted file mode 100644 index a1f6128cb6..0000000000 --- a/.changeset/typed-expression-envelope-dialect.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: a typed expression slot fixes its dialect on the envelope arm too, and refuses a blank string (#15028, #15035) - - - -**BREAKING** accept-set narrowing on the twelve authorable keys typed -`CronExpressionInputSchema` (`system/CronSchedule:expression`, -`ai/KnowledgeRefreshPolicy:cron`, `api/ScheduledExport` and -`api/ScheduleExportRequest` `schedule.cronExpression`, -`automation/ScheduleState:cronExpression`, `integration/DataSyncConfig:schedule`, -`system/CacheWarmup:schedule`, `system/BackupConfig:schedule`, -`system/DisasterRecoveryPlan` `testing.schedule`) and -`TemplateExpressionInputSchema` (`ai/PromptTemplate:system`, -`ai/PromptTemplate:user`, `data/Object:titleFormat`). Shipped as `minor` under -the repo's launch-window convention for breaking changes. Measured cost: zero -— of the 46 author values probed across the repo, the examples, the docs, the -skills and the objectui pin, every one is a bare string or a same-dialect -envelope. - -**What changes** (`packages/spec/src/shared/expression.zod.ts`): - -- The envelope arm of each typed schema is `ExpressionSchema` narrowed to that - one dialect literal. A cron-typed slot accepts a bare string or - `{ dialect: 'cron', source }` only; a template-typed slot likewise for - `template`. An envelope naming any other dialect — `cel` or `template` on a - cron slot, `cel` or `cron` on a template slot, or the retired `js` — is - refused with ONE `invalid_union` at the slot whose message is the slot's - dialect-only sentence (`TYPED_EXPRESSION_DIALECT_ONLY[dialect]`, exported). - Before, the arm was the unrestricted `ExpressionSchema`, so a cron slot - parsed a `cel` envelope green and whatever read it received an expression it - could not schedule — a copy-paste artifact of the untyped schema, never a - decision. -- The bare-string arm refuses a blank string — empty or whitespace-only, the - notion of blank `EvaluatedExpressionSchema` already applies (`source.trim()`) - — with ONE `invalid_union` at the slot whose message is the slot's - source-required sentence (`TYPED_EXPRESSION_SOURCE_REQUIRED[dialect]`, - exported). Before, `.min(1)` did not trim, so `' '` normalized to - `{ dialect: 'cron', source: ' ' }` on every typed slot. -- The author type narrows with it: `CronExpressionInput` / - `TemplateExpressionInput` no longer admit a foreign-dialect envelope, and the - published JSON Schema and the generated reference page declare the envelope's - `dialect` as that one literal. `TypedExpressionDialect` names the pair. - -**What does NOT change.** No cron syntax is judged at parse time; `croner` -judges it where a schedule is wired (`CronSchedule.expression`, the one cron -slot with a reader); no grammar is restated in spec. `'not a cron'` still -normalizes to `{ dialect: 'cron', source: 'not a cron' }`, deliberately: the -repo's two cron grammars already disagree on 5 of 32 probed patterns, and a -restatement would be a third. `ExpressionInputSchema` and `ExpressionSchema` -are untouched — the untyped envelope still takes every declared dialect, and an -envelope with neither `source` nor `ast` is refused exactly as before. - -```ts -// a cron-typed slot, e.g. defineStack({ jobs: [{ schedule: { type: 'cron', expression } }] }) -expression: '0 9 * * 1-5' // accepted, normalized to { dialect: 'cron', source } -expression: { dialect: 'cron', source: '0 9 * * 1-5' } // accepted verbatim -expression: { dialect: 'cel', source: 'now()' } // refused at jobs.0.schedule.expression -expression: ' ' // refused at jobs.0.schedule.expression -expression: 'not a cron' // accepted — syntax is croner's verdict at schedule time -``` diff --git a/.changeset/unanswerable-target-refusal-opens-with-prose.md b/.changeset/unanswerable-target-refusal-opens-with-prose.md deleted file mode 100644 index 394ad26ac8..0000000000 --- a/.changeset/unanswerable-target-refusal-opens-with-prose.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -`findReferencesToMeta`'s unanswerable-target refusal now opens with prose instead of a machine-shaped `[unanswerable_target]` tag that nothing read. - -``` -before 501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} -after 501 {"error":{"code":"NOT_IMPLEMENTED","message":"References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} -``` - -Nothing else moves: same `501`, same `NOT_IMPLEMENTED`, same envelope position, and the prescriptive sentence ADR-0110 D3 requires is untouched. Callers branch on `code`, which is unchanged; only the human-facing sentence is shorter. - -Why the tag was wrong here specifically. This producer writes a bracketed tag on many refusals, and every other one is the lowercase restatement of that throw's own declared `code` — `[item_locked]` with `ITEM_LOCKED`, `[no_draft]` with `NO_DRAFT`, `[invalid_request]` with `INVALID_REQUEST`. Measured across the two producer files, 30 of the 31 tagged throw sites that declare a code restate it that way. This refusal declares `NOT_IMPLEMENTED`, so its tag was the sole exception: it named a token the envelope carries on no axis, and a repo-wide search finds no parser, no switch, no assertion and no doc that reads it. Per the ruling behind the `/data` door's `FORBIDDEN:` prefix removal, `error` is human language and `code` is the machine token. - -It became worth fixing when the `/meta/:type/:name/references` door started relaying the producer's prose verbatim: before that the whole sentence was replaced by `Internal server error` and the tag reached nobody, and after it the tag was the first thing an operator read on the screen where they decide whether to delete something. The `@objectstack/rest` entry in this release quotes the pre-removal sentence in its example; this entry is the later word on that wire text. - -The absence is now pinned in `protocol.reference-target-unanswerable.test.ts` — nothing pinned the tag, so without a pin nothing would have pinned its removal either. diff --git a/.changeset/undefined-comparand-prescription-position-safe.md b/.changeset/undefined-comparand-prescription-position-safe.md deleted file mode 100644 index 25e5aaf86b..0000000000 --- a/.changeset/undefined-comparand-prescription-position-safe.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `undefined` comparand refusal prescribes the null predicate by its ruled spellings (#14426) - -`parseFilterAST`'s comparand-type door refuses an `undefined` comparand at every -position. Its prescription read "Write null for the null predicate, or omit the -key" — position-agnostic advice that, followed at `{ $gt: undefined }`, produced -`{ $gt: null }`, which the 2026-09-01 ruling refuses one door over (and, at an -`$in` / `$nin` / `$between` member, produced the list shapes refused on -2026-08-31). Two loud refusals to reach one right answer. - -The sentence now names the null predicate by its complete spellings — -`{"$eq": null}` / `{"$ne": null}` — or omit the key, so following it never lands -in a refusal at any position the sentence is emitted at. No accept/refuse -behaviour changes: same envelope (`INVALID_FILTER` / 400), same path, same -accepted-set and NOT-applied sentences. diff --git a/.changeset/unpack-reverification-owner-runtime-loader.md b/.changeset/unpack-reverification-owner-runtime-loader.md deleted file mode 100644 index 6500bbb7e9..0000000000 --- a/.changeset/unpack-reverification-owner-runtime-loader.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/core": patch ---- - -Five source comments in `@objectstack/cli` and `@objectstack/core` stop attributing unpack-time `manifest.integrity` re-verification to the cloud control plane and name the owner this repo has already ruled: the **future runtime loader** (ADR-0025 §3.5 steps 4–7). The enforce leg stays tracked on #11331. - -`packages/spec`'s `manifest.zod.ts` was corrected to that owner in an earlier change, and these five sites were left behind — so the repo stated both things at once. A comment that names the wrong owner costs nobody a build, but it teaches a reader (and a reading AI) to expect a verification that no component performs and that ADR-0025's own status line records as unimplemented. - -- `packages/cli/src/utils/osplugin.ts` — the `.osplugin` packaging docblock, and the `sriDigest` TSDoc. -- `packages/cli/src/commands/plugin/publish.ts` — the integrity-preflight comment. -- `packages/core/src/security/index.ts` — the `verifyIntegrity` export comment. -- `packages/core/src/security/plugin-artifact-integrity.ts` — the verifier's own module docblock, which had explained the module's byte-for-byte portability *by* the wrong owner. It now explains it by the leg itself: the module stays portable to whatever runs unpack-time re-verification. - -**What does NOT change.** The other half of every one of these comments — the digest map is computed by `os plugin build` and self-checked by the `os plugin publish` preflight — is true and is kept verbatim. No accept set, export, signature or runtime behaviour moves; the diff is comment prose only. - -**What moves for consumers, measured on the built output.** `@objectstack/cli` ships `dist/`, and the `sriDigest` TSDoc rides into `dist/utils/osplugin.d.ts`, so an editor's hover on `sriDigest` stops naming the control plane. `@objectstack/core`'s two sites do **not** reach its published bundle — a module docblock and a line comment above an `export {}` are both dropped from `dist/index.d.ts` — so nothing in that package's shipped bytes moves. It is declared here anyway because the pre-correction attribution is quoted in `packages/core/CHANGELOG.md`, a generated record that may not be hand-edited; a changeset naming the package is the only way the correction reaches that published record. diff --git a/.changeset/validate-lowers-inline-handlers-before-parse.md b/.changeset/validate-lowers-inline-handlers-before-parse.md deleted file mode 100644 index e1f71d3754..0000000000 --- a/.changeset/validate-lowers-inline-handlers-before-parse.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/lint": minor ---- - -`objectstack validate` now lowers hooks authored as inline `handler` functions to a metadata body before it parses, so the hook write-set rules judge them there exactly as `objectstack build` and `objectstack lint` already do. - -The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, and `objectstack validate` parsed the normalized stack without lowering — so on that command the whole family returned before reading anything, and a stack `objectstack build` refuses with `hook-api-update-readonly-field` (exit 1) passed `objectstack validate` with exit 0 and no finding. The same statement authored as an explicit `body: { language: 'js', source }` was refused by `objectstack validate` all along, so the silence was the command's intake, not the rule. - -`objectstack validate` now runs the same `lowerCallables` pass `objectstack build` runs before its parse — after its two pre-parse undeclared-key lints, which keep reading the un-lowered stack, and before the schema parse, which reads the lowered view — and hands the rule registry the parsed result as before. This moves what `objectstack validate` accepts in **both** directions, and both are parity with `objectstack build`: - -- **Narrowing (hooks).** A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack validate` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` and (since #16095) `objectstack lint` with the same finding, so nothing that builds green starts failing `objectstack validate`. -- **Widening (actions, and a nameless `functions` array entry).** A plain-object config carrying an inline action `target` callable — `actions: [{ name, label, target: async (ctx) => { … } }]`, or the same on `objects[*].actions[*]` — was **refused** by `objectstack validate` before this change: `ActionSchema.target` is a string, and nothing lowered the function before the parse, so the run exited 1 with `invalid_type` at `actions.0.target` (measured through the real CLI: `valid=false errors=2 invalid_type@objects.0.actions.0.target | invalid_type@actions.0.target`). The same pass now lowers it to a ref string plus `body` on this command too, so `objectstack validate` **accepts** it (exit 0, `valid: true`) — exactly as `objectstack build` accepted it all along (exit 0 on both sides). Likewise a nameless `functions` **array** entry, `functions: [{ handler: async (ctx) => { … } }]`, which the same pass names `anon_fn`: the array form requires `name`, so `objectstack validate` refused it at the parse (measured: `valid=false errors=1 invalid_union@functions`, exit 1) and now accepts it (exit 0, `valid: true`), as `objectstack build` did (exit 0 on both sides). The `functions` map forms and `hooks[*].handler` parse either way and are not affected. These are accepted-set relaxations on a published command; they are declared here rather than inferred from the build's behaviour, and pinned beside the hook legs. -- The warning-severity members of the family now report on inline handlers under `objectstack validate` too; they fail a run only with `--strict`, as every other advisory does. -- The `--json` payload gains no key and the text face prints no new step: the lowering is a view for the parse and the rule registry. A handler the extractor cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by `objectstack lint`'s `hook-body/*` rules and `objectstack build`'s warn-and-bundle line, never guessed at here. -- Nothing about what `objectstack build` accepts changes; on both axes above `objectstack validate` now agrees with it. - -Measured on this repository's ten `objectstack.config.ts` corpus files at `6ba0db4e0` with `objectstack validate --json`, before and after: **exit code, error text and rule-id list identical on 10 of 10 — zero findings change, zero verdicts change.** Six reach the rule registry (the four example apps and the `plugin-auth` / `plugin-security` / `service-i18n` configs); two (`driver-memory`, `plugin-hono-server`) are plugin manifests, not stacks, and are refused at the schema parse — after the lowering point — with the same top-level `unrecognized_keys` on both sides; two (`app-showcase`, the `blank` template) fail at load in the measuring environment, before the lowering point, on both sides. None of the repository's handler-authored hooks writes through `ctx.api`, and none of the ten carries an inline action `target` callable, which is why the delta is zero on both axes rather than either being unreached — a corpus with neither shape cannot see either limb, so both are pinned on their own fixtures; the reach itself is pinned by the card's own fixture, with the body-authored control beside it and a handler-authored hook the family has nothing to say about still passing. - -`@objectstack/lint` carries only the header ledger recording which intakes reach each hook rule; `objectstack validate` moves from "not reached" to "reached". Its behaviour is unchanged. diff --git a/.changeset/validation-failed-code-constant.md b/.changeset/validation-failed-code-constant.md deleted file mode 100644 index 2bb224f933..0000000000 --- a/.changeset/validation-failed-code-constant.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -`ValidationError` publishes its error `code` as an importable constant — the last row of #16159's census. - -`content/docs/kernel/contracts/data-engine.mdx` teaches the convention: catch an engine refusal *by `code`, not `instanceof`*. Following it for record validation meant re-spelling `'VALIDATION_FAILED'` in your own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. - -One new export from `@objectstack/objectql`: - -- `VALIDATION_FAILED_CODE` — `ValidationError`'s ADR-0112 `code`. Thrown by `validateRecord` when an insert/update payload violates the object's own field metadata, carrying the per-field breakdown on `fields[]`. **Additive widening, `minor`.** - -**This row's consumer-side drift is the widest on the card, and worth stating precisely rather than as a slogan.** `'VALIDATION_FAILED'` is re-authored as an inline literal at **148 non-test sites across 33 files** in this repo — but the honest reading of that number is that the large majority are **independent producers** minting their own house-code envelope (`@objectstack/rest`'s response bodies, `plugin-approvals`' `VALIDATION_FAILED: …` message-prefix convention, `plugin-sharing`'s locally-declared `SharingCriteriaValidationError`, `@objectstack/metadata-protocol`'s own class whose docblock calls the code *"this package's own house code"*). Those are not consumers of this class and nothing about them changes. - -The sites this export actually serves are the **recognizers**, and there are four: `packages/types/src/validation-failure.ts` and `packages/rest/src/error-response.ts` both test `code === 'VALIDATION_FAILED' || name === 'ValidationError'`, `packages/rest/src/error-response.ts` tests the wire body's `code` a second time, and `packages/plugins/plugin-auth/src/objectql-adapter.ts` does the same to map an engine refusal onto a `better-auth` `APIError`. Each holds its own copy of the string. **No consumer is rewired here** — the card's scope is the producer-side importable constant, and re-pointing another package's recognizer is a cross-package coupling this card never asked for. - -**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of `ValidationError` gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary. - -**Nothing about the wire changed.** The constant holds text byte-identical to the literal it replaces; the refusal throws the same `code` and the same message as before. Consumers that spell the string themselves keep working unchanged — this adds an affordance and removes nothing. - -**It does not converge `VALIDATION_FAILED` with `VALIDATION_ERROR`.** `EMPTY_CREDENTIAL_REFUSAL_CODE` in the same package is `'VALIDATION_ERROR'`; #16159 explicitly leaves *"whether they should converge"* unruled, and publishing the current spelling keeps that decision exactly as open as it was — a convergence is a breaking rename of a registered wire code either way. A pin test asserts the two are still two, so a future ruling has to argue for itself rather than arrive as a side effect. - -**`ValidationError` was already exported and stays exported.** The constant joins it on the batteries barrel only, matching every existing `*_CODE` in this package; the class is *also* on the lean `./core` entry, so this adds one more instance to the asymmetry #16260 owns — deliberately not decided here. diff --git a/.changeset/validation-message-locale-negotiation.md b/.changeset/validation-message-locale-negotiation.md deleted file mode 100644 index 0a67b9bd8e..0000000000 --- a/.changeset/validation-message-locale-negotiation.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -`accept-language: zh` now reads a Chinese refusal on the response whose labels are already Chinese. - -`@objectstack/spec` has one locale-negotiation rule — `resolveBundleLocale`: exact match, then case-insensitive, then base language, then **variant expansion**, which is the step that reaches a `zh-CN` bundle from a bare `zh`. `pickData` calls it, and every document translator (`translateObject`, `translateView`, `translateDataset`, …) goes through `pickData`. That is why an app shipping only `zh-CN` still answered `accept-language: zh` with translated object, view and dataset labels. - -The write path's message bridge was the one consumer that never negotiated. `ExecutionContext.locale` is the header's first tag verbatim — `preferredLocaleFromHeader` reports what was *asked for* and expands nothing, deliberately, because each of its callers negotiates differently — and the engine handed that tag straight to `II18nService.t()`. A served adapter resolves a locale exactly and then falls to its declared fallback (`FileI18nAdapter.t()` is `resolveFromLocale(key, locale)` then `resolveFromLocale(key, fallbackLocale)`), so `zh` missed the `zh-CN` bundle and the English text came back. The result was a half-translated response an app had no way to see coming: the bundle key was present and correct and the coverage gate was green. - -`ObjectQL`'s validation-message context now resolves the requested tag against what the bridged service reports it holds (`II18nService.getLocales()`), through that same `resolveBundleLocale`. The rule is not re-implemented in the engine — the document translators ask it about a bundle's keys, and this asks it about the service's locales. Authored `objects.._validations..message` text, `validation.field.*` overrides and translated field labels all follow, because they read one locale. - -Unchanged: **which** writes are refused, and everything machine-readable about a refusal — the `code`, the `field`, the `constraint`, the status. Only the language of the sentence moves. `preferredLocaleFromHeader` is untouched, and so is every other caller of it. A request with nothing to negotiate against — no i18n service, a service that cannot report its locales, or a tag no variant of which is on offer — passes through exactly as before. - -`ObjectQL.setI18nService` accepts an optional `getLocales?: () => string[]` alongside `t`. `II18nService` has always required `getLocales()`, so every real service already satisfies it; a partial shim that omits it keeps today's behaviour rather than being negotiated against. diff --git a/.changeset/value-domain-membership-off-vocabulary-refusal.md b/.changeset/value-domain-membership-off-vocabulary-refusal.md deleted file mode 100644 index 06127d79ed..0000000000 --- a/.changeset/value-domain-membership-off-vocabulary-refusal.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): `isValueDomainMember` refuses an off-vocabulary domain instead of failing OPEN on `Object.prototype` names - -`DOMAIN_MEMBERSHIP` is an object literal, so it inherits `Object.prototype`, and -`isValueDomainMember` indexed it with no own-property guard. Measured against the -built artifact (`dist/shared/index.mjs`) on the repo's Node 22 baseline (v22.22.2), -an off-vocabulary `domain` did one of two wrong things — and one of them was a -membership FALSE POSITIVE out of a predicate whose whole job is to refuse -non-members: - -| `domain` | before | after | -|:--|:--|:--| -| `iana_time_zone` (in vocabulary) | `true` for `UTC` | `true` for `UTC` — unmoved | -| `toString` | `'[object Object]'` — a truthy **string** | `false` | -| `valueOf` | a truthy **object** | `false` | -| `constructor` | a truthy **object** | `false` | -| `__proto__` | threw a `TypeError` | `false` | -| `nope`, `''` | threw a `TypeError` | `false` | - -**Why it is reachable.** "Unreachable in-repo" is not "unreachable". The parameter -is typed `ValueDomain` and every in-repo call site names a member, but -`isValueDomainMember` is **published** on `@objectstack/spec/shared` (it is in -`packages/spec/api-surface/shared.json`). A plain-JS consumer, or any caller -handing over a domain string read from **metadata** rather than written in source, -reaches it with no type checking at all — and metadata-sourced strings are exactly -where `constructor` and `toString` show up. - -**This narrows and widens nothing, measured rather than asserted.** Every accepted -`domain` is an own key of the record, so no value that was accepted before is -refused now; the three real domains answer from their own definitions, unmoved. -The change is one `Object.prototype.hasOwnProperty.call` guard — the same spelling -the `iso_4217_currency` definition in the same module already uses — returning -`false` for a domain that is not an own key. A **null-prototype record** was the -other shape available and was not taken: it converts the truthy answers into -throws rather than into `false`, and it costs the `Readonly>` -annotation that makes a vocabulary member added without a definition fail to -compile. - -**Unknown domain answers `false`; it does not throw.** `false` is the narrowing -reading — it refuses more and accepts nothing new — whereas a thrown refusal would -change published behaviour for callers who today receive a truthy value. This is -the same third branch a sister ruling settled for the same defect family: list -reject / own-member value / prototype-resolvable ⇒ reject. - -The pin that existed did not cover this, and the fix is as much about its -POPULATION as about the guard: the totality pin asserted the return `typeof` was -`boolean` but iterated `ValueDomainSchema.options` **only** — exactly the domains -that behave. The new pins put `toString`, `valueOf`, `constructor`, -`hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable`, `__proto__` and plainly -absent words into the population, and a third pin holds that population honest by -asserting every one of them is still outside the vocabulary. diff --git a/.changeset/value-shape-detail-prefers-unrecognized-keys.md b/.changeset/value-shape-detail-prefers-unrecognized-keys.md deleted file mode 100644 index be2c73cfb9..0000000000 --- a/.changeset/value-shape-detail-prefers-unrecognized-keys.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -`os migrate value-shapes` now prescribes the key rename on a legacy `{latitude, longitude}` location, instead of reporting the missing-pair type error. - -A value-shape rejection was read positionally — `parse.error.issues[0]` — at both places the value-shape detail is produced: the write path's warn-first / strict branch, and the exported `valueShapeViolation` the scan imports. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a value whose keys were **renamed** the actionable message sorts last and was discarded. A `location` stored as `{latitude, longitude}` — the exact legacy shape the scan's own header names as one it exists to find — reported `Invalid input: expected number, received undefined`, leaving an operator to derive a rename that edit distance cannot reach (`latitude` -> `lat`), while `LocationValueSchema` had built the prescription and thrown it away. - -Both readers now prefer the undeclared-key issue when the rejection carries one, through a single shared helper — two readings of the same rejection drifting by one clause is how one path prescribes the rename and the other does not. The affected strings are the `os migrate value-shapes` finding `detail`, the warn-first `[value-shape]` log line, and the `invalid_value_shape` error's `detail` under strict enforcement. - -⛔ No verdict moves. The same values are flagged, the same writes are rejected or admitted, and the deployment gate opens on exactly the same evidence — only the operator-facing text changes. - -Scoped by measurement rather than by assumption: of the sixteen types these readers cover, only `location` and `address` are backed by a key-closed object schema, so only they can emit `unrecognized_keys` at all — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half. The defect reaches `address` as well as `location`: every address member being optional rules out a *missing*-member type error, but not a *wrong-typed* declared one, which still sorts ahead of the undeclared-key issue. diff --git a/.changeset/verify-reads-package-owned-collections.md b/.changeset/verify-reads-package-owned-collections.md deleted file mode 100644 index 66c861ab06..0000000000 --- a/.changeset/verify-reads-package-owned-collections.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/verify": patch ---- - -fix(verify): `os verify` no longer reports a green run over a multi-package app it measured nothing about - -Every reader in this package took the artifact's **flattened** top level and -nothing else. A multi-package app whose definitions live under `packages[]` — -the shape ADR-0130 D4's option B emits — therefore reached `deriveCrudCases` -with no objects and no datasources, and reached `rlsProbePermissionSet` and -`declaredPositionNames` with no objects and no positions. Nothing threw. The run -derived zero CRUD round-trip cases, built an empty RLS probe permission set, -minted no persona for any declared position, and printed `✓ verify passed`. - -That is the most expensive place in the platform for a false green: `verify`'s -entire job is to be the thing that notices. A missing collection is at least -missing — zero coverage dressed as a passing run is not. - -The four reads now resolve through `resolveArtifactPackageOrder` -(`@objectstack/core`, ADR-0130 D4+D5), **flattened top level first**: - -- `deriveCrudCases` — the objects it derives cases for, and the datasource-by- - name map behind ADR-0015's double write gate. Both, because objects alone - would leave a write-opted-in federated object judged against an empty - datasource map and reported read-only, i.e. skipped by a verifier that says it - covered it. -- `declaredPositionNames` — one RLS persona per declared position. -- `rlsProbePermissionSet` — the object grants and the owner-scoped narrowing - that are what make an RLS run a probe rather than a report about the object - gate. - -The top-level read still answers first and is returned untouched, so an app on -today's additive artifact gets a bit-identical answer, and a stack that declares -an empty collection (`objects: []` is truthy) still gets an empty one. Only a -top level that does not carry the key at all consults `packages[]`. A malformed -`packages` array now surfaces `resolveArtifactPackageOrder`'s ADR-0112 refusal -instead of reading as "this app declares nothing". diff --git a/.changeset/visibility-bare-identifier-has-occurrence.md b/.changeset/visibility-bare-identifier-has-occurrence.md deleted file mode 100644 index 0b1affa86a..0000000000 --- a/.changeset/visibility-bare-identifier-has-occurrence.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -`visibility-bare-identifier` now reports an identifier written bare beside a `has()` guard in the same visibility predicate. - -`has(status) && status == "qualified"` published clean while `status == "qualified"` — the same defect, without the guard — gated at `error`. The guarded spelling is the one the totality discipline pushes authors toward, so an author who correctly adds `has()` and forgets the `record.` prefix on both halves landed in the silent row. That predicate never evaluates for any record, and an unevaluable `visibleWhen` on a form surface fails OPEN: the field renders and carries its `required: true` into the console's submit check. - -The cause was not the exclusion a `has()` argument earns — that is correct and stays. `firstUndeclaredReference` reads the first error the CEL checker reports and acts only on `Unknown variable: X`; a bare `has(x)` fails that check with `has() invalid argument` instead, and a first error of a different class masked every undeclared reference behind it in the same predicate, whatever it was called. Each `has(…)` call is now masked out of the source before the checker sees it, using the canonical AST's own spans, so the argument occurrence is excluded and every other occurrence is judged exactly as it would be with no guard written beside it. - -Expect new `error` findings on predicates that used to publish clean: a guarded-but-unprefixed `visibleWhen` on a view, page component or form section is now refused at build, validate and lint alike. That is the fail-open shape the rule exists to catch. A `has()` argument that is the only bare occurrence — `has(status)` on its own — stays silent, as it did before. diff --git a/.changeset/webhook-fanout-organization-dimension.md b/.changeset/webhook-fanout-organization-dimension.md deleted file mode 100644 index 3d0e783331..0000000000 --- a/.changeset/webhook-fanout-organization-dimension.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-webhooks": patch ---- - -Webhook fan-out now matches subscriptions on the organization dimension, closing a cross-organization delivery on walled deployments (`OS_TENANCY_POSTURE=isolated|group`). - -`AutoEnqueuer` selected the subscriptions to deliver to by object name and trigger only, and every organization's `sys_webhook` rows live in one cache — so organization A's record events reached organization B's webhook endpoint, signed with B's secret, on first delivery. Both the per-record (`data.record.*`) and the bulk (`data.records.*`) fan-out paths now compare the subscription's own organization (`sys_webhook.organization_id`) with the organization the engine stamps on the event (`DataEvent.organizationId`, `BulkDataEvent.organizationId`): one equality per candidate, no lookup on the hot path. - -What changes for a subscription: - -- **Owned by organization A** — receives only events stamped A. An event that names no organization (an environment-wide row or an object outside the wall on the per-record path; a batch the tenant wall could not attribute to one organization on the bulk path) is not delivered inside the wall — fail-closed — and the first such refusal is logged once with the reason. -- **With no organization** (`organization_id` NULL — for example a package-declared webhook on a walled deployment) — no longer receives any organization-stamped event; the refusal is logged once per subscription. It still receives events that name no organization. On a `single`-posture deployment nothing stamps either side, so delivery there is unchanged. - -An event whose `organizationId` is present but not a non-empty string is dropped loudly as off-contract, delivering to nobody. diff --git a/.changeset/widget-measures-missing-every-family.md b/.changeset/widget-measures-missing-every-family.md deleted file mode 100644 index 97d5c88d9d..0000000000 --- a/.changeset/widget-measures-missing-every-family.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -`widget-measures-missing` — the empty-measure selection is reported on every widget family, not just charts - -`chart-measures-missing` (#15462) reported the authoring placeholder only for the chart -family, but the return that produces it is type-independent. At the `@object-ui` revision -this repo pins (`.objectui-sha` = `a472b0716`), `packages/plugin-dashboard/src/DatasetWidget.tsx:683` -reads `if (values.length === 0)` and returns *"Pick measures (values) for this dataset -widget."* ABOVE `isMetric` (`:423`, over `METRIC_TYPES` at `:343`), `isTable` (`:424`) and -the chart branch alike. So a `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`, `table` or -`pivot` widget that selects no measures renders the same placeholder — the KPI number or -the table the author declared is not drawn at all — and nothing reported it: -`table-count-only` requires `values.length > 0` before it looks, and the rules that iterate -`dimensions[]`/`values[]` are silent on an empty array by construction. - -- **New id `widget-measures-missing`** — a NON-chart declared widget type selects no - measures. Warning tier, suppressible per widget with - `suppressWarnings: ['widget-measures-missing']`, exactly as the chart-family id is. The - message states the consequence its family actually has (the single KPI number is not - drawn / no table is rendered) and the hint names the dataset's declared measures. -- **`chart-measures-missing` is unchanged** — same id, same chart-family population, same - message and same suppression. The condition split rather than widened because "chart" - stops naming it once the population is every family, while the old id is reachable from - the package barrel (a public-surface contract) and may already be written into a board's - `suppressWarnings`. -- `chart-dimensions-missing` stays chart-family only: a dimensionless `metric` or `table` - is what those families are for. - -The two never double-report one widget, in the pin's own order: the measures check runs -before the dimensions one, and `table-count-only` already skips an empty selection. diff --git a/.changeset/window-functions-row-presentation.md b/.changeset/window-functions-row-presentation.md deleted file mode 100644 index 7dfcd8b49e..0000000000 --- a/.changeset/window-functions-row-presentation.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -fix(driver-sql)!: `findWithWindowFunctions()` presents its rows like every other read door — a declared boolean answers `true`, not `1` (#16609) - - - -**BREAKING** on the rows returned by `SqlDriver#findWithWindowFunctions()`. -Shipped as `minor` under the repo's launch-window convention for breaking -changes, matching #3849 — the `aggregate()` / `distinct()` half of this same -gap, which graded `minor` for the same boolean-shape move. - -**What was wrong.** `findWithWindowFunctions()` was the one record read door -that returned `await builder` with no presentation at all: no `formatOutput` -(which every `find()` / `findOne()` row gets) and no `presentReadValue` (which -`aggregate()` / `distinct()` got under #3797 / #3849). So it handed back -STORAGE forms where every other door hands back the declared type's -presentation. Measured on SQLite against the built package, one row through the -two doors: - -``` -find(): { ok: true, closed_at: '2026-01-10T09:00:00.123Z', meta: { k: 1 } } -findWithWindowFunctions(): { ok: 1, closed_at: '2026-01-10T09:00:00.123Z', meta: '{"k":1}', rn: 1 } -``` - -A declared `Field.boolean` answered `1` where `find()` answered `true`; a -declared `Field.object` answered the stored JSON TEXT where `find()` answered -the parsed object. On Postgres and MySQL the same door handed out the client -library's `Date` for `Field.datetime` and the audit stamps — the one shape every -other read door no longer produces — so on the live dialects the divergence was -between this door and the driver's own declared read contract, not merely -between dialects. - -**What moves, per column class and per dialect (storage form → presented form).** Routing this door -through `formatOutput` moves SEVEN classes, not only the boolean and JSON ones -the defect was reported as. `unchanged` means the storage form on that dialect -already WAS the presented form, so the row is byte-identical there — it is -recorded rather than omitted, because the same code path now runs for it. - -| class | sqlite | postgres | mysql | -|---|---|---|---| -| `Field.boolean` | `1` / `0` → `true` / `false` | unchanged (native `boolean`) | `1` / `0` → `true` / `false` | -| `Field.object` (JSON) | `'{"k":1}'` TEXT → `{ k: 1 }` | unchanged (native `jsonb`) | unchanged (mysql2 parses JSON) | -| numeric fields | `'4'` → `4` (a numeric STRING off a legacy TEXT-affinity column) | unchanged | unchanged | -| `Field.datetime` + `created_at` / `updated_at` | unchanged — already the canonical text since #3912; a legacy zone-naive row is repaired to it | `Date` → `'2026-01-10T09:00:00.123Z'` | `Date` → `'2026-01-10T09:00:00.123Z'` | -| `Field.date` | unchanged (`toDateOnly` on text is identity) | unchanged (the driver pins the `date` OID parser to text) | `Date` → `'2026-01-10'` | -| `Field.time` | unchanged | `'09:30:00.5'` → `'09:30:00.500'` | → canonical `HH:MM:SS[.fff]` | -| `external.columnMap` | the row KEY renames: remote column key → local field key | same | same | - -The instant TO is the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` TEXT **on every -dialect**, never a JS `Date` — that is ADR-0053 D-F1 as #16619 landed it, and -this door now runs the same presenter, so it answers the same shape the other -read doors do. - -`external.columnMap` is the one class nobody named on the card, and it is a KEY -move rather than a value move: on an external object with a `columnMap`, the row -this door returns is now keyed by the LOCAL field names, as `find()` has always -keyed it, instead of by the remote physical column names. - -**What to do.** Code that compensated for the storage forms stops being -correct and should simply drop the compensation: - -- `if (row.ok === 1)` → `if (row.ok)`; the value is a real boolean now. -- `JSON.parse(row.meta)` → `row.meta`; it is already the parsed value, and - parsing an object throws. -- `Number(row.amount)` → `row.amount`; a numeric column is a `number`. -- A `Field.datetime` / `Field.date` / `Field.time` / `created_at` / `updated_at` - read through this door is now the same presented value `find()` gives, so a - branch that re-normalised it — or that called `Date` methods on it — can go. -- A reader of an external object with a `columnMap` indexes the row by the LOCAL - field key, not the remote column key. - -**The alias columns are carved out**, which is the design question this door -raised. A window alias is a computed value, not a declared field, so no declared -field's presentation rule touches it. When an alias is spelled the same as a -declared field, SQL had already decided which value wins the key — `select *` -plus ` as ok` projects two columns named `ok` and the row keeps the -LAST, so the computed value wins and the declared column's value is not in the -row at all. That is unchanged. What is now ruled is that the winning value stays -RAW: presenting a `row_number` of `1` and `2` as the declared boolean would fold -both to `true` and destroy the value the caller asked for. This is the same -ruling `aggregate()` already makes for a date-bucketed column aliased as its own -field name. diff --git a/.changeset/zh-cn-dashboard-gap-source-parity.md b/.changeset/zh-cn-dashboard-gap-source-parity.md deleted file mode 100644 index 366fcd963c..0000000000 --- a/.changeset/zh-cn-dashboard-gap-source-parity.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): the zh-CN `dashboard.gap` help text says what its source now says - -`metadataForms.dashboard.fields.gap.helpText` in `zh-CN.metadata-forms.generated.ts` -read 「栅格间距(Tailwind 单位)」. That was a faithful translation of the source it was -extracted against, `Grid gap (Tailwind units)` — but the source has since been rewritten -to `Space between widgets, in steps of 0.25rem (4 = 1rem)`, which deliberately drops the -CSS framework unit an app author never chose and cannot act on, and adds the magnitude -the author can size a dashboard with. - -The leaf kept the retired vocabulary and never gained the magnitude, because bundle merge -fills gaps only: a present-but-stale leaf is not a gap, so no amount of re-extraction -corrects it. It now reads 「组件之间的间距,每级 0.25rem(4 = 1rem)」 — `widgets` is -「组件」 as it is everywhere else in this bundle, the grid framing is gone exactly as it is -upstream, and the conversion is carried so a zh author can size `gap` without reading the -English. - -One leaf. `columns` is unchanged upstream, so 「栅格列数(默认 12)」 stays accurate, and -the other five leaves of this subtree were corrected separately. diff --git a/.changeset/zh-cn-metadata-forms-source-parity-21.md b/.changeset/zh-cn-metadata-forms-source-parity-21.md deleted file mode 100644 index 8d9c188c97..0000000000 --- a/.changeset/zh-cn-metadata-forms-source-parity-21.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): 21 zh-CN metadata-form leaves say what their source says - -`zh-CN.metadata-forms.generated.ts` carries 615 leaves that differ from `en` and hold no -digest in `zh-CN.source-hashes.generated.ts` — LEGACY-TRUSTED values carried in from a -pre-consolidation hand vocabulary (`e0077ea36` deleted a 746-line -`src/metadata-translations/zh-CN.ts` and imported its strings) and never reconciled -against the English the same commit range seeded. A census of all 613 (as the population -then stood) found 26 that assert something the source does not, or drop a distinct concept -the source names. Five of the 26 — the whole `dashboard` subtree — landed in `9f57f1e31`. -These are the remaining 21. - -They are not stale fills and no gate can see them: a stale fill is a byte copy of a -previous source revision, detectable by cross-locale agreement or a recorded digest, and -these are neither. Nor does re-extraction correct them — bundle merge fills gaps only, and -a present-but-wrong leaf is not a gap. - -Three defect kinds, all decided against this bundle's own usage: - -- **Asserts an input that does not exist.** `skill.sections.triggers.description` promised - 「触发关键词」 for a section holding only `triggerConditions` (`triggerPhrases` was - removed with the key); `email_template.fields.variables.helpText` promised a per-variable - 「默认值」 that `EmailTemplateDefinitionVariableSchema` does not declare; - `action.sections.advanced.description` promised 「批量」 after `bulkEnabled` was removed - from that section. -- **Names the wrong technology.** `action.fields.body.helpText` said the body is - 「JavaScript 代码」; an L1 expression is not JavaScript. It now reads - 「L1 表达式或 L2 沙箱 JS 体」 — verbatim the sibling `hook.fields.body.helpText`, which - translates the identical source sentence correctly. -- **Drops a distinct concept the source names.** `object.fields.isSystem.helpText` dropped - 「共享默认为公开」; `view.fields.filter.helpText` reduced a sentence about the shared - visual builder to 「筛选规则」; `permission.sections.identity.description` dropped both - sentences explaining how permission sets stack on profiles. - -zh-CN only: es-ES and ja-JP are untouched here. The 18 looser paraphrases the census -excluded are also untouched. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 0533e0d118..04b848ed3c 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.3.0 + ghcr.io/objectstack-ai/objectstack:17.4.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.3.0 + ghcr.io/objectstack-ai/objectstack:17.4.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.3.0 +FROM ghcr.io/objectstack-ai/objectstack:17.4.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.3.0 +RUN npm install -g @objectstack/cli@17.4.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 49effac452..465eccb91b 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.3.0, released 2026-09-04). +- [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-08). - [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 45ffe164a3..5bd97108de 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.3.0 +image: ghcr.io/objectstack-ai/objectstack:17.4.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 662511ffb6..75bd9cf7dc 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.3.0 +FROM ghcr.io/objectstack-ai/objectstack:17.4.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.3.0 + ghcr.io/objectstack-ai/objectstack:17.4.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.3.0 +FROM ghcr.io/objectstack-ai/objectstack:17.4.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.3.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.4.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 0e73abb995..2cc283e1be 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,151 @@ # @objectstack/example-crm +## 4.0.96 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [429ec1e] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [c1eafe6] +- Updated dependencies [68437d4] +- Updated dependencies [44c849c] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [68f8f77] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [ce8bfc9] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [3e560da] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [f2f6684] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [ac6213e] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [8a12067] +- Updated dependencies [de75e40] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [c1d8f98] +- Updated dependencies [8744de9] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + ## 4.0.95 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 52e8f9f7e7..c7113ba71e 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.95", + "version": "4.0.96", "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 6c1bf2e69f..2477e0a40b 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,130 @@ # @objectstack/example-multi-package +## 0.0.3 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + ## 0.0.2 ### Patch Changes diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json index d42021fed6..4955290209 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.2", + "version": "0.0.3", "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 7810529083..5070c2b109 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,225 @@ # @objectstack/example-showcase +## 0.3.18 + +### Patch Changes + +- e0af1a8: feat(spec)!: `` / `` are retired from the react-tier component contract — `data={{ provider: 'object', object }}` / `type` are the only spellings (#14791) + + + + **BREAKING** — an accept-set narrowing on a published contract. The `REACT_BLOCKS` + ListView entry no longer publishes the `objectName` and `viewType` overlay props that + #11284 had deprecated in favour of ListViewSchema's own `data` / `type`: the generated + contract (`skills/objectstack-ui/references/react-blocks.md`) drops both rows, and + `@objectstack/lint`'s `validate-react-page-props` now REFUSES either spelling on a + `kind:'react'` page with a new `react-prop-retired` error that carries the fix, where it + used to warn and accept. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescription is registered under protocol major 18 + (`ui-react-list-view-binding-aliases-retired`). Maintainer ruling on #14791 (2026-09-07, + director seat summon #17, decision batch #1, option B — retire now, no deprecation window, + 「同意」). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `` | `` | + | `` | `` | + | `` with no binding at all | add `data={{ provider: 'object', object: '…' }}` — it is the required binding on a react page | + + One-line fix: on every `` in react page source replace `objectName="X"` with + `data={{ provider: 'object', object: 'X' }}` and `viewType="K"` with `type="K"`, then re-run + `objectstack validate` — a leftover alias is reported as `react-prop-retired` with this + same prescription, and a list with no data source as `react-prop-missing-required`. + + ## Why now, and why no window + + The contract deprecated both aliases (#11284) while objectui's ListView still read only + `objectName`, so a page written the canonical way validated green and rendered an empty + list. That consumer half has landed and ships in the console this repo pins + (`normalizeListViewSchema` at `a472b071` folds `data.provider === 'object'` onto the key + the renderer reads and takes the author's `type` for the view kind), so both spellings + render today — and the maintainer's standing rule for a spelling with zero external + authors is to retire it at once rather than keep two vocabularies alive. + + ## What else moved + + - `REACT_RETIRED_OVERLAY_PROPS` is a new export of `@objectstack/spec/ui`: the tombstone + ledger (prop → replacement + one-line fix) the lint quotes, the react-tier twin of a + metadata schema's `retiredKey()`. + - `data` is restated on the ListView overlay as its **required** binding (ledgered in + `REACT_OVERLAY_SHADOWS`), so the generated contract marks it ✓ and a `` with no + data source is refused — the check the required `objectName` used to carry. + - `REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']` prescribes the canonical spelling. + - The showcase pages (`crm-workbench`, `renewals-pipeline`, `task-desk`), the published + `objectstack-ui` skill and the react-pages / validating-metadata guides write the + canonical spelling; `@objectstack/lint` exports `REACT_PROP_RETIRED`. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [429ec1e] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [c1eafe6] +- Updated dependencies [68437d4] +- Updated dependencies [44c849c] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [68f8f77] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [fb447b4] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [ce8bfc9] +- Updated dependencies [21c5dcb] +- Updated dependencies [001a83b] +- Updated dependencies [6d4d5d3] +- Updated dependencies [45cfa1b] +- Updated dependencies [7862fb7] +- Updated dependencies [1ca95df] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [8f2ecb3] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [6b66ec7] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [3e560da] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [be92d46] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [f2f6684] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [61821e5] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [ac6213e] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [8a12067] +- Updated dependencies [de75e40] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [33e939f] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [c1d8f98] +- Updated dependencies [8744de9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [b72226f] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] +- Updated dependencies [78bc4ad] + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/service-datasource@17.4.0 + - @objectstack/cloud-connection@17.4.0 + - @objectstack/connector-mcp@17.4.0 + - @objectstack/connector-openapi@17.4.0 + - @objectstack/connector-rest@17.4.0 + - @objectstack/connector-slack@17.4.0 + ## 0.3.17 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 42075eb8db..1ee0377a76 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.17", + "version": "0.3.18", "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 708ff61b8a..1d319a9ba2 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,201 @@ # @objectstack/example-todo +## 4.0.96 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [429ec1e] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [c1eafe6] +- Updated dependencies [68437d4] +- Updated dependencies [44c849c] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [68f8f77] +- Updated dependencies [b1b978c] +- Updated dependencies [cf74a11] +- Updated dependencies [7beaaa3] +- Updated dependencies [e944fdb] +- Updated dependencies [7092d63] +- Updated dependencies [92dc937] +- Updated dependencies [31e7542] +- Updated dependencies [29bef09] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [ce8bfc9] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [6d4d5d3] +- Updated dependencies [7862fb7] +- Updated dependencies [ed5d557] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [61b4eb3] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [3e560da] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [be92d46] +- Updated dependencies [17f8604] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [6f23f0e] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [f2f6684] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [26144c2] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [ac6213e] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [efc5447] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [8a12067] +- Updated dependencies [de75e40] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [c1d8f98] +- Updated dependencies [8744de9] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [b72226f] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/client@17.4.0 + - @objectstack/driver-sqlite-wasm@17.4.0 + - @objectstack/knowledge-memory@17.4.0 + - @objectstack/mcp@17.4.0 + - @objectstack/service-knowledge@17.4.0 + ## 4.0.95 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 0e20505d1f..7986269b5d 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.95", + "version": "4.0.96", "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 eaf309f262..19abadb975 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,160 @@ # @objectstack/example-embed-objectql +## 0.0.36 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [10d05bb] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [26144c2] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] + - @objectstack/spec@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/driver-memory@17.4.0 + ## 0.0.35 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index 476208f119..8df297d12a 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.35", + "version": "0.0.36", "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 a4f64569a0..773ecfcede 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/hono +## 17.4.0 + +### Minor Changes + +- cfb64a6: `createHonoApp` mounts the auth surface where the auth service actually serves, and refuses a prefix it cannot serve it under. + + The documented embed did not reach better-auth at all. `createHonoApp` mounted `/auth/*` under its own `prefix` (default `/api`) while `AuthPlugin` configures better-auth with `basePath: '/api/v1/auth'`, so the two never intersected. The forwarded request could only 404, that 404 fell through to the terminal dispatcher catch-all, and the caller got a `200` with an empty body. Measured on a real kernel with `AuthPlugin`, driving `createHonoApp({ kernel })` with both defaults untouched: + + ``` + POST /api/auth/sign-in/email (valid shape, wrong password) -> 200 {} + GET /api/auth/get-session -> 200 {} + POST /api/auth/sign-up/email -> 200 {} + ``` + + A failed sign-in answering `200 {}` is the silent-success shape: a client that reads `res.ok` sends the user into an authenticated view with no session. The same boot now answers, through the same embed: + + ``` + POST /api/v1/auth/sign-in/email (wrong password) -> 401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"} + GET /api/v1/auth/get-session -> 200 null + POST /api/v1/auth/delete-user -> 401 {"message":"Unauthorized","code":"UNAUTHORIZED"} + ``` + + **Neither default moves.** `prefix` still defaults to `/api` and the auth `basePath` still defaults to `/api/v1/auth`. What changed is which of the two decides the mount: + + - **`@objectstack/hono`** — the `/auth/*` mount is derived from the auth service's configured `basePath`, read at app-construction time, rather than from `prefix`. An auth service that does not expose its base path keeps the previous `${prefix}/auth` mount, so a custom or older auth service is unaffected. + - **`@objectstack/hono`** — a `prefix` the auth base path is not inside now **refuses at construction**, naming both values and every one-line fix that actually constructs: move the app up to the base path's own parent namespace, or configure better-auth down under the prefix (carrying the leading slash the prefix may itself be missing). ⛔ A direction with no working answer is not offered rather than offered wrongly — a single-segment base has no usable parent prefix, because `''` falls back to `/api` and `'/'` mounts every other route of the app under `//`. Previously that composition served auth outside the namespace the host asked for while `${prefix}/auth/*` answered `200 {}`. This is the one behaviour that can stop an app booting: a deployment passing, say, `prefix: '/custom'` alongside the default auth base path was already not serving auth, and now says so instead of failing silently. + - **`@objectstack/plugin-auth`** — `AuthManager.getBasePath()` is new and public: the configured base path in its one normalised spelling (a leading slash added when absent, trailing slashes stripped), which is the spelling an HTTP adapter can mount on. ⛔ **Purely additive — no configured `basePath` changes anything this package does.** better-auth is still handed the configured string verbatim, and the route-ownership walk still normalises its own copy; that copy now reads this accessor instead of repeating the expression. ⛔ It is **not** the string better-auth receives, and it is **not** the single definition of the value. `getAuthIssuer()` and `getMcpResourceUrl()` still derive their own copies and are deliberately unchanged: they are the OAuth `iss` this AS advertises and the RFC 8707 resource identifier a token's `aud` is matched against, both compared by exact string by relying parties, so retiring their copies moves published identifiers and is not a tidy-up that belongs on this card (filed as #16399). Normalising the string handed to better-auth is that same move seen from the other side — it shifts the access-token `iss` off `getAuthIssuer()`, and this manager's own `verifyMcpAccessToken` then rejects every MCP token the deployment mints. Measured on a real `client_credentials` token, and not done. + +### Patch Changes + +- 1c00b01: The Hono adapter's `/auth/*` mount yields only a 404 that disclaims ownership + + `createHonoApp`'s `${prefix}/auth/*` mount forwards every request under it to the + kernel's `auth` service and, since #4117, hands the request on to the rest of the + chain when that service answers 404 — which is what keeps `/auth/me/permissions` + and `/auth/me/localization` reachable through the gated `dispatch()`. The yield + had only the status to go on, so it could not tell "I do not serve this path" + from "I serve it and the answer is 404". + + Measured on a real boot through this adapter (a real kernel with `AuthPlugin`, + `prefix: '/api/v1'`), `GET /api/v1/auth/delete-user/callback?token=…&callbackURL=…` + answered `404 {"message":"Not found","code":"NOT_FOUND"}` from better-auth and + `200 {}` on the wire. `plugin-auth`'s route ledger carries that route under its + `disabled` disposition precisely because it is published and answers 404, so the + ledger's recorded answer was true of the auth service and false on this adapter's + wire. Nothing had to be composed in for that: the `${prefix}/*` dispatcher + catch-all this same function registers is terminal and answers `200 {}` for paths + under `/auth/`. + + The mount now asks the auth service whether its own router serves the path, via + an optional `ownsRoute(request)` — the seam `AuthManager` grew in the plugin-side + fix for the same defect — and yields only when it does not. Every answer that is + not a literal `true` (no such method, a throw, anything else) means yield, so a + service predating the method behaves exactly as before and a failure to decide + can never cost the ordering-independent surface. + + ⛔ The mount is unchanged and still claims `${prefix}/auth/*`; 401/403 were never + yielded and still are not. What narrowed is only which 404 may be handed on. +- fc0a783: `createHonoApp` no longer discards the status and body of a dispatcher result that is already a `Response` — it hands the object on unchanged. + + `HttpDispatcherResult.result` is declared for direct response objects ("For flexible return types or direct response objects (Response/NextResponse)"), and the runtime really puts one there: the `/auth` domain returns whatever the auth service answered as `{ handled: true, result: response }`. The adapter's `toResponse` had no arm for that. It tested `result.type` for the `redirect` and `stream` descriptors, a `Response` spells neither, and the fall-through was `c.json(res, 200)` — so the real status was replaced by a literal `200` and the real body by `JSON.stringify` of a `Response`, which is `{}` because a `Response` has no own enumerable properties. + + Measured on a real boot through this adapter (a real kernel, the real dispatcher, `prefix: '/api/v1'`), an auth service answering an honest 404 on a path it does not serve: + + ``` + GET /api/v1/auth/me/permissions + the door answered : 404 {"message":"Not found","code":"NOT_FOUND"} + the caller read : 200 {} + ``` + + A discarded status is not a missing answer, it is a wrong one that reads as success: `res.ok`, `status === 200` and "nothing threw" all report a refusal, a 404 or a 500 as a completed operation, and a fail-closed guard written as `if (!data) return false` does not fire on `{}` because `{}` is truthy. Callers embedding this adapter now see the status and the body the door actually produced, along with its headers, and a non-JSON body arrives byte-identical instead of being re-serialized. + + The check is `instanceof Response` and nothing else: the `redirect` and `stream` descriptor arms, the plain-object rendering after them, and the separate `response` arm all behave exactly as before. +- Updated dependencies [429ec1e] +- Updated dependencies [233222e] +- Updated dependencies [e9fcd6b] +- Updated dependencies [98191d2] +- Updated dependencies [f1a1028] +- Updated dependencies [c1eafe6] +- Updated dependencies [68437d4] +- Updated dependencies [44c849c] +- Updated dependencies [68f8f77] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [ce8bfc9] +- Updated dependencies [bca21f7] +- Updated dependencies [2c753fe] +- Updated dependencies [fa85759] +- Updated dependencies [5f7fa1d] +- Updated dependencies [088f761] +- Updated dependencies [3e560da] +- Updated dependencies [6615a02] +- Updated dependencies [cf9bda4] +- Updated dependencies [f2f6684] +- Updated dependencies [ac6213e] +- Updated dependencies [4db3c61] +- Updated dependencies [92b5d7f] +- Updated dependencies [8a12067] +- Updated dependencies [de75e40] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [8744de9] +- Updated dependencies [c5d6803] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [3d3f60e] + - @objectstack/runtime@17.4.0 + - @objectstack/plugin-hono-server@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index a24d20a546..e5be4d6309 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.3.0", + "version": "17.4.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 cf15cb8e37..249dde86e8 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,152 @@ # @objectstack/account +## 17.4.0 + +### Patch Changes + +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index f84317e009..51fc18f55a 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.3.0", + "version": "17.4.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 11deed6d49..da1d73cd61 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,152 @@ # @objectstack/setup +## 17.4.0 + +### Patch Changes + +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index 8a664845d6..a9cec1b067 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.3.0", + "version": "17.4.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 bab2e5390d..ef519aee3d 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,152 @@ # @objectstack/studio +## 17.4.0 + +### Patch Changes + +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index 09315e787f..dce1771982 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.3.0", + "version": "17.4.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 2b1ff39a66..ee4858ec7f 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,1279 @@ # @objectstack/cli +## 17.4.0 + +### Minor Changes + +- 95d5cbb: Ratify `./hook-body` as a public subpath export — `extractHookBody`, `HookBodyExtractionError`, `HookBodyRefusalKind` and `ExtractedBody` were reachable as a deep `dist/utils/extract-hook-body.js` import until #13123 sealed the surface, and an app's hook-body fidelity harness (hotcrm's `test/helpers/action-sandbox.ts`) consumes them to run the SAME body-only lowering `os build` ships through the real QuickJS runner, so a test executes what production executes rather than a lookalike. The #13123 body names exactly this remedy for an out-of-repo consumer — ratify the subpath as public surface rather than read `dist/` paths — and 17.3.0 applied it to `./console` for cloud's `objectos-runtime`; this applies it to the second consumer (#15325). `@objectstack/cli/hook-body` is a dedicated entry that re-exports those four names and nothing else; the deep `dist/` path stays sealed. Also admits `./package.json`, so the ordinary tooling idiom of reading a dependency's own manifest resolves again. + + `minor`, not `patch`: a new subpath on a published package's `exports` map is a purely additive widening of its public surface — a new accepted key — which takes at least `minor` under the maintainer's 2026-09-04 rule (decision batch #35, on #15294) in the Check Changeset step's "WHICH LEVEL" prose; the commit type never lowers it. +- 2da2901: `os lint --strict` makes warning-severity findings fail the run, so an app can rely on the platform's warning-level rules as its gate instead of re-implementing them locally (#15935) + + Only an `error` failed `os lint` before. `packages/lint` ships ≈250 authoring rules, 119 of them at `warning`, and a run with any number of warnings and no errors exited 0 — so an app that wanted one of those rules to gate its CI had to re-implement it locally at error level, or bolt a script onto the JSON output to promote a family by hand. + + New public flag: **`os lint --strict`**. With it, a run with one or more `warning`-severity findings exits 1 exactly as an `error` does, and the console says why, naming the count and the flag: + + ``` + ✗ 1 warning(s) fail this run under --strict (a warning is advisory without the flag) + ``` + + `suggestion`s stay advisory under both. ⛔ The default is unchanged: without the flag the same stack still exits 0, and no existing `os lint` expectation moves. + + The `--json` face carries the verdict so a gate can read it without re-deriving it from the counts. Two keys, unconditionally present on every project-lint payload, flag or no flag: + + ```json + { "passed": false, "errors": 0, "warnings": 1, "suggestions": 0, "strict": true, "failing": 1 } + ``` + + `strict` says whether the flag was in effect; `failing` is the count the exit code was read from — `errors`, or `errors + warnings` under `--strict`; and `passed` is `failing === 0`, the same statement the exit code makes — so `--strict --json` on a warning-only stack reads `passed: false` beside exit 1, never `passed: true` next to a failing exit. + + Not in this change: per-rule severity configuration, any change to a rule's severity, and `--eval` mode, which keeps its own pass bar (`--eval-min`). +- 000fd05: `os generate migration` now emits the character column `driver-sql` actually creates, in both the TypeScript and the SQL format. + + A `text` field took `VARCHAR(255)` from both generators while the platform creates an unbounded `text` column for it, so a 300-character value the platform stores was refused by every generated table with `value too long for type character varying(255)`. Enumerating the whole character-column family found the same disagreement in eight more places: the SQL format gave `url` and `phone` and `color` widths nothing on the platform has (2048, 50 and 7 against the platform's 255), and neither format read a field's declared `maxLength` at all, so a `maxLength: 400` email was `varchar(400)` on the platform and `varchar(255)` in the migration generated for it. + + All of them now follow the platform's own three answers: the text family is unbounded unless the object KEYS the column — a field declared `unique`, or one an object-level `indexes[]` entry lists, takes `varchar(maxLength)` up to the 768-character key-part ceiling, exactly as the platform builds it, and stays unbounded above that ceiling or with no declared bound, where the declared bound is enforced at the write seam instead — the string family takes its declared `maxLength` verbatim in both directions, and TEXT rather than a clamp when it exceeds what a `varchar` can express, and the remaining string-valued types keep the default width and ignore a declaration, because their stored value is an option code or another row's id rather than the declared string. + + The keyed half was measured after the rest: `{ type: 'text', unique: true, maxLength: 100 }` built `varchar(100)` on the platform and `text` in both generated tables, so a 300-character value the platform REFUSES was accepted by every generated table — the same disagreement as the headline row, pointing the other way. + + This scopes to PostgreSQL, which is the only dialect `os generate migration --format sql` claims. +- ffe058a: **BREAKING** `os lint --eval --generator ""` now refuses instead of quietly running the offline eval, matching the rule the same flag already follows without `--eval`. + + Eval mode guarded the generator load with a truthiness test, so an empty string fell straight through it: the module was never loaded, no warning was printed, and the `Failed to load generator` message that exists for exactly this failure was never reached. What came out was the ordinary offline report — `Mode: offline`, `5/5 passed · mean 99/100`, exit 0 — to someone who had asked for a live run and read that score as their generator's. + + It was not merely ineffective. Driven against the same command with the flag absent entirely, and with the elapsed-time token normalised, the two runs produced byte-identical stdout, empty stderr and the same exit code on every face the command has, `--json` included. There was no channel on which the difference was visible. The usual way to type it is `--generator "$GEN"` in a script where `GEN` is unset. + + The guard now tests whether the flag was provided rather than whether its value is truthy — the same test `os lint --generator` outside `--eval` has used since it started refusing — so one flag has one rule for "the operator typed it". No new failure shape is introduced: an empty string is a path that names no module, so it answers through the load path an unresolvable path already answered through, with the reason on `error`, exit 1, and on `--json` a single JSON document. No error code is invented for it. + + A scripted invocation that passed an empty `--generator` to `os lint --eval` now exits 1 with the reason, where it previously exited 0 having silently scored the bundled corpus instead. Every other invocation is untouched: `--eval --generator ` still loads the module and scores live output, `--eval` alone still scores the bundled corpus offline, and a plain project lint is unchanged. + + +- 9c3fda5: `os lint --eval --json` now carries the ADR-0112 error carriers on its generator-load failure, instead of a bare `{error}`. + + Eval mode's `--generator` load failure was the one exit on that mode with a machine face, and it was off-envelope: the `catch` built its human message and discarded the error object, so `code` and `httpStatus` could never reach the payload. A consumer that reads `code` to branch got a real code from the same command's project-lint catch-all and `undefined` from eval mode — the case a consumer is most likely to be caught by, because the face is present and looks answerable. + + The exit now spreads `errorCodeFields(error)`, the same helper the project-lint catch-all spreads, so both failure faces of `os lint` are built from one source rather than two hand-written shapes. + + Nothing is minted. `errorCodeFields` passes a producer's code through and returns nothing otherwise — ADR-0112's ledger stays the authority on who may mint a code — so the exit is polymorphic in exactly the way its sibling already is. Measured on the command's own output, across the reachable load-failure classes: + + - a generator whose top-level evaluation throws a coded failure (an SDK refusal as the module builds its client at import) now answers `{"error": …, "code": "FORBIDDEN", "httpStatus": 403}`; both keys were being dropped; + - a file the generator reads at import that is missing now answers `code: "ENOENT"`, the errno vocabulary already documented for this command, and no invented HTTP status; + - an unresolvable path or a syntax error — esbuild's own build failure, which carries neither key — still answers a bare `{error}`, as does the hand-thrown "module must default-export a function". + + The human (non-`--json`) path, the eval report exit, and offline eval are unchanged. +- be75493: **BREAKING** `os lint --generator` now refuses to run without `--eval`, instead of accepting the flag and ignoring it. + + The flag's own description has always ended "Requires --eval.", and nothing checked it. `--generator` is read only by eval mode, so outside `--eval` the flag reached no code at all: `os lint --generator ./gen.mjs` linted the current project, exited 0 with "All checks passed", never loaded the module, and named the flag nowhere on either the human face or `--json`. A path that did not exist was accepted just as readily. Someone who meant to score a live generator got a successful-looking run whose generator was never called, with nothing said. + + The refusal is this command's own, not the argument parser's, so it keeps the shape the command's other failures already have: the reason on `error`, exit 1, and on `--json` a single JSON document with stdout still reserved for the machine. No error code is invented for it. + + A scripted invocation that passed `--generator` outside eval mode now exits 1 with the reason, where it previously exited 0 having silently skipped the generator. Eval mode itself is untouched: `--eval --generator` still loads the module and scores live output, and `--eval` alone still scores the bundled corpus offline. + + +- 0cde37d: `objectstack lint` now judges hooks authored as inline `handler` functions with the same write-set rules it already applied to explicit `body` hooks. + + The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, so on the stack `objectstack lint` handed the rule registry the whole family returned before reading anything — while the reference app authors every one of its hooks that way. `objectstack build` never had the gap: it lowers each inline handler to a metadata body before it parses and judges the lowered stack. + + `objectstack lint` now hands the registry's parsed-tier rules that same lowered view (the `lowerCallables` pass the build runs), so a handler-authored hook writing a `readonly` field through `ctx.api` is refused by the pre-flight exactly as the build would refuse it. What this does and does not change: + + - A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack lint` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` with the same finding, so nothing that built green fails lint red. + - The warning-severity members of the family (`hook-body-write-unknown-field`, `hook-api-update-readonly-when-field`, …) now report on inline handlers too; they never fail a run without `--strict`. + - Nothing about what `objectstack build` accepts changes, and `objectstack validate` — which parses without lowering — is unchanged and still does not see handler-authored hooks; both are recorded in the rules' headers. + - The lint input is never mutated: rules that read the live function value (`hook-body/not-lowerable` and its siblings) keep seeing it, and a handler the extractor refuses has no body on any command, so no rule guesses about a body that was not produced. + + Measured on this repository's own four example apps (`examples/app-crm`, `app-showcase`, `app-todo`, `app-multi-package`), before and after: **121 findings before, 121 after — row for row identical, and zero at `error` on both sides.** No config that passes today starts failing. Two of the six hooks in that corpus are `handler`-authored and were invisible to this family before; their bodies write nothing the family objects to, which is why the delta is zero rather than the family being unreached. The reach itself is pinned separately, with a body-authored control beside every leg. + + `@objectstack/lint` carries only the header ledger recording which *intakes* reach each hook rule — the call sites of `runAuthoringRules`, which are more numerous than the three commands (the scaffold validator is a fourth, and it has always reached this family). Its behaviour is unchanged. +- 4a1a3b0: `os lint` no longer crashes on a localized label. + + `convention/label-case` indexed its argument (`label[0].toUpperCase()`) on a parameter annotated `string`, while every call site reaches it through `any`-typed config walking and the spec does not require a label to be a string: `I18nLabelSchema` is `z.union([z.string(), InlineLocaleMapSchema])`. On the map form `label[0]` is `undefined`, the rule threw a `TypeError`, and the throw escaped `lintConfig` into the command's catch-all — so an author who localized an app label or a list-view label could not lint the project at all. Every face exited 1 with `Cannot read properties of undefined (reading 'toUpperCase')`, naming no rule, no path and no remedy, on input `ObjectStackDefinitionSchema` parses clean. + + The rule now checks `typeof label === 'string'` first. Two of the four carriers it walks accept the inline locale map — `apps[].label` (`AppSchema`) and a view's `list` / `listViews.*` labels (`ListViewShapeSchema`); the other two are `z.string()` and reject the map at the schema door (`objects[].label`, `objects[].fields.*.label`). + + **Nothing about a plain string label moves.** Same warning, same message, same `fix`, same path, on all four carriers — that is pinned per carrier rather than asserted. + + **The rule deliberately says nothing about a localized label**, rather than resolving the map and case-checking one of its entries. Case is a property of a literal, and deciding which locale entry a case verdict is taken against is a product call, not a lint call. Widening the rule that way is a separate change. +- 25011b0: feat(cli): point `@objectstack/cli/console` at a public barrel with a name-and-shape pin + + **BREAKING**: `@objectstack/cli/console` publishes three names instead of thirteen. Ten names it used to resolve no longer resolve through that subpath. + + The subpath pointed straight at `dist/utils/console.js` — an internal module — and carried no surface pin of any kind, neither names nor shapes. Two assertions did exist and neither is one: `./console` was held among the declared `exports` keys, and the specifier was held to resolving from the packed tarball. Both answer *is the door open*; neither can answer *what is behind it*. So all thirteen of that module's top-level exports were public API, and every export it gained afterwards became a permanent public contract the moment it landed, silently. + + The subpath stays open and now points at a dedicated barrel, `dist/console.js`, which re-exports by name (no star) exactly the three helpers the one ledgered out-of-repo consumer uses to mount the Console SPA: + + - `resolveConsolePath` + - `hasConsoleDist` + - `createConsoleStaticPlugin` + + Those three keep their existing shapes exactly, so a consumer importing only them compiles unchanged. + + These ten are no longer reachable through `@objectstack/cli/console`: + + - `CONSOLE_PATH` + - `ConsoleShaDrift` + - `DRIFT_OVERRIDE_ENV` + - `ResolveConsoleOptions` + - `createRuntimeAssetsPlugin` + - `decideConsoleMount` + - `detectConsoleShaDrift` + - `formatConsoleShaDriftRefusal` + - `formatConsoleShaDriftWarning` + - `isConsoleVersionCompatible` + + Nothing was deleted. `utils/console.ts` still exports all thirteen and every in-package caller still imports it directly; what these ten lost is only the ability to be named through a published specifier. `ResolveConsoleOptions` in particular is still `resolveConsolePath`'s parameter type, so the options object a caller passes keeps working structurally — only the type's name is no longer importable from this subpath. + + `decideConsoleMount` and `createRuntimeAssetsPlugin` were retired on a measurement rather than by default: every reference to either name in this repo is inside `packages/cli`, the consumer-specifier ledger names neither, and `decideConsoleMount`'s own docblock scopes it to `isDev` and states that no published install can reach the refusal it exists to produce. + + `packages/cli/test/published-subpath-console.pin.test.ts` now holds the packed `.d.ts` to exactly the three names and their shapes, compiled by a real consumer outside the workspace, with a control per retired name. Re-admitting any of the ten is a deliberate, reviewed, `minor`-bumped edit to that barrel and that pin. + + +- cee3961: **BREAKING** `os create ` now refuses a project name that npm refuses, and refuses it before it writes anything. + + `os create plugin "My App"` used to exit 0 having written `./plugin-My App/`, carrying a manifest that read `name: "@objectstack/plugin-My App"`. Nothing failed at scaffold time, so the invalid name surfaced later at `npm publish`, in the terminal of whoever ran it next. `os init` has always refused that same input before touching the disk. The rule set is now shared between the two scaffolders rather than restated in one of them, so they answer the same way. + + `os create` also refuses a name whose composed scoped package name exceeds npm's 214-character ceiling. `@objectstack/plugin-` spends 20 of those characters before the name begins, so a name that `os init` accepts can still compose to one npm rejects; that check sits next to the composition rather than in the shared rule set. + + A scripted invocation that passed an invalid name now exits 1 with the reason on stderr, where it previously exited 0 and produced a project that could not be published. + + +- cf6b671: `os create` now emits a project that installs outside this monorepo. + + Every project the command scaffolded declared its `@objectstack/*` dependencies + with pnpm's `workspace:*` protocol, extended a `tsconfig.json` two directories + above itself, and was written into this repository's own `packages/plugins/` or + `examples/` by default — so a developer following the documented command got a + project `pnpm install` refuses. The default emission is now standalone: + + - `@objectstack/*` dependencies are published semver ranges pinned to the + version of the CLI that generated them; + - the emitted `tsconfig.json` is self-contained and extends nothing; + - the project is written to `./` in the current directory (or `--dir`); + - a `pnpm-workspace.yaml` carries the build approvals a fresh `pnpm install` + needs on pnpm 11. + + The `plugin` template also emits `init` where it used to emit `initialize`. + `initialize` is not part of the `Plugin` contract, so the scaffold did not + type-check under its own `strict` config (TS7006 on the untyped `context` + parameter) and `kernel.use()` refused the plugin at load with + `Plugin init function is required` — a defect the kernel protocol docs + previously carried a warning about instead of a fix. + + The previous monorepo-internal placement is still available for ObjectStack + platform work as the explicit `--in-repo` flag, which keeps the `workspace:*` + specs and writes into `packages/plugins/` or `examples/`. +- c8e5ac6: feat(cli)!: retire `os create example` — it was a weaker `os init` plus a README, and the refusal now names `os init` (#16483, #15531) + + **BREAKING** — a published CLI surface is removed. `os create example ` is a + command a user can run today and cannot run after this release. Graded `minor` + rather than `major` under this repo's lockstep launch-window convention (no + package here has ever shipped a `major` changeset); the break is stated here + instead of in the number. + + + + **No alias and no deprecation window.** `os create example` will not come back, + so change the command rather than pinning an older CLI. (Those terms are recorded + on card #16483 and are pending maintainer confirmation — the removal itself is + settled by the #15531 batch entry below. The behaviour ships either way, and it is + the same shape `os g agent` already shipped.) + + #15531 rendered the real emission of both scaffolder families and hashed it file + by file. The only template-level duplication left between them was this one + template: `os create example` wrote a **subset** of what `os init` writes, plus + one README. The two families' emission policy is already unified through four + shared exports, so the remaining duplicate was the template itself — and the + ruling (decision batch #66, option B) is that it goes, not that the two command + families merge. They emit two different artifacts: a kernel code `Plugin` is not + a declarative app, and collapsing them would make that collision structural. + + **What to run instead** + + ```bash + os init # a full application project + os init -t empty # config only, no src/objects + ``` + + `os init` writes the same `tsconfig.json` the retired template did (byte-identical, + measured) and an **equivalent** `objectstack.config.ts` — both manifests are + `ManifestSchema`-valid but they are not the same bytes: the retired template wrote + `name: ''`, `description: ' example application'` and commented-out + barrels, where `os init` writes a title-cased `name`, an empty `description` and no + barrels. On top of that `os init` adds `src/objects`, a `.gitignore` and the + dependency install the retired template never had. + + **`os create plugin` is unaffected.** It scaffolds the kernel code `Plugin` + contract — `src/index.ts` exporting a `Plugin` with `init` / `destroy`, built by + `tsc`, publishable as `@objectstack/plugin-` — which `os init` does not + emit. `os create`'s flags and its standalone emission policy are unchanged for + `plugin`. ⚠️ `--in-repo` is narrowed rather than untouched: the flag survives, but + its `examples/` placement is removed with the template and gets no + replacement — `--in-repo` now only ever lands in `packages/plugins/plugin-`. + + **The removal is a signpost, not a deletion.** `os create example` still answers: + it exits **1** and names `os init`, rather than falling through to the generic + `Unknown type:` roster. A reader arriving from an older tutorial or a CI script + that still calls it learns what replaced it instead of learning only that their + spelling is off the list. Pinned end-to-end by driving the real CLI in + `packages/cli/test/create-example-retired.e2e.test.ts`, which asserts both halves + — the non-zero exit **and** the message naming `os init`. The four public doc + pages are held to the same promise by a SEPARATE pin, + `packages/cli/test/create-example-retired-docs-parity.test.ts`: it spawns nothing, + so unlike the `.e2e` file it runs in the per-PR tier rather than the nightly one. +- acab609: `os serve`'s runtime state file is keyed by the PROJECT, not by the environment id alone — so two projects on one machine stop overwriting each other's supervision record. + + + + **BREAKING** for anything that opens the runtime state file by its old name. Shipped as `minor` under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. The file `os serve` writes under the ObjectStack home was named `runtime..json` and is now named `runtime...json`. + + `os serve` publishes `{ pid, port, url, environmentId, startedAt }` to a file under the ObjectStack home, so a supervisor can answer *"is my server running, and where?"*. That file was named `runtime..json`, and both halves of where it lived were machine-global: `resolveObjectStackHome()` takes no arguments (it reads `OS_HOME`, else `~/.objectstack`), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary `local` environment, therefore wrote one file. + + Driven with two real boots, two project roots and one home, that produced two failures with one cause: + + - project B's boot replaced project A's record, so a reader asking about A's server was answered `pid`/`port`/`url` belonging to **B** — confidently, while A's own server was still alive and still listening elsewhere; + - project A's shutdown then deleted the file that by that point described **B**, leaving a running server with no supervision record at all. + + The file is now `runtime...json`, where the project component is a sanitised basename plus a short digest of the served app's root — the same root `serve` already resolves for host-anchored package loads. The payload is unchanged: no new key, and in particular no database path (which #15374 ruled out deliberately, because it would turn a best-effort supervision file into an identity contract). + + **If you read this file:** a reader that hard-codes `runtime..json` now gets `ENOENT` rather than a stale or foreign record — a loud, correct answer to "is my server running", where the old name could only give a confident wrong one. Readers that glob `runtime.*.json` inside a home they pinned themselves (as `scripts/publish-smoke.sh` does) are unaffected. A `runtime..json` left over from an earlier version is no longer written or cleaned up by `os serve`; delete it once. + + **Which root the project component is taken from**, for a supervisor that has to reconstruct the name out of tree: it is the app root `serve` anchors at, which is the config file's own directory when that file exists and that directory carries a `package.json`, and the process's working directory otherwise. Two boundaries follow, stated rather than fixed: the same app served from two working directories without a manifest keys two files, and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key differently — each spelling gets its own file, and each is internally consistent. + + Two boots of the *same* project from the *same* anchor still share one file, which is the same-project case and unchanged here. +- 984f1da: `scoreMetadata` no longer scores a stack whose linter crashed as a perfect one. + + The metadata rubric is two halves: a schema parse and the lint sweep. When `lintConfig` threw, the scorer caught the throw and continued with `issues = []` — so the penalty was 0 and a stack half of whose rubric never ran came back as **100 / grade `A` / `valid: true`, every count zero, `issues: []`** — byte-for-byte the verdict a genuinely clean stack gets. "The linter found nothing" and "the linter never ran" collapsed into the better-looking one. + + The crash is reachable on a schema-valid stack: a localized `label` (`{ en: 'Todos', 'zh-CN': '待办' }`) on an app, or on a view's `list`, parses clean and makes the label-case rule throw a `TypeError`. That rule's crash is a separate defect, filed on its own; what changes here is that the scorer stops publishing a clean verdict it did not earn. + + A crashed lint run is now recorded in every carrier a consumer might read, because reading any one of them has to be enough: + + - **`lintError`** — a new optional string on `MetadataScore`, carrying the thrown message. Set only when the linter could not run; absent when it ran and reported errors, which is a lint verdict rather than a missing one. It reaches the CLI's published payload through `os lint --eval --json`, on `results[].score`. + - **A synthetic `error` issue** (`rule: 'rubric/lint-crashed'`, exported as `LINT_CRASHED_RULE`) — so `issues`, `counts.errors` and `valid` carry the failure too. This is what makes the eval harness fail the case: its `passed` reads `counts.errors`, and would never have seen a new field. It still fails at `--eval-min 0`, where the score alone stops discriminating. + - **`score: 0` / grade `F`** — the only channel `os lint --score --json` publishes, and the same refusal `unscorableScore()` already gives an eval case there was nothing to judge. + + The schema half is untouched and still reported: `schemaErrors` and `counts.schemaErrors` say exactly what the parse found, which was the defensible half of the original intent. +- ec0a6e7: feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064) + + A roll-up value has three producers — the insert-time seed, the child-write + recompute, and the one-off backfill — and **declaring a summary field on an + object that already has rows reaches none of them**. For `count`/`sum` the + backfill repairs that as a side effect (every `NULL` is a hole to it). For + `min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the + function alone, so "never computed" and "no child rows" were indistinguishable, + the column stayed `NULL` on every pre-existing parent, and the report said + `filled: 0` — a false all-clear that a timed flow built on the column then + turned into "matches nothing" (the customer case behind cloud#1908). + + **What changes** — maintainer ruling on #15064, option A: the caller who holds + the fact gets a way to say it; the predicate and the default run do not move. + + - `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]` — `object.field` + roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is + walked like a `count`: every `NULL` parent is recomputed through the same + `aggregateSummaryValue` the engine writes. A parent whose aggregate is the + empty-set reading (`null` — no child rows) already holds the engine's own + value, so it is neither counted as a hole nor written; the scoped run is + therefore idempotent in the same "re-run until it reports zero" sense. + Naming a `count`/`sum` is accepted and changes nothing, so a publish path can + pass every column it just declared without knowing the empty-set list. + - A name that resolves to no roll-up owned by an object the run walks — a typo, + a plain field, or an object `objects` left out — is **refused before any row + is read**, dry run or apply, with an ADR-0112 envelope (`code: + 'INVALID_FIELD'`, `status: 400` — the code the projection and write axes + that name a field already answer, while sorting keeps `INVALID_SORT`; + `field` names the first unresolved entry, `fields` all of them). A silent + no-op there would be the same false all-clear this option exists to end. + - `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement + of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an + unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'` + to every roll-up function, since a named `max` now appears in `fields`. + - `os migrate summary-nulls --recompute-undefined-on-empty object.field` + (repeatable) passes the scope through; the confirmation prompt names the + columns; `formatSummaryBackfillReport` lists them under "Recomputed on + request" and explains a `NULL` that remains. + + **What does not change:** without the option the walk, the writes, every + counter and the human-readable report are byte-for-byte what they were (pinned + against output captured on `main` before this change); `min`/`max`/`avg` stay + out of scope and keep being reported under `skippedUndefinedOnEmpty`; the + predicate `summaryNullIsBackfillable` is untouched, so `os migrate + summary-nulls` keeps its meaning on every deployment. The only visible delta on + an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`. + + `minor` for both packages: an optional parameter on a published exported + function, a new report key, and a new CLI flag are each a purely additive + widening of a published surface, which takes at least `minor` (bump-level rule, + 2026-09-04); the `fix`-shaped motivation does not lower it. +- 6b7d709: `objectstack validate` now lowers hooks authored as inline `handler` functions to a metadata body before it parses, so the hook write-set rules judge them there exactly as `objectstack build` and `objectstack lint` already do. + + The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, and `objectstack validate` parsed the normalized stack without lowering — so on that command the whole family returned before reading anything, and a stack `objectstack build` refuses with `hook-api-update-readonly-field` (exit 1) passed `objectstack validate` with exit 0 and no finding. The same statement authored as an explicit `body: { language: 'js', source }` was refused by `objectstack validate` all along, so the silence was the command's intake, not the rule. + + `objectstack validate` now runs the same `lowerCallables` pass `objectstack build` runs before its parse — after its two pre-parse undeclared-key lints, which keep reading the un-lowered stack, and before the schema parse, which reads the lowered view — and hands the rule registry the parsed result as before. This moves what `objectstack validate` accepts in **both** directions, and both are parity with `objectstack build`: + + - **Narrowing (hooks).** A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack validate` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` and (since #16095) `objectstack lint` with the same finding, so nothing that builds green starts failing `objectstack validate`. + - **Widening (actions, and a nameless `functions` array entry).** A plain-object config carrying an inline action `target` callable — `actions: [{ name, label, target: async (ctx) => { … } }]`, or the same on `objects[*].actions[*]` — was **refused** by `objectstack validate` before this change: `ActionSchema.target` is a string, and nothing lowered the function before the parse, so the run exited 1 with `invalid_type` at `actions.0.target` (measured through the real CLI: `valid=false errors=2 invalid_type@objects.0.actions.0.target | invalid_type@actions.0.target`). The same pass now lowers it to a ref string plus `body` on this command too, so `objectstack validate` **accepts** it (exit 0, `valid: true`) — exactly as `objectstack build` accepted it all along (exit 0 on both sides). Likewise a nameless `functions` **array** entry, `functions: [{ handler: async (ctx) => { … } }]`, which the same pass names `anon_fn`: the array form requires `name`, so `objectstack validate` refused it at the parse (measured: `valid=false errors=1 invalid_union@functions`, exit 1) and now accepts it (exit 0, `valid: true`), as `objectstack build` did (exit 0 on both sides). The `functions` map forms and `hooks[*].handler` parse either way and are not affected. These are accepted-set relaxations on a published command; they are declared here rather than inferred from the build's behaviour, and pinned beside the hook legs. + - The warning-severity members of the family now report on inline handlers under `objectstack validate` too; they fail a run only with `--strict`, as every other advisory does. + - The `--json` payload gains no key and the text face prints no new step: the lowering is a view for the parse and the rule registry. A handler the extractor cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by `objectstack lint`'s `hook-body/*` rules and `objectstack build`'s warn-and-bundle line, never guessed at here. + - Nothing about what `objectstack build` accepts changes; on both axes above `objectstack validate` now agrees with it. + + Measured on this repository's ten `objectstack.config.ts` corpus files at `6ba0db4e0` with `objectstack validate --json`, before and after: **exit code, error text and rule-id list identical on 10 of 10 — zero findings change, zero verdicts change.** Six reach the rule registry (the four example apps and the `plugin-auth` / `plugin-security` / `service-i18n` configs); two (`driver-memory`, `plugin-hono-server`) are plugin manifests, not stacks, and are refused at the schema parse — after the lowering point — with the same top-level `unrecognized_keys` on both sides; two (`app-showcase`, the `blank` template) fail at load in the measuring environment, before the lowering point, on both sides. None of the repository's handler-authored hooks writes through `ctx.api`, and none of the ten carries an inline action `target` callable, which is why the delta is zero on both axes rather than either being unreached — a corpus with neither shape cannot see either limb, so both are pinned on their own fixtures; the reach itself is pinned by the card's own fixture, with the body-authored control beside it and a handler-authored hook the family has nothing to say about still passing. + + `@objectstack/lint` carries only the header ledger recording which intakes reach each hook rule; `objectstack validate` moves from "not reached" to "reached". Its behaviour is unchanged. + +### Patch Changes + +- aae8843: `os explain` stops teaching shapes the spec rejects: all seven remaining broken catalog entries are corrected, and the sweep's xfail ledger is now empty. + + The catalog in `packages/cli/src/commands/explain.ts` is hand-maintained and does not derive from the spec, so its examples drifted behind the schemas they claim to demonstrate. The sweep landed for #14811 parses every entry's `example` against its real schema and pinned each failure as an `it.fails` xfail naming a card. This corrects all six that carried one, disposes of the seventh (an entry with no type at all), and promotes every xfail to a plain assertion — leaving the ledger empty, so the identical error cannot return silently. + + - **`object`** — select `options` are sampled as objects. A bare `['open', 'closed']` was rejected twice over (`expected object, received string`); each option is `{ label, value }`, where `value` is the stored lowercase machine identifier. + - **`field`** — the prose half of the same defect, which the sweep structurally cannot see: the optional-property table documented `options` as `type: 'string[]'`, and that row is where the `object` example's error came from. It now names `SelectOption[]` and spells the member shape out, and the entry's example demonstrates the real option list instead of a plain text field. + - **`view`** — the entry taught the wrong LEVEL, not a drifted key name. `ViewSchema` is the per-object view CONTAINER (`list` / `form` / `listViews` / `formViews`); a flat list-view literal is rejected wholesale. The tables now document the container, and the example shows a single view's own keys inside a slot. + - **`agent`** — `tools` was removed in `@objectstack/spec` 17 with no key its value moves to (ADR-0064: an agent reaches exactly the tools its surface-compatible skills declare). The entry now teaches `skills`, `model` as the configuration OBJECT it is, and the required `label` / `instructions` it had listed as optional or omitted. Two rows naming keys the schema does not have (`objects`, a top-level `temperature`) are gone. + - **`app`** — navigation items carry `id` plus the discriminant's own target key (`objectName` / `dashboardName`), not a bare `object` / `dashboard`. The table's `logo` and `defaultRoute` rows were the same class of error — neither is an `AppSchema` key, and `logo` is now rejected by name in favour of `branding`. + - **`dashboard`** — widgets bind a `dataset` and select `dimensions` / `values` by name (ADR-0021); the pre-ADR-0021 inline analytics shape (`object` + `groupBy` + `aggregate`) was removed, and `'chart'` is not a widget type at all — the enum names the concrete mark. `label` and `widgets` move to required, where the schema has them, and the non-existent dashboard-level `layout` row is dropped for the per-widget `layout: { x, y, w, h }`. + - **`trigger`** — not a wrong example but an entry with no type to check against. ADR-0088 §1 retired the `trigger` metadata kind and records that its enum comment referenced a `TriggerSchema` that never existed. The entry becomes an explicit redirect, the same shape the `workflow` entry already uses, pointing at the two delivered mechanisms the ADR names: a `hook` for synchronous in-transaction data-layer logic, a `record_change` flow for asynchronous automation. Its `docsPath` moves off a page that does not exist. + - **`action`** — `object` is `objectName`, `confirmation` is `confirmText`, and a `type: 'flow'` action names its flow in `target` (there is no `flow` key, and none was suggested, which is why the entry could not simply be renamed). The required table also advertised a `"button"` action type that is not in the enum, and a `url` key the schema does not have. +- e9fcd6b: fix(cli): `explain` names the renamed `dashboard.refreshIntervalSeconds` (#14478) + + The dashboard key catalogue `os explain` prints lists + `refreshIntervalSeconds` instead of `refreshInterval`, following the + `@objectstack/spec` rename of the authored key (the unit now lives in the key + name). Same key, same seconds; no other command output and no public surface of + this package changes. +- 680ded0: `os package publish` now decides what a manifest id is by parsing it through `PackageSchema.manifestId` — the schema for the very column it publishes into — instead of testing it against a hand-copied look-alike. + + The command carried its own rule (`MANIFEST_ID_RE`, a case-insensitive "starts alphanumeric, then any of a-z 0-9 dot underscore hyphen, up to 255 chars"), which is looser than the declared contract on every axis. The local preflight therefore **admitted what the control plane refuses**: a single segment (`crm`), an underscore (`com.acme.repair_desk`), upper case (`COM.ACME.CRM`), a digit-first segment (`9foo.bar`), an empty segment (`com..acme`) and a trailing dot (`com.acme.`). The preflight passed, the request went out, and the server answered `400`. Its error text, when it did fire, named a contract (`a-z0-9._-`) that does not exist — so a user who followed the message walked into a second refusal. + + - **One rule, both paths.** `MANIFEST_ID_RE` is deleted. The explicit `--manifest-id` / `objectstack.manifest.json` path and the derive path (`deriveManifestId`, which adopts `artifact.manifest.id`) now ask the same imported schema. They previously disagreed with each other as well as with the declaration: the derive path additionally required a dot, so a bare `crm` was blocked there and accepted on the explicit path. That extra condition is gone because the schema subsumes it — its pattern requires at least two segments. + - **The refusal text is quoted from the schema**, from its own `invalid_format` issue plus its `.describe()`, so it can no longer drift from the rule it describes. + - **A derived id the schema rejects is refused, not rewritten.** `slugify` has no letter-first rule, so an app named `2024 App` derives `local.2024-app` — digit-first, and rejected. That is now refused before any network call, with a message naming where the id came from and how to set one (`--manifest-id`, `manifestId` in `objectstack.manifest.json`, or `manifest.id`). It is deliberately not normalised into some other id: `manifestId` is immutable once published, and minting a different permanent global identifier than the inputs imply is worse than saying what is wrong. + + Publishing is unaffected for every id the control plane accepts — a legal reverse-domain id passes both paths with unchanged bytes. What changes is that the ids the server was going to reject are now refused locally, with the real rule in the message. +- dacb73f: `os generate migration`: the builtin `created_at` / `updated_at` columns now match `driver-sql` on nullability and default text, and the SQL format declares itself PostgreSQL-only. + + Both formats emitted `NOT NULL` on the two audit-stamp columns while the driver creates them nullable, and the SQL format spelled their default `now()` while both knex producers emit `CURRENT_TIMESTAMP`. Nothing failed either way, but `information_schema` kept the pair textually apart forever, so a schema diff between a generated table and a platform-created one was permanently noisy. Both generators now follow the driver — the same rule the `id` column already follows — and `--format sql` states in its help text and its docblock that it targets PostgreSQL only and makes no MySQL or SQLite claim. +- c0c07ef: `os package publish --help` no longer points its local-dev example at a directory this repo does not have. + + The last line of the command's `EXAMPLES` block read: + + ``` + $ OS_CLOUD_URL=http://localhost:4000 os package publish # local dev (apps/cloud) + ``` + + `apps/cloud` was deleted from this repository — the reference cloud host now lives in `objectstack-ai/cloud` — so the parenthetical sent a reader to a path that is not in the tree they cloned. This is help text, not a source comment: it is printed verbatim to anyone who runs the command. + + The parenthetical is dropped rather than re-pointed at the other repo. The example is about `OS_CLOUD_URL` overriding the control-plane URL, which the `--server` flag already documents in the same output; which directory happens to serve `localhost:4000` was never part of what the example teaches, and a `--help` reader is not looking for a file in a monorepo. `# local dev` alone carries it, and it now matches how the CLI reference docs have long published the same example. + + No behaviour changes: `examples` is a static help string, and no flag, argument, default or exit code moves. +- ba5284e: docs(cli): give the two `plugin` artifacts their own nouns, and rewrite "Which scaffolder?" as a two-question decision (#16484, #15531) + + `plugin` names two different artifacts in this CLI, and neither the help text + nor the docs said which one a reader was about to get: + + - `os init -t plugin` scaffolds a **metadata package** — declarative + objects another stack loads, built by `objectstack compile`, emitted + `private: true`. + - `os create plugin ` scaffolds a **kernel code plugin** — TypeScript + implementing the kernel `Plugin` contract, built by `tsc`, publishable as + `@objectstack/plugin-`. + + Someone who wanted a "plugin skeleton" and reached for the nearer of the two got + the wrong artifact, with no failure anywhere to tell them so — the metadata + package has no `Plugin` to implement, and the kernel code plugin has no + declarative objects to compile. + + **No flag and no subcommand is renamed.** `-t plugin` and `os create plugin` are + published surface and are spelled exactly as before; renaming them is a separate + decision, not this change. What moved is the NOUN each surface uses for the + artifact, so the two shapes stop sharing one word: + + - `os init --help` now reads `Template: app, plugin (a metadata package), empty`, + and the `plugin` template describes itself as + `Metadata package: declarative objects another stack loads` rather than + `Reusable plugin with objects`. + - `os create`'s `plugin` template describes itself as a **kernel code** plugin. + + The "Which scaffolder?" guidance in `content/docs/deployment/cli.mdx` is now a + two-question decision — *metadata or kernel code?* then *a new project, or an + addition to a directory you already have?* — landing on exactly one of the four + entry points, each with the reason to pick it: `npm create objectstack@latest` + (equivalently `npx create-objectstack`), `os init`, `os init -t plugin`, + and `os create plugin `. `os create example` is deliberately absent: it was + retired in #16483. +- ee79099: `os validate|info|diff|lint|compile|build|verify|migrate meta|i18n check|i18n extract --json` no longer print human text on stdout when the config file is missing. + + `resolveConfigPath()` emitted both of its refusals — the explicit-path miss and the auto-detect miss — through `printError` and `console.log`, **both of which write to stdout**, and then called `process.exit(1)` directly. Ten published `--json` faces reach that helper, so a missing config file answered them with exit 1, an unparseable stdout and an **empty stderr**: 206 bytes of prose on the one stream `--json` reserves for the machine. And because the exit was called rather than thrown, every command's catch-all `--json` error exit — all of which sit downstream of a throw — never ran. + + The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. Nothing else moves: + + - **the exit code is still 1**, so a consumer branching on exit status sees no change at all; + - **the wording is unchanged**, hints included, so a human reading a terminal sees the same three lines; + - **nothing is accepted or rejected differently** — no config that loaded before fails now, and none that failed now loads. + + ⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* when the config file is missing is an envelope question that touches ten published faces at once, and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today. + + A new pin (`config-miss-stdout-purity.e2e.test.ts`) drives all ten faces on both branches of the helper. The existing purity pin could not: it discovers its family as the commands that call `bootSchemaStack`, and these fail before any kernel boots. +- 68fd85a: `os dev`'s MCP connect hint is built from the origin the deployment is REACHABLE on, not from the socket the serve child bound. + + A dev boot printed two MCP addresses. The ready banner's `➜ MCP:` row goes through `resolveAuthBaseUrl` — `OS_AUTH_URL` → legacy `BETTER_AUTH_URL` → `OS_BASE_URL` → `http://localhost:` — while the `🤖 MCP server — connect a coding agent` block below it derived its base from the child's `objectstack:listening` `url`, which is the bound socket by construction. `OS_AUTH_URL` never entered that expression, so anything sitting in front of the app split the two apart: measured on `objectstack dev -p 4001` under `OS_AUTH_URL=https://localhost:4443` behind a TLS reverse proxy, the banner said `https://localhost:4443/…` and the block said `http://localhost:4001/…` in the same output. + + That block's `Connect` line is a command the reader pastes, so the wrong origin was not cosmetic: `claude mcp add` registered an entry against an address discovery never advertises and, behind the proxy, nothing can reach — and the two rows disagreeing made the correct one look like the typo. + + - **One resolver, not two.** The hint now calls the same `resolveAuthBaseUrl` the banner's call site does, with the port the child ACTUALLY bound. The precedence chain is not restated anywhere in `dev`. + - **The ordinary local boot is unchanged, by the resolver's own tail.** With none of the three variables set the chain answers `http://localhost:` — including dev's auto-shift (`3000` busy → `3001`) and an ephemeral port — so the local case needs no second fallback and cannot be broken by a canonical origin being hardcoded in front of it. + - **An unusable base URL now prints no block at all.** When the chain yields nothing parseable — a set-but-empty `OS_AUTH_URL=`, which does not fall through to the rest of the chain, or a value with no scheme — the banner's rule is to print paths with no origin and name the variable that fixes it. A `claude mcp add` line has no paths-only form, so the block is omitted instead of reprinting, on the same screen, the exact address the banner just refused to print. + + `resolveAuthBaseUrl` itself is untouched, including its set-but-empty behaviour; the `Endpoint` / `Skill` / `Connect` wording is unchanged. +- ad0b3e7: `os diff` with no path arguments no longer prints its usage error on stdout — in either face. + + The refusal sat **above** the command's first `if (!flags.json)`, so the face was still undecided when it ran and it fired in **both**. `printError` plus three `console.log` calls — all four writing to stdout — then `process.exit(1)`. Measured on the published entry `bin/run.js` with `NO_COLOR=1` and the streams captured separately, `os diff --json` and bare `os diff` answered byte-identically: exit 1, **141 bytes of prose on stdout, an empty stderr**, and `JSON.parse(stdout)` throwing on the one stream `--json` reserves for the machine. + + The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. The 141 bytes moved intact — stdout 141 → 0, stderr 0 → 141. Nothing else moves: + + - **the exit code is still 1**, so a consumer branching on exit status sees no change at all; + - **the wording is unchanged**, both usage hints included, so a human reading a terminal sees the same four lines; + - **nothing is accepted or rejected differently** — no invocation that worked before fails now. + + ⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* on a refusal is an open envelope question, entangled with `os lint --eval --json`'s bare `{ error }` (no `code`, no `httpStatus`), and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today. + + This is the sibling of the `resolveConfigPath` repair, and a genuinely different site: that one is reached through `loadConfig()`, this one is `diff.ts`'s own usage error, raised before any config work happens. The existing pin drives `os diff` with two paths precisely so the run gets *past* this check, so it could not see this path. A new pin (`diff-usage-error-stream.e2e.test.ts`) drives the bare form in both faces, and carries a structural tripwire: across 62 command modules, 27 of which offer `--json`, `diff` was the only one with a stdout write above its guard, and the tripwire goes red if another arrives. +- 25b0789: `os lint --eval --generator ""` no longer prints a double space in its refusal. + + `bundle-require` composes its own refusal as ` is not a valid JS file`, so an + empty filepath contributes no characters and that fragment arrives with a leading space — + which landed against the space in our own `": "` separator: + + ``` + Failed to load generator "": is not a valid JS file # before, both faces + Failed to load generator "": is not a valid JS file # after + ``` + + The composed message now drops leading spaces from the detail, so the separator carries + exactly one. The empty string still answers through the same door an unresolvable path + answers through — same `catch`, same exit code 1, same one-key `{error}` document on the + `--json` face — and every refusal whose detail does not open with a space is byte-identical, + the unresolvable-path case included. +- 1ea349f: `os generate ` no longer exits 0 after writing TypeScript the compiler cannot parse. + + The command ran no name validation of any kind — no `validateProjectName`, no sanitiser — so the name went into a binding position untouched. `os generate object foo.bar` reported success and left two broken files behind: `const foo.bar: Data.ServiceObject = {` in `src/objects/foo.bar.object.ts`, and a matching `export { default as foo.bar } from './foo.bar.object';` appended to the barrel `src/objects/index.ts`. The author learned about it at the next `tsc`, in a file the scaffolder had just told them it created. + + Both emissions are now rendered once, at the single point where the derived identifier is finished, and handed to TypeScript's own parser before anything is written. If either does not parse, the command prints the compiler's own diagnostics for each affected file and exits 1 without touching the filesystem — including under `--dry-run`, where a preview of un-parseable output under exit 0 is the same defect in preview form. One check covers all 14 emission sites across all 7 generators (`object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`) plus the barrel, and a generator added later inherits it. + + - **The criterion is parseability, not a charset.** Nothing is rewritten and no name that already produced parseable output is refused: the accepted set moves only by the names whose emission was already broken. Which names `os generate` should accept — and whether it should normalise the ones it does, the way `os create` derives its identifier — is a separate, open decision. Deriving a legal-looking identifier from a name that should have been refused is the worse of the two failures, so this refuses loudly rather than answering that question by widening tolerance. + - **Asking the compiler is what makes the check correct per emission position.** A rule about identifier characters, or about reserved words, gets this wrong in both directions: `os generate object class` is refused (`const class:` is not a declaration) while `os generate view class` is accepted (that generator emits `const classViews:`), and a name carrying a quote or a comment terminator breaks the emitted file without touching the identifier at all. +- 1f2a02b: `os generate migration --format sql` gives every timestamp column the time zone the platform actually stores. + + The SQL format spelled its two audit-stamp columns — and every declared `datetime` field — as bare `TIMESTAMP`. In PostgreSQL that is `timestamp WITHOUT time zone`, while both of the other producers of the same columns yield `timestamptz`. This implements **[ADR-0053](../docs/adr/0053-date-and-datetime-semantics.md) D-B4** (accepted), which governs both sites in one sentence: `Field.datetime` maps to `DATETIME(3)` on MySQL while "Postgres deliberately keeps `timestamptz`", and "the builtin `created_at`/`updated_at` take the same type — the registry declares them `Field.datetime`". So the declared-field row and the audit-stamp rows are one decision rather than two judgement calls, and `driver-sql`'s `createAuditTimestampColumn`, its `createColumn` `datetime` arm and this CLI's TypeScript migration format were already implementing it — the SQL format was the one producer that was not. + + Driven, not compiled: all three producers were run against a live PostgreSQL 16.13 and their columns read back out of `information_schema.columns`. Only the SQL format came back zone-naive, and the consequence is a data defect rather than a cosmetic type difference. A zone-naive column stores the wall clock of whatever session wrote the row and keeps nothing to recover the offset from, and `DEFAULT now()` is folded into that session's `TimeZone` on the way in. Two defaulted rows inserted **six milliseconds apart**, one under `TimeZone='UTC'` and one under `Asia/Tokyo`, were recorded **nine hours apart** in the generated table and 3 ms apart in the driver's own: + + ``` + sqlgen (timestamp) a_utc 2026-09-05 22:31:28.309421 + sqlgen (timestamp) b_tokyo 2026-09-06 07:31:28.315458 <- +9h, same instant + tsgen (timestamptz) a_utc 2026-09-05 22:31:28.31332+00 + tsgen (timestamptz) b_tokyo 2026-09-05 22:31:28.316401+00 + ``` + + The whole temporal class was enumerated in that same run and `datetime` is its only divergent member: `date` is `DATE` and `time` is `TIME` on all three producers, so neither moves. + + Two things this deliberately does not change. The audit columns' **nullability** stays as it is: the driver leaves both nullable and both generators say `NOT NULL`, nothing fails either way, and the driver's own audit DDL is dialect-branched in a way a Postgres-flavoured generated migration does not reproduce — so which side moves is a ruling, recorded in `generate-builtin-id-column.pin.test.ts` and still open. The `DEFAULT now()` spelling stays too: it is the same instant as the driver's `CURRENT_TIMESTAMP` (both are `transaction_timestamp()`) and only reads differently in the catalog. + + Scope for an existing project: already-generated migration files are checked-in artifacts and are not rewritten, and no deployed column is altered — a table created from an older generated migration keeps `timestamp without time zone` until its owner migrates it. What changes is what the next generated migration says. +- 8644d1d: `os generate migration` gives a table's own `id` column the shape the platform actually creates. + + Both migration generators hardcoded the primary key as a UUID — `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()` in the SQL format, `table.uuid('id').primary().defaultTo(db.fn.uuid())` in the TypeScript one (the default format). The platform's SQL driver emits `table.string('id').primary()`, which is knex's `varchar(255)`. A platform id is a string, not a uuid, so on Postgres the generated table refused the platform's very first insert with `22P02 invalid input syntax for type uuid`. + + The quieter half is the `DEFAULT`, and it is why this was worth correcting rather than working around. The driver emits no database-side default at all — its insert path always supplies the id itself — so `gen_random_uuid()` never fired for a platform write, only for an out-of-band one, handing that row a 36-character uuid this platform's id generator would never mint. One table would then hold two incompatible id shapes, with nothing said. + + Both generators now emit the driver's own answer: `"id" VARCHAR(255) PRIMARY KEY` and `table.string('id').primary()`. The correction also closes a contradiction inside the generator file, whose prose already stated that a reference column takes the width of the target's `id` column *because* the driver emits `table.string('id').primary()` — a few hundred lines above the two lines that emitted `uuid`. + + `generate-builtin-id-column.pin.test.ts` reads the width from the driver's own `DEFAULT_STRING_VARCHAR_CHARS` rather than transcribing `255`, so the generators cannot drift away from the driver again without a named failure. +- 51d59e4: `os lint` / `os i18n check` stop reporting a written inline locale map as an untranslated string. + + `I18nLabelSchema` authorizes two forms of a display label: a plain string, whose translations live in a bundle, and an **inline locale map** — `{ en: 'Members', 'zh-CN': '成员' }` — written out at the authoring site and picked at render time. Rulings on both forms make the map the one localisation route for props that have no bundle key at all, so a page localised that way is fully localised. + + The coverage walk could not see it. `inlineText()` narrowed a map to `undefined` — the same value an **absent** prop produces — so one diagnostic carried two opposite facts, and the gate reported a prop written out in four languages exactly as it reports a prop nobody wrote: + + - with no bundle entry, the key was dropped from the expected set entirely: neither covered nor missing, invisible in the counts; + - with a bundle entry for one locale, the key came back with no inline evidence, and every locale the **map** held and the bundle did not was reported `missing translation` — about text that was right there in the file. + + An entry now carries a third axis beside `sourceValue` and `inline`: `inlineLocales`, the map the author wrote, verbatim. Coverage reads it per locale — a locale the map carries counts as covered, a locale it omits is reported as a gap, and the default locale is satisfied by the map the way it has always been satisfied by an inline string. The read is deliberately narrower than the renderer's: only the tag-matching limbs of the shared `resolveI18nLabel` rule count, because falling back to `en` or to the untagged `default` entry **is** what an untranslated locale looks like. + + Two things this deliberately does not do. The map is still **never extracted**: no bundle row is scaffolded for it, and no key family is added — a translator working from the locale bundle still will not find these strings, which is the cost of the form and is now stated where an author chooses it (`i18n.zod.ts`, and the extractor's own header). And no key is synthesised from a node's position in the page tree: position-addressed keys would turn a reorder of two sibling components into a silent, all-green swap of their translations. If inline maps are ever to be extracted, the recorded direction is identity first — `component.id` / `section.name` / `tabs item.value` made mandatory and gate-enforced, then the existing `pages..components..` family reused. + + Net effect on a project that authors no inline maps: none. On one that does, the gate starts telling the truth in both directions — the false `missing translation` goes, and a map that genuinely omits a locale is reported for the first time. +- 309bee5: `os i18n extract --check --dry-run` now compares the bundles and reports what it found, instead of exiting 0 having compared nothing. + + `--check` and `--dry-run` are both "write nothing" modes, so the pair reads as the safest spelling to put in CI — and it was the one spelling that measured nothing. The `--dry-run` branch returned before the `--check` block was reached, so the same tree that failed `--check` with `Translation bundles have drifted from the schema` reported success as soon as `--dry-run` was added to the command line. A check that cannot fail is indistinguishable from a check that finds nothing: the pipeline went green and nobody learned the bundles had drifted. + + ⚠️ **A pipeline running `--check --dry-run` against drifted bundles starts failing on this release, and that is the repair working.** The failure is not new — the drift it names was already there and the old exit code was wrong about it. The fix is the one `--check` has always printed: regenerate the bundles and commit them. Nothing else about the pair changes, and a tree that is in sync still exits 0, now with the `bundle(s) are in sync with the schema` line it never printed under `--dry-run` before. + + - **What each flag contributes is unchanged.** `--dry-run` still prints the rendered modules to stdout, `--check` still compares them against what is committed in `--out`, and neither writes a file — on any path, including a bundle that is present but out of date, which keeps its bytes. + - **The `--out` advice no longer contradicts the command line it is printed on.** `Dry run — no files written (pass --out= to write)` was printed even to runs that had just passed `--out`, which reads as "your directory was ignored" when it had not been. A run with an `--out` now names the directory it did not write to; a run without one still gets the advice. +- c14c70c: `os i18n extract --check` now prints the invocation it was given, minus `--check`, as its "Regenerate and commit" hint — instead of a command assembled from four of the flags. + + The hint used to be built at the print site from the config argument, the emitted locales minus the default one, `--fill` and `--out`. Everything else was absent from the expression, so it was absent from the advice. Driven on the reported invocation against a stack whose `i18n.defaultLocale` is `zh-CN`: + + ``` + $ os i18n extract objectstack.config.ts --locales=zh-CN --no-metadata-forms \ + --no-objects-only --filter=kpi_ --out=OUT --check + ✗ missing: ../../../../../tmp/os-i18n-repro/zh-CN.objects.generated.ts + ✗ Translation bundles have drifted from the schema. Regenerate and commit: + os i18n extract objectstack.config.ts --locales= --fill=empty --out=OUT + ``` + + `--locales=` came out empty because the only locale asked for was the default one, and the echo dropped the default locale on the grounds that `--locales` always re-adds it; `--no-metadata-forms`, `--no-objects-only` and `--filter=kpi_` were never candidates for the line. Running what it printed wrote 775 keys across two files where the operator's own command writes 2 across one — a `metadata-forms` companion they had explicitly switched off, and an unfiltered key set. The next `--check` then failed again, on `out of date:` instead of `missing:`, and printed the same wrong command. A failure that heals itself in one step became a loop, and the loop was the printed advice. + + The hint is now a deletion rather than an assembly: this run's own argv with the `--check` token removed, shell-quoted so it can be copied, `--` honoured so a positional `--check` is left alone. Nothing enumerates flags, so a flag added to this command later is echoed without anyone remembering this print site. When `--check` is not in the argv the command cannot say what it removed, and prints "re-run the same command without `--check`" rather than guessing. + + Diagnostic paths are also no longer walks. `missing:`, `out of date:` and `Wrote` printed a bare path relative to the working directory, which for an `--out` outside the project produced `../../../../../tmp/i18n-out/zh-CN.objects.generated.ts` for a directory the operator had just typed in full. A path the working directory cannot reach downwards is now printed absolute; an in-tree `--out` — what all nine of this repo's extract configs use — keeps the short relative form it has always had. +- 591f194: `os i18n extract --check --json` now COMPARES. It used to exit 0 having compared nothing, on a tree whose bundles had provably drifted. + + The machine face returned before the comparison ran: `if (flags.json) { … return; }` sat ahead of both the `--check` needs-`--out` guard and the comparison block. Driven on one fixture, two invocations differing only by `--json` — the first exited 1 with `missing: OUT/zh-CN.objects.generated.ts` and `Translation bundles have drifted from the schema`, the second exited 0 with the ordinary extract payload. The first run is the second one's positive control: the drift was really there. Same shape as the `--dry-run` branch repaired one release earlier, and `--json` is if anything the more likely CI spelling of the two, because a pipeline that wants to parse the result reaches for it. + + ⚠️ **A pipeline that runs `os i18n extract … --check --json` and was green may now go red, and that is this repair working.** The green was a comparison that never happened; the red is the drift that was already in the tree. The fix is the one the failure names — re-run the same command without `--check` **and without `--json`**, then commit what it writes. Neither of those two flags writes files, and the command the failure prints now has both taken out of it. + + What each invocation now does, with no new member on any published payload: + + - **drift found** — the run ends on this command's existing `{ "error": … }` envelope with exit 1, carrying the same sentence the console face prints, the regenerate-and-commit command included. Deliberately not a new `drift` / `missing` / `stale` payload member: every other way this command can fail already speaks that envelope, and naming the drifted files in the machine payload would widen a published output face. + - **in sync** — unchanged: the ordinary extract payload, exit 0. + - **`--check` with no `--out`** — the refusal is now reachable under `--json` too, in the same `{ "error": … }` envelope with exit 1. It used to exit 0 with a payload, having been asked for a comparison it could not make. + - **`--json` without `--check`** — unchanged in every respect. + + The run leaves through exactly one of those faces, so stdout still parses as exactly one JSON document. + + One more thing moved with it: the command a drifted `--check` prints as its remedy now has `--json` taken out of it as well as `--check`. It used to keep `--json`, so the machine face named a command that emits a payload, writes zero files, and leaves the next run failing with the same advice. +- f570c28: `os i18n extract --source-hashes` no longer writes a provenance companion with no bundle module beside it, and names the sections it commits from the payloads those modules hold instead of from two literals. + + The command narrows the provenance table to "the sections this run commits" before writing `.source-hashes.generated.ts`. The half that decided WHICH modules were emitted already read the emitted set; the half that named them pushed the string `'objects'` or `'metadataForms'`. + + - **A zero-record orphan is no longer written.** With no module emitted for a locale — a stack whose only surface is apps, under the default `--objects-only` with `--no-metadata-forms` — the committed-section list is empty, `narrowToCommittedSections` returns `{}`, and `{}` is truthy at the emit gate. The run therefore wrote one file holding an empty table, describing nothing, with no bundle module beside it for it to be about. Because `--check` compares the companion by bytes like any other emitted file, that orphan once committed is a file the gate demands forever: deleting it made `--check` report `missing` and exit 1. Such a run now writes nothing, and reports `Generated 0 file(s)`. + - **The section list is derived.** `translationModuleSections(bundle, kind)` sits beside `translationModulePayload` and is switched on the same `kind`, so what a module holds and which sections it commits are one decision rather than two. Under `kind: 'stack'` the module holds every group the stack authors and the caller now names all of them; a group added later needs no edit, and a further aggregate kind fails to compile at that one site rather than silently committing its own name as a section. + + **No provenance record changes in this repository, and none is restored.** The generated tables only ever carry the two sections `collectFilledFromHashes` walks (`objects` and `metadataForms`), so `'objects'` was the right name for both stack sub-tree modes — the old list was correct by coincidence, not by construction. In particular an `apps.*` record is not restored by this change: no such record is built, so none was being filtered out. + + **Already committed an empty companion?** Nothing needs doing and nothing is deleted. `--check` compares only the files a run writes and reports `missing` / `stale` over that set, so a leftover empty companion is in neither category — it is tolerated where it sits, and is inert to the next extract, which reads it back as an empty record set exactly as it would read its absence. Delete it at your convenience. +- fc3fb7c: `os i18n extract` reports key counts that describe the bytes it emitted, and its summary is a partition of the skeleton rather than a sum over it. + + `extractTranslations` returned `counts[locale]` as a WALK counter — `count += 1` once per expected entry, unconditionally — and the command spent it as the number of keys in the file it had just written. Under the default `--objects-only` the module holds only the `objects` sub-tree, so the two are different numbers. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: + + ``` + Skeleton summary + zh-CN 776 key(s) (of 776 expected) + 773 metadataForms key(s) + Wrote OUT/zh-CN.objects.generated.ts (776 keys) + ``` + + The file that run wrote holds **2** leaves. The true split of the 776 is 2 objects + 1 app + 773 metadata-form baseline, so the summary appended a number the 776 already contained and read as 1549 out of 776 — an operator could not derive the truth from it, and the `(776 keys)` described no file the run produced. Both lines now read off the emitted tree: + + ``` + Skeleton summary + zh-CN 775 of 776 key(s) emitted objects 2 · metadataForms 773 + Wrote OUT/zh-CN.objects.generated.ts (2 keys) + Wrote OUT/zh-CN.metadata-forms.generated.ts (773 keys) + ``` + + **What each number now means.** `ExtractResult.counts[locale]` is a leaf count of `bundles[locale]` — the whole skeleton built for that locale, taken off the tree instead of off the walk that built it. It is explicitly not the size of any one file: which sections of the skeleton become committed modules is the caller's decision. The command therefore takes every count it reports off that module's own payload, selected with `translationModulePayload` — the same function `renderTranslationModule` renders from, so the number and the bytes cannot drift apart, including for a sub-tree mode added later. Nothing subtracts one count from another at a print site: that would repair today's two modes and leave the third wrong in the same way. + + **The summary line's shape changed** from `N key(s) (of N expected) + M metadataForms key(s)` to `E of S key(s) emitted` with a per-module breakdown. `E` is what this run's modules hold together and `S` is what the locale's skeleton holds, so `E ≤ S` always and the gap is exactly the keys a flag excluded — one app label under the default `--objects-only`, and nothing at all under `--no-objects-only`. A module a flag SUPPRESSED is named in the breakdown too, with its size and the words `not emitted` that keep it out of `E`: under `--no-metadata-forms` the row reads `2 of 776 key(s) emitted objects 2 · metadataForms 773 not emitted`, so the operator still sees how big the baseline they switched off is — which the old, double-counting line did tell them. + + **A module with no leaves is no longer written.** The emit gate was `counts[locale] > 0`, a property of the skeleton: on a stack whose only surface is apps, the default `--objects-only` wrote a `.objects.generated.ts` holding `{}` and announced it as 774 keys. The gate is now the module's own leaf count. + + **`--json`**: `counts` is now the leaf count of the `bundles` payload printed beside it, instead of the extractor's skeleton size. The skeleton total is unchanged and still reported, under its own name, as `totalExpected`. + + ⚠️ That is **not** the relationship `metadataFormsCounts` has to `metadataForms`, and nothing here changes the latter. `metadataFormsCounts` reports the baseline as BUILT, emitted or not: under `--no-metadata-forms` the payload carries `metadataFormsCounts: { 'zh-CN': 773 }` beside an empty `metadataForms`, deliberately, and a pin holds it there. So the payload carries two count semantics — `counts` is what was emitted, `metadataFormsCounts` is what was built. Both faces are unchanged by this note; it exists because an earlier draft of it claimed a symmetry that does not hold. + + **No committed bundle moves.** All nine extract configs in this repository run under the default `--objects-only` on stacks that do author objects, and every emitted module is byte-for-byte unchanged; `pnpm check:i18n` stays green on the committed tree. What changed is stdout, the `--json` counts, and the emission of a module that would have been empty. + + The regression pin spawns the real CLI in four flag states and compares each printed count against a structural leaf count of the module it wrote, parsed back off disk. That comparison is the thing the defect precluded: a walk counter cannot disagree with the walk, so no assertion over `ExtractResult` could have failed while the printed number was wrong by two orders of magnitude. Its `--json` case drives `--metadata-forms` in both states, because a case that drives one state of a flag cannot see what that flag does — driving it ON only is exactly how the symmetry claim above survived unmeasured into a first draft. +- f5aec38: `os i18n extract --no-metadata-forms` is honoured whatever `--objects-only` is set to, and the Studio metadata-form baseline lands in exactly one module. + + The flag gated only the `.metadata-forms.generated.ts` companion. The stack module's renderer had a third mode, `kind: 'full'`, that serialised the WHOLE `TranslationData` — the baseline included — and `--no-objects-only` selected it. So the two flags stopped being independent the moment the second one was passed, in both directions: + + - **`--no-metadata-forms --no-objects-only`** suppressed the companion and wrote the same keys into `.objects.generated.ts` instead. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`: the emitted zh-CN module carried **776 leaves, of which 773 were the metadata-form baseline** the flag had just switched off (the stack's own surface is 3). Those 773 are **English** — the default locale is filled from the source labels and the metadata-form registry authors them in English — so a non-English default locale shipped the platform's English Studio strings inside its own application bundle. + - **`--no-objects-only` alone** wrote those 773 keys **twice**, once in each module. + + `--objects-only` picks the stack module's sub-tree; `--metadata-forms` decides whether the baseline is emitted at all, and it is now the only control over it **on both faces**. Both flags keep exactly the meaning their `--help` already gave them, and nothing here picks a winner between them — the overlap was in the emitter, never in the two meanings. + + `'full'` is renamed `'stack'` and omits `metadataForms`, so the module a run writes and the baseline companion beside it are disjoint, and under `'stack'` the two together are everything the extractor built (3 + 773 = 776 on the fixture above — the extractor's own count, none dropped, none duplicated). ⚠️ That is a statement about the PAIR a run emits, not about "three kinds partitioning the leaves": `'objects'` is a sub-selection of `'stack'`, not a sibling of it. + + `--json`, documented as "output JSON instead of writing files", mirrors that file set: `bundles` is the stack module and a `metadataForms` map is the companion, keyed by the locales whose companion would be written and gated by the same predicate. That map is new. It exists because the first cut of this change stopped the fold on the `--json` face as well and left the baseline with no JSON home at all — measured, `--json --no-objects-only` with the flag ON and with `--no-metadata-forms` returned payloads equal in every field but `duration`, so on that face the flag decided nothing, the mirror image of the defect this card reports. `metadataFormsCounts` reports the baseline's size in every run, as before. + + **No bundle in this repository moves.** All nine extract configs run under the default `--objects-only`, whose emitted module, export name and type signature are byte-for-byte unchanged — `pnpm check:i18n` stays green on the committed tree. A stack that DOES pass `--no-objects-only` regenerates a smaller `.objects.generated.ts`: its export keeps its name and narrows from `TranslationData` to `Omit`, and the baseline it used to duplicate is in the companion beside it unless `--no-metadata-forms` says it should not be there at all. + + **What content moves where.** On the file face nothing published loses content: under the default `--objects-only` the output is byte-identical, and under `--no-objects-only` the baseline moves out of the stack module into the companion the same command already writes — unless `--no-metadata-forms` says it should not exist, which is the ask. On the `--json` face the baseline moves from inside `bundles` to its own top-level key, and under `--no-metadata-forms` it is now absent, which it never was before: that face did not honour the flag at all. + + The regression pin spawns the real CLI and takes a group census of the bytes it wrote, and drives `--json` in BOTH flag states. The one-state version of that case could not have failed on the axis that failed here — a pin that exercises only the flag-OFF path can never detect a flag that does nothing. The sibling pin that mirrors the emit rule and checks file NAMES stayed green through all of this: the file set was right in every combination, and only the content was wrong. +- 095df7f: `os lint` and `os i18n extract` no longer count one translation key twice. + + A translation key is derived from *where a string is addressed*, not from *which declaration was being read* when the walk reached it — and two declarations can address one bundle slot. `collectExpectedEntries` emitted one entry per declaration, so a key reachable twice became two expected entries. Two families were measured, with different causes: + + - **Two carriers, one action.** The normalized config attaches an object's actions to `obj.actions` *and* to the top-level `actions` list — the same object reference, not a copy — so both action branches emitted `objects.OBJECT._actions.ACTION.*`. This is the family the coverage report shows: 70 of 691 baselined units across `app-todo` (40), `app-showcase` (29) and `app-crm` (1). + - **Two declarations, one form field.** `deleteBehavior` is declared twice in each of the `field` and `object` metadata forms, gated on `visibleWhen` (`lookup` vs `master_detail`); both render into one key. Config-independent — it duplicated six entries on every config, including an empty one. + + Neither is an authoring mistake, and neither is fixable where it originates: both are two correct declarations of one displayed string. So the walker now collapses entries that address the same path, keeping the first emission. + + What that corrects, in both directions: + + - **`os lint`'s i18n findings.** The same missing key was reported twice, byte-identically. `pnpm check:i18n-coverage` ratchets the finding *count* while its report calls the number "untranslated declared strings", so translating one key moved the ratchet by two and the frozen debt was ~11% larger than the work it described. The three coverage baselines are regenerated in this change and fall by exactly 70 (691 to 621): `app-crm` 102 to 101, `app-showcase` 443 to 414, `app-todo` 146 to 106. The ratchet's direction, monotonicity and failure text are unchanged — only the population it counts. + - **`os i18n extract`'s reported counts.** `totalExpected` and the per-locale `counts` counted emissions while the skeleton itself had already collapsed the duplicates on the way in, so extract over-reported what it wrote — 1632 claimed against 1531 keys written on `app-showcase`, 894 against 870 on `app-todo`, 930 against 925 on `app-crm`. Those numbers now match the skeleton. + + No generated bundle changes: every duplicate pair measured carries a byte-identical record, so de-duplication removes copies and never a demand. All nine `translations/*.generated.ts` packages stay in sync. +- 212eaba: `os init -t app`, `os init -t plugin` and `os g object` now emit an object file that compiles. All three wrote `const … : Data.Object`, and `@objectstack/spec/data` exports no member named `Object`, so the first command a new user runs produced a project that failed its own `pnpm typecheck`. + + Measured against the **published** package a real user installs (`npm pack @objectstack/spec@17.3.0`, extracted and linked into a driven emission), not against the workspace: + + ``` + error TS2694: Namespace '.../@objectstack/spec/dist/data/index' has no exported member 'Object' + tsc exit 2 + ``` + + Identical at TypeScript 5.3.3, 5.8.3 and 6.0.3, so it was never a compiler-version effect. `os create example` type-checked clean on the same tarball in the same run — the failure was specific to these emissions. + + The annotation is now `Data.ServiceObject`. That name was not chosen here — it is what [ADR-0122](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0122-schema-type-alias-naming-convention.md) D1 already ruled: for a schema `XSchema`, the **bare** alias denotes the author state (`z.input`), and it is "the name documentation, examples, skills and AI authoring surfaces use for the thing an author writes". An emitted scaffold is the thing an author writes, so the bare alias is the one it owes. The sibling generators were already on that convention — `UI.View`, `UI.Action`, `UI.Dashboard` and `Automation.Flow` are each the bare alias of their own schema — and only the object emitters had drifted off it. + + **Nothing was added to `@objectstack/spec`**: `ServiceObject` has been exported from `@objectstack/spec/data` throughout. + + The parsed-state alias is not an alternative here. Annotating the same emitted literal `Data.ServiceObjectParsed` fails all three cases with `error TS2740`, because every field literal is then missing the keys the schema supplies by default — which is exactly the author-state/parsed-state distinction ADR-0122 D2 draws. + + `content/docs/deployment/cli.mdx` taught the broken spelling too, and is corrected with them — a reader copying from the docs wrote the same uncompilable line. + + The whole emitter roster was swept rather than the three reported sites: driving every `os init` template and every `os g` generator through `tsc --noEmit` under the tsconfig the scaffolder itself writes, `Data.Object` was the only non-existent member any of them named. In particular `UI.View` and `Automation.Flow` — named alongside `Data.Object` in the docs line and explicitly not swept when this was reported — are genuinely exported, and their generators compile at exit 0. + + Why nothing caught this: both existing scaffold sweeps are runtime pins that load the emitted TypeScript through esbuild, which erases type annotations **without checking them**, so a broken annotation transpiles to byte-identical JavaScript and is invisible to them by construction. The scaffolds parsed, validated and loaded; they simply did not compile. A new pin runs the emitted projects through a real `tsc` program, with a canary that must fail with TS2694 so the harness cannot pass by resolving nothing. +- 68aee4c: `os init` / `os create` now write a `lint` script into every scaffolded project, matching what `npx create-objectstack` already emits. + + The two scaffolders had diverged. `npx create-objectstack` copies a template that declares `dev`, `start`, `build`, `validate`, `lint` and `typecheck`, and ships a CI workflow that runs `pnpm validate`, `pnpm lint` and `pnpm typecheck`. The three script maps in `os init` each declared `validate` and no `lint`, so a project scaffolded through `os init` that adopted that workflow — the documented next step — failed its first push with `Command "lint" not found`. + + `objectstack lint` is not a second spelling of `objectstack validate`. Both run the shared authoring-rule engine, but only `lint` reaches the hook-body lowering check, so `hook-body/not-lowerable` — a handler that has silently stopped lowering to a metadata-only body, a change of deployment shape from a refactor that looks like tidying — was unreachable from a project scaffolded this way. + + The new entry sits after `validate` in each map, matching the template's order, and its value is `objectstack lint` on both sides. Existing projects are unaffected; add the script by hand to pick the check up: + + ```json + "scripts": { + "validate": "objectstack validate", + "lint": "objectstack lint" + } + ``` + + A pin now holds the two scaffolders equal on the scripts the shipped CI workflow runs, derived from that workflow rather than transcribed, so the next divergence is a red test instead of a discovery. +- c5b7d84: Correct the remaining out-of-package comments that still described + `SqlDriver#formatOutput`'s two timestamp passes as gated on `if (this.isSqlite)`. + + Since ADR-0053 D-F1 (#13973) both passes — the `AUDIT_TIMESTAMP_COLUMNS` pass and the + `normalizeSqliteDatetimeOutput` pass over `datetimeFields` — run on every dialect, so the + record read door presents the builtin audit columns and every declared `Field.datetime` + as canonical ISO-8601-`Z` text on Postgres and MySQL as well as SQLite. Measured on the + tree rather than recalled: in `packages/drivers/driver-sql/src/sql-driver.ts` the + `if (this.isSqlite)` arm inside `formatOutput` opens at line 16965 and closes at 17026, + covering only the JSON codec and the numeric-scalar repair, while the audit-column loop + (17046) and the `normalizeSqliteDatetimeOutput` loop (17061) both sit at the method's top + level, below that closing brace. + + Two of the corrected comments were load-bearing rather than merely stale. The + `service-storage` one drew a conclusion for a live read door from the false premise, and + it also claimed that folding at the driver's read boundary "would reverse the deliberate + `withPostgresCalendarDayAsText` decision" — which is what #13973 ruled and did. The two + `packages/cli` ones attached the wrong reason to a true fact: the holder probe reads + through the raw-SQL seam, so `formatOutput` never runs on that path at all, and the + dialect divergence there survives the ruling for that reason and not because of a gate. + + Comments only — no runtime behaviour, no exported symbol and no public type changes. + `@objectstack/cli` is the one package named here because its per-file build carries the + amended text verbatim into `dist/commands/migrate/duplicates.js` and + `dist/commands/migrate/duplicates.d.ts`, so its published output changes. + `@objectstack/metadata-protocol` is deliberately NOT named: its edits are all in test + files, which are not published. `@objectstack/service-storage` and `@objectstack/metadata` + are deliberately NOT named either: their source edits are JSDoc blocks on the internal + `usableCreatedAt` and `canonicalTimestampText`, and both bundles strip them — measured + absent from `dist/`, with each package's identifier found in the same `dist/` (and the + exported `StrandedOrphanInventoryEngine` docblock present in `dist/index.d.ts`) as the + firing control that the probe works. + + Three carve-outs are preserved rather than flattened: `withPostgresCalendarDayAsText` is + untouched by that ruling (D-F2 — the client library still materialises `timestamptz` / + `DATETIME(3)` as a `Date`); the Invalid `Date` residue still stands (D-F3 — the one `Date` + shape with no canonical text leaves the read door unchanged, so no sentence claims the + read door never hands out a `Date`); and the ruled-B consumer arms stay, with only the + prose explaining why they exist corrected. +- 923caed: `os lint --eval --json` no longer leaks esbuild's own diagnostics to stderr while loading a `--generator` module. + + A `--json` invocation is a machine face, and its stdout document was already well-formed — but the `--generator` load runs through `bundleRequire`, and esbuild's logger writes straight to stderr from inside that call, before anything throws. The `catch` that builds the one-key `{error}` document therefore never got a chance to suppress it, and a caller who asked for JSON got an internal bundler's diagnostic on the human channel alongside it. + + Measured on `bin/run-dev.js` with `NO_COLOR=1`, two runs that both leaked: + + - an unresolvable `--generator` path: exit 1, a well-formed `{error}` on stdout, and `✘ [ERROR] Could not resolve ""` on stderr; + - a generator that bundles and loads *successfully* but makes esbuild warn: exit 0, the full live eval report on stdout, and 340 bytes of `▲ [WARNING] …` on stderr. Nothing throws on this path at all, so no error handling was ever involved. + + The load now passes `esbuildOptions: { logLevel: 'silent' }`, scoped to that one call site and applied only when `--json` is set. + + - **The refusal is unchanged.** `logLevel` governs whether esbuild *prints*; it still throws its `BuildFailure` with `errors` populated, and that text already forms the tail of the `{error}` string on stdout. Both stdout documents above are byte-identical before and after. + - **The human face is untouched**, by construction rather than by restating a default: without `--json` no `esbuildOptions` is passed at all. `os lint --eval --generator ` still prints esbuild's line on stderr exactly as before. + - **What is suppressed beyond the leak itself:** under `--json`, an esbuild *warning* on a generator that loads fine now reaches nothing. A warning is not thrown, so no handler carries it onto stdout. This is inside the defect rather than beyond it — the machine face is not a place for human-channel output — but a `--json` consumer that was reading stderr for bundler warnings will no longer see them. + - The other `bundleRequire` callers in the CLI (`os serve` / `os dev`, config loading, scaffold validation) are not affected and keep their diagnostics. +- 54e2369: `os lint --eval` no longer scores a failed generation as a perfect one: a generator that throws now counts 0 toward `meanScore` instead of 100. + + The harness has always handled a throwing `--generator` by substituting an empty stack and scoring that. An empty stack is **100 / grade `A` / `valid: true`** — it has nothing wrong with it because it has nothing in it. So a live eval in which every single generation failed reported the best possible headline number: + + ``` + os lint --eval --json --generator ./throws.mjs + exit 1 · ok: false · passed: 0 · failed: 5 · meanScore: 100 + every case: score 100 · grade A · valid true · generationError "model unavailable" + ``` + + `meanScore` is the first number a human scanning that report reads, and it read perfect precisely when the model under test produced nothing. + + **What was NOT wrong: `passed`.** It carries its own guard (`!generationError && …`), so the failed cases were reported as failed and `ok` was `false` throughout. A reader who cross-read `ok`/`passed` was safe; a reader who checked the mean and moved on got exactly the wrong impression. That is the whole defect, and nothing about `passed`, `ok`, `total`, `failed` or the exit code changes here. + + The repair is the verdict the sibling failure path already used. A generator that *returns* a value nobody can walk was already scored `0 / F / valid: false`, with the reason written into the module: a stack that cannot be walked is not an empty stack, and `valid: true` for one that was never parsed is simply false. A stack that was never produced is not an empty stack either — so both now answer the same: + + ```json + { "id": "invoice_with_line_items", + "generationError": "model unavailable", + "passed": false, + "score": { "score": 0, "grade": "F", "valid": false } } + ``` + + and the run above now reports `meanScore: 0`. + + `meanScore`'s denominator is unchanged and is now stated in the payload's own documentation: the mean is over every case **attempted**, so a failed case contributes its 0 and is counted. The alternative — averaging only over cases that could be scored — is a different metric that would report the quality of the generations that arrived while staying silent about how many never did; a `meanScore` that switched denominators without saying so would be a worse defect than the one being fixed. + + No key is added to or removed from the `--json` payload, and nothing a generator can return is newly accepted or rejected: an off-shape stack is still a **scored** case whose schema errors are why it fails, never a generation error. +- 17ec4b1: `os lint --eval --json` reports an unscorable generated stack as a failed case instead of crashing with no JSON at all. + + The eval harness promised totality in writing — *"Never throws — generation failures become failed cases"* — and the promise was false as written. Its `try` wrapped only the call to your `--generator` module; the `scoreMetadata(stack)` call that follows sat outside it. So a generator that **threw** became a failed case, exactly as documented, while a generator that **returned** a value nobody could walk took the whole process down: + + ``` + os lint --eval --json --generator ./g.mjs + exit 1 · stdout 0 bytes · stderr " Error: poison getter" + ``` + + A caller that asked for `--json` got the framework's human error text on stderr and no document at all to parse. Eval mode dispatches above the project-lint `try`, so the catch-all JSON exit that mode has could never see it either. + + Scoring a stack means walking it, and there are two walks: the normalizer spreads the stack's top level, and the schema parse walks everything below it. A throw from **either** now becomes that case's `generationError` — the same per-case channel a throwing generator already used — so the report exit that was always there emits its JSON, names the cause, and still exits non-zero: + + ```json + { "id": "invoice_with_line_items", + "generationError": "Failed to score the generated stack: poison getter", + "passed": false, + "score": { "score": 0, "grade": "F", "valid": false } } + ``` + + Nothing new appears on the `--json` face: no new key, no new payload shape. The failing exit was already reachable for a throwing generator; it is now reachable for a poisonous one too. + + The failed case is scored `0 / F / valid: false` rather than as an empty stack. An empty stack scores 100 / A / valid, and stamping that on a stack nobody could parse would have put a clean-looking verdict next to a failure — the crash replaced by a quiet wrong answer. + + Unchanged: offline mode, and every off-shape stack a generator can return. Bad metadata is still **scored**, with its schema errors as the reason it fails — it is not rerouted into the failure channel. +- 135843d: `os migrate meta` no longer prints the protocol version under the word "runtime", where it read as the installed package version. + + The chain line used to end `(runtime 17.0.0)`. That number is `PROTOCOL_VERSION` — the protocol major padded to a semver — and it is not, and never tracks, the version of the installed `@objectstack/cli` or `@objectstack/spec`. On a 17.3.0 install the line appeared beside the real package versions of the same upgrade session (`npm view`, the changelog), so it read as "your runtime is 17.0.0": an apparent downgrade or a stale install, neither of which was true. + + The value was never wrong — the label and the semver form were. The line now states the fact in the protocol's own units: + + ``` + Chain: protocol 17 → 17 (this runtime implements protocol 17) + ``` + + The parenthetical is relabelled rather than dropped, because it carries a fact nothing else on screen does: when `--to` stops below this build's major, it is the only place the operator is told where the runtime actually stands (`Chain: protocol 16 → 16 (this runtime implements protocol 17)`). + + The `--json` payload is deliberately untouched: its `runtime` key still carries the same padded protocol semver. Renaming a machine-readable key is a contract change owing a reader census and a deprecation window of its own, and it is tracked separately — an e2e pin now asserts the key's current value so that move cannot happen silently. +- 5023630: The published `os` binary no longer freezes in the kernel when whatever is reading its output stops draining. + + Node puts the CLI's stderr on the non-blocking write path when it opens the pipe, so a write to a reader that has stopped is buffered rather than parking the thread. libuv clears that flag again in the pre-exec of every child spawned with **inherited** stdio — and inheriting is `dup2`, so the flag lives on an open file description the spawner shares. Clearing it for the child clears it for the CLI too. + + Measured on the built binary, `os dev --verbose` with its output piped to a reader that stopped draining: `os dev` spawns `os serve --dev` with inherited stdio at 2.8 s, that child spawns the esbuild service with inherited stderr at 5.2 s, and fd 2 stays blocking for the rest of the run. 3.1 s after the reader stopped, the main thread sat in `write(2)` (`wchan=sock_alloc_send_pskb`), 4 of 4 runs — parked 28.9 s, **ignoring SIGINT while parked**, and released only when the consumer resumed. Not a crash and not a timeout: alive, idle, unresponsive, with an empty log. Anything that pipes `os dev` and reads it slowly — a CI log collector, a backgrounded runner, a supervisor that stops draining while it does work — could park the CLI this way. + + `bin/run.js` now installs `keepStderrNonBlocking()` before oclif can write a byte. The guard re-asserts `O_NONBLOCK` immediately ahead of each write, which is what the measurement requires: the clearing that persisted was made by a **grandchild** the CLI does not spawn and cannot see, so a one-shot at startup would be undone silently and no change to the CLI's own spawn sites would have prevented it. + + The guard itself is not new — it shipped in no published install. It lived at `packages/cli/bin/stderr-nonblocking.mjs`, and `files` names only `dist`, `README.md` and `CHANGELOG.md`; npm packs a `bin` **target** regardless of `files`, which is why `bin/run.js` reached every install and the module beside it reached none. It now compiles from `src/utils/stderr-nonblocking.ts` into `dist/`, under the whitelist that was already there. + + Nothing about which arguments the CLI accepts, what it prints, or what it exits with changes. The refusal of `setBlocking(true)` in `src/utils/format.ts` stands and is untouched — this is its inverse, and what keeps its premise true. +- ce8caba: `os create plugin ` now derives the exported plugin symbol as a JavaScript identifier rather than copying the project name into an identifier position. + + `validateProjectName` accepts exactly what npm accepts — a dot, an underscore and a leading digit included — so `os create plugin foo.bar` used to exit 0 having written `export const foo.barPlugin: Plugin = {`, a property access where a binding name belongs. The scaffolded project did not parse. + + What the command accepts is unchanged, and so is what it emits as a name: the package name, its scope and the project directory stay byte-for-byte what was typed. Only the code identifier is normalised — every run of characters that is legal in an npm name but illegal in a JavaScript identifier now folds the way `-` already did, and a leading digit takes an `a` prefix. Ordinary names are unaffected (`my-app` still exports `myAppPlugin`). The emitted README names the derived symbol in prose, so the mapping is stated where it is read. +- 4771bd9: The `Server is ready` line now reports the degraded boot it is standing on, instead of printing a green `✓` over it. + + `✓ Server is ready` and the kernel's `System started with degraded capabilities. Missing core services: …` were two statements about one boot, produced by two packages — the banner in `@objectstack/cli`, the conclusion in `@objectstack/core` — with **no data path between them**. So the ready signal did not depend on the thing that broke, and therefore could not report it. Measured twice within a day, from unrelated causes: an objectui CI boot where the auth plugin failed and not one `sys_*` table existed, and this repo's own weekly registry canary on the published `npx create-objectstack@latest` on-ramp, where the tick printed directly **above** four boot warnings. In the second case the ready line carried no weight in the job's verdict at all — it was present, green, wrong, and believed by nobody. + + - **The data path.** `ObjectKernel.validateSystemRequirements()` now publishes the list it had already computed — the same array behind its own warning — on the kernel's service registry, which is the seam boot facts already cross to reach the banner (`serve` reads `auth` and `seed-summary` off it the same way). No member and no type is added to `@objectstack/core`'s public surface, and nothing re-derives which services count as `core`: that judgement stays in `ServiceRequirementDef` alone. + - **The line.** On a degraded boot the banner prints `⚠ Server is ready — DEGRADED: missing core services: `, naming exactly what the kernel found missing. On a healthy boot the ready block is byte-for-byte unchanged, so an ordinary boot's output does not move. + - **Readiness is NOT made strict.** Nothing about what boots, binds, or exits changes. A machine deliberately running without auth still starts, still prints ready, and still exits 0 — the line just says what state it is ready in. +- ed6579b: `objectstack build` now refuses to lower a hook/action body that calls `.create(`, and the shared write-pattern ledger stops advertising the verb. Three layers used to disagree about `ctx.api.object('x').create({ … })`, and the loudest one was wrong. + + - The spec contract `IScopedObjectRepository` (`packages/spec/src/contracts/scoped-context.ts`) declares `insert` and names `create` as measured-and-deliberately-excluded. + - The QuickJS sandbox installs exactly `insert / update / delete / updateMany / deleteMany / upsert` as the `ctx.api.object()` write leaves — no `create`. An L2 body calling `.create()` therefore threw `TypeError: not a function` on its **first run**, and under a hook's default `onError: 'abort'` that throw aborted the triggering write, with a message naming no member. + - The extractor ledger nonetheless advertised `.create({…})` as legal `api-crud-literal` syntax and mapped it in `API_WRITE_METHODS`, so `hook-body-write-unknown-field` graded the payload as a live write and stayed silent when the field existed — a clean bill of health for a call that cannot run. Build time said nothing at all. + + What changes: + + - **`@objectstack/cli`** — `.create(` joins `FORBIDDEN_PATTERNS` in the hook/action body extractor, beside `.sudo(` and for the same reason (a member real on the in-process `ScopedContext` / `ObjectRepository` and absent from the VM). The refusal names `.insert({ ... })` as the spelling the sandbox actually has. Behaviour is the `forbidden-token` fallback every other entry has: the callable is still registered and still shipped through the back-compat `.mjs` bundle, so a handler keeps running in-process where the host `create()` alias exists — `objectstack build` merely declines to *also* emit it as a body that cannot run. Under `--strict-body` it is a hard failure, correctly. The rule is receiver-loose like `.sudo(` (`const repo = ctx.api.object('x'); repo.create(…)` is refused too) with one carve-out: `Object.create()` is a real sandbox global and is **not** affected. + - **`@objectstack/lint`** — `create` is withdrawn from `HOOK_BODY_WRITE_PATTERNS`' advertised `api-crud-literal` syntax and from `API_WRITE_METHODS`, on the hook and action surfaces alike. `hook-body-write-unknown-field` / `action-body-write-unknown-field` no longer grade a `.create()` payload; `hook-api-update-readonly-field` keeps its existing `create` exclusion, whose *reason* is updated — it is no longer "the call throws, so a silently-dropped finding would be false" but "the shape can no longer reach this rule at all". + + **Migration.** If a hook or action body calls `ctx.api.object('x').create({ … })`, spell it `ctx.api.object('x').insert({ … })` — the same host method, the one the sandbox installs and the only insert verb the contract declares. The host-side `ObjectRepository.create()` alias is untouched and stays reachable from in-process handlers and actions. +- 7845951: `objectstack init` and `objectstack create` now read one emission policy instead of each restating it. + + Both commands write a `tsconfig.json` and a set of third-party dependency ranges into a new project. Each had written those in its own words, and the words had come apart. Measured on the tree: the TypeScript range — the value that decides whether a scaffolded project type-checks at all — was written in six places across three scaffolders and had split into three values (`^5.3.0`, `^5.8.0`, `^6.0.0`); the vitest range into two. Dated off `git log -G` as of 2026-09-05: the two CLI values were written in the same commit and stayed apart for 210 days, and the third value is 53 days old — the bundled template landed at `^5.3.0` like the others and was moved to `^6.0.0` later, in a commit that records no reasoning about TypeScript. + + The control for that reading was already in the same file: `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are imported by the second scaffolder rather than restated, and across the same five emissions, the same window and the same authors, they had not drifted at all. So the policy moved to where those already live — `renderScaffoldTsconfig()` and one `SCAFFOLD_*_RANGE` constant per dependency, in `init.ts`, imported by `create.ts`. + + Two emitted values had to survive the merge, and both are argued rather than picked: + + - **TypeScript `^5.3.0`.** `TypeScript 5.3+` is already this project's published floor — `content/docs/getting-started/index.mdx` says so, and `content/docs/deployment/troubleshooting.mdx` repeats it. `^5.8.0` matched no statement anywhere, and `^5.3.0` was already what three of the five emissions carried. Measured rather than assumed: TypeScript 5.3.3 type-checks every shape these two commands emit with results identical to 6.0.3. + - **vitest `^4.0.0`.** Neither value was a recorded decision and both were written in the same commit; `^4.0.18` claimed a patch-level floor nothing justifies and was strictly the narrower of the two. + + **Nothing a scaffolded project installs changes.** `^5.3.0` and `^5.8.0` both resolve to typescript 5.9.3, and `^4.0.0` and `^4.0.18` both to vitest 4.1.11 — what moves is the floor each project declares, which is a support promise, so the surviving one is the promise the docs already make. Driving all five emissions and hashing the trees before and after: every `tsconfig.json` is byte-identical, `os init -t app` and `os init -t empty` are byte-identical in full, and exactly three `package.json` files change by exactly the one line each. + + `npx create-objectstack` is deliberately untouched. It cannot import from `@objectstack/cli` — the dependency edge runs the other way — and its `^6.0.0` is a different question: unifying it would change which major of TypeScript a scaffolded project installs. +- 0c6c55e: `os serve` now says so when the SQLite file it is serving is no longer the file at its configured path. + + Deleting the data directory under a running server — `rm -rf .objectstack/data`, which is what a `demo:reset` script does and what a fresh-database repro starts with — unlinks the inode without touching the process. SQLite keeps reading and writing the now-invisible file, health keeps answering `200`, and a later boot creates a brand-new database at the same path. From that moment every filesystem inspection of that path describes a *different* database than the running server answers from, and nothing anywhere says so: a row edited there has no observable effect on the live server, and a user who authenticates against the live server is not in that file. Both readings are true, both look like a broken write path, and one investigation that reported them as evidence cost a full P0 cycle. + + A boot that serves an on-disk SQLite file now records that file's identity once the boot is complete and re-checks it on a 30-second interval. When the file is gone, or the path holds a different file, it reports **once** at `error` — naming the path, the consequence (every external observation of this deployment is now false, and it will keep looking healthy) and the fix (restart the server so it opens the file that is at that path now). + + It refuses nothing and retries nothing: the running server is still correct, merely invisible, and breaking a working dev loop to fix a reporting gap would trade a bad hour for a worse one. Nothing is added to any payload, endpoint or state file. Silence from the check is not a claim that the file is intact — every uncertainty in it resolves toward staying quiet, because a false report would send an operator to restart a server whose database is fine. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- b3ef687: Stop the published CLI from dying of an uncaught `write EPIPE` when its caller's stderr read end is gone. + + `bin/run.js` — the file `bin.objectstack` / `bin.os` point at, and the only thing under `bin/` npm packs — now attaches the same no-op `error` listener to `process.stderr` that the in-repo dev shim has carried since the original finding. `process.stderr` is an `EventEmitter`, so an `error` event with nothing listening is an uncaught exception. + + Measured on the published entry with the read end destroyed (`stdio: ['ignore','ignore','pipe']`, then `child.stderr.destroy()`), traced with an observer that installs no listener and wraps no write: + + ``` + uncaughtException code=EPIPE msg=write EPIPE + at afterWriteDispatched (node:internal/stream_base_commons:159:15) + exit code=1 + ``` + + 3 of 3 runs, 3049-3433 ms in, on `os serve` over `examples/app-todo`. Read by a draining parent the same child boots and serves and exits 0, having written 7926 bytes over 16.6 s — so the crash was costing the run at its first diagnostic line and 20 of its 21 stderr writes. Failing invocations do not reach it: everything they put on stderr is written after `run()` has settled, by a handler that exits on top of its own report. + + Behaviour change worth knowing about: a long-running command (`os serve`, `os dev`, `os start`) whose reader has gone now keeps running and reports its own exit status, instead of dying on its first diagnostic write. A supervisor that destroyed the read end and relied on that crash to end the child needs to end it itself. +- f89812e: Five source comments in `@objectstack/cli` and `@objectstack/core` stop attributing unpack-time `manifest.integrity` re-verification to the cloud control plane and name the owner this repo has already ruled: the **future runtime loader** (ADR-0025 §3.5 steps 4–7). The enforce leg stays tracked on #11331. + + `packages/spec`'s `manifest.zod.ts` was corrected to that owner in an earlier change, and these five sites were left behind — so the repo stated both things at once. A comment that names the wrong owner costs nobody a build, but it teaches a reader (and a reading AI) to expect a verification that no component performs and that ADR-0025's own status line records as unimplemented. + + - `packages/cli/src/utils/osplugin.ts` — the `.osplugin` packaging docblock, and the `sriDigest` TSDoc. + - `packages/cli/src/commands/plugin/publish.ts` — the integrity-preflight comment. + - `packages/core/src/security/index.ts` — the `verifyIntegrity` export comment. + - `packages/core/src/security/plugin-artifact-integrity.ts` — the verifier's own module docblock, which had explained the module's byte-for-byte portability *by* the wrong owner. It now explains it by the leg itself: the module stays portable to whatever runs unpack-time re-verification. + + **What does NOT change.** The other half of every one of these comments — the digest map is computed by `os plugin build` and self-checked by the `os plugin publish` preflight — is true and is kept verbatim. No accept set, export, signature or runtime behaviour moves; the diff is comment prose only. + + **What moves for consumers, measured on the built output.** `@objectstack/cli` ships `dist/`, and the `sriDigest` TSDoc rides into `dist/utils/osplugin.d.ts`, so an editor's hover on `sriDigest` stops naming the control plane. `@objectstack/core`'s two sites do **not** reach its published bundle — a module docblock and a line comment above an `export {}` are both dropped from `dist/index.d.ts` — so nothing in that package's shipped bytes moves. It is declared here anyway because the pre-correction attribution is quoted in `packages/core/CHANGELOG.md`, a generated record that may not be hand-edited; a changeset naming the package is the only way the correction reaches that published record. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [429ec1e] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [dcad825] +- Updated dependencies [6136293] +- Updated dependencies [07f40e5] +- Updated dependencies [6573af9] +- Updated dependencies [54bb2f1] +- Updated dependencies [fd014b1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [ea03c7c] +- Updated dependencies [6530e04] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [954cb0b] +- Updated dependencies [159dbad] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [c1eafe6] +- Updated dependencies [ac9376a] +- Updated dependencies [a775510] +- Updated dependencies [60c0f61] +- Updated dependencies [68437d4] +- Updated dependencies [8b67272] +- Updated dependencies [44c849c] +- Updated dependencies [abb140c] +- Updated dependencies [65846bc] +- Updated dependencies [2e6a2ea] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [347b777] +- Updated dependencies [36a16d0] +- Updated dependencies [c01b3a6] +- Updated dependencies [a51eb86] +- Updated dependencies [4e090ec] +- Updated dependencies [68f8f77] +- Updated dependencies [b1b978c] +- Updated dependencies [cf74a11] +- Updated dependencies [7beaaa3] +- Updated dependencies [e944fdb] +- Updated dependencies [7092d63] +- Updated dependencies [92dc937] +- Updated dependencies [31e7542] +- Updated dependencies [29bef09] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [30b0990] +- Updated dependencies [236f2df] +- Updated dependencies [d30ccb9] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [81919a7] +- Updated dependencies [85a2459] +- Updated dependencies [693fbcb] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [fb447b4] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [ce8bfc9] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [10d05bb] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [a06faeb] +- Updated dependencies [001a83b] +- Updated dependencies [6d4d5d3] +- Updated dependencies [45cfa1b] +- Updated dependencies [7862fb7] +- Updated dependencies [1ca95df] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [7862fb7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ed5d557] +- Updated dependencies [7862fb7] +- Updated dependencies [a646120] +- Updated dependencies [5071310] +- Updated dependencies [2200f8e] +- Updated dependencies [0145680] +- Updated dependencies [bc0ac1d] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [dfb7a0d] +- Updated dependencies [2025b1f] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [5a91387] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [b371960] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [d61bad0] +- Updated dependencies [53cf263] +- Updated dependencies [d91dff4] +- Updated dependencies [21aabbc] +- Updated dependencies [4f85e4d] +- Updated dependencies [dff0bdd] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [fa85759] +- Updated dependencies [8f2ecb3] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [61b4eb3] +- Updated dependencies [cfb64a6] +- Updated dependencies [5f7fa1d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [6b66ec7] +- Updated dependencies [3e7ef9c] +- Updated dependencies [3e7ef9c] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [e1d4f9e] +- Updated dependencies [7a01847] +- Updated dependencies [a87163c] +- Updated dependencies [36a6082] +- Updated dependencies [7ad2ca0] +- Updated dependencies [0cde37d] +- Updated dependencies [7dafaae] +- Updated dependencies [52b59d6] +- Updated dependencies [9f890d3] +- Updated dependencies [720bf47] +- Updated dependencies [434ca2d] +- Updated dependencies [41cbc54] +- Updated dependencies [f502898] +- Updated dependencies [c383352] +- Updated dependencies [51ae731] +- Updated dependencies [3e560da] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [6615a02] +- Updated dependencies [9f39897] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [be92d46] +- Updated dependencies [7bf96cf] +- Updated dependencies [142c01c] +- Updated dependencies [17f8604] +- Updated dependencies [4ca358d] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [ce21963] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [6f23f0e] +- Updated dependencies [b4b37e5] +- Updated dependencies [ba426b0] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [f2f6684] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [61821e5] +- Updated dependencies [0a038cc] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [9fa5775] +- Updated dependencies [ac6213e] +- Updated dependencies [d770b3e] +- Updated dependencies [f4e6adf] +- Updated dependencies [a4816a7] +- Updated dependencies [d5c4022] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [4771bd9] +- Updated dependencies [455d037] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [ed6579b] +- Updated dependencies [65ec530] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [efc5447] +- Updated dependencies [89758ac] +- Updated dependencies [d83d079] +- Updated dependencies [afa3a26] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [1e43386] +- Updated dependencies [7370989] +- Updated dependencies [289bb43] +- Updated dependencies [094b8fd] +- Updated dependencies [46803fa] +- Updated dependencies [c7aca0d] +- Updated dependencies [1d73d45] +- Updated dependencies [8a12067] +- Updated dependencies [de75e40] +- Updated dependencies [618f70d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4998efa] +- Updated dependencies [813d6c5] +- Updated dependencies [fd75728] +- Updated dependencies [8341ed2] +- Updated dependencies [33e939f] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4177ed3] +- Updated dependencies [4c0b22b] +- Updated dependencies [460d4b8] +- Updated dependencies [c1d8f98] +- Updated dependencies [8744de9] +- Updated dependencies [a646120] +- Updated dependencies [e9fcd6b] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ebb5550] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [2024eca] +- Updated dependencies [6b8c677] +- Updated dependencies [2e35765] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [b72226f] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [b8c82de] +- Updated dependencies [dd2184a] +- Updated dependencies [0cf0867] +- Updated dependencies [8c7cca1] +- Updated dependencies [4c31f02] +- Updated dependencies [5964124] +- Updated dependencies [9408b7f] +- Updated dependencies [615fac3] +- Updated dependencies [1375344] +- Updated dependencies [ec0a6e7] +- Updated dependencies [3890244] +- Updated dependencies [1157e7b] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [3e9065c] +- Updated dependencies [b398ad2] +- Updated dependencies [ce478db] +- Updated dependencies [6c439f2] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [b224324] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [7d711c9] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [6b7d709] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] +- Updated dependencies [c550baf] +- Updated dependencies [60ff091] +- Updated dependencies [7ceb416] +- Updated dependencies [cd55558] +- Updated dependencies [78bc4ad] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/metadata-protocol@17.4.0 + - @objectstack/service-analytics@17.4.0 + - @objectstack/plugin-approvals@17.4.0 + - @objectstack/service-automation@17.4.0 + - @objectstack/lint@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/service-storage@17.4.0 + - @objectstack/service-settings@17.4.0 + - @objectstack/client@17.4.0 + - @objectstack/driver-turso@17.4.0 + - @objectstack/console@17.4.0 + - @objectstack/service-datasource@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/driver-memory@17.4.0 + - @objectstack/driver-mongodb@17.4.0 + - @objectstack/driver-sqlite-wasm@17.4.0 + - @objectstack/plugin-email@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/trigger-record-change@17.4.0 + - @objectstack/plugin-hono-server@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/cloud-connection@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/plugin-pinyin-search@17.4.0 + - @objectstack/plugin-reports@17.4.0 + - @objectstack/plugin-sharing@17.4.0 + - @objectstack/service-sms@17.4.0 + - @objectstack/trigger-api@17.4.0 + - @objectstack/mcp@17.4.0 + - @objectstack/plugin-webhooks@17.4.0 + - create-objectstack@17.4.0 + - @objectstack/trigger-schedule@17.4.0 + - @objectstack/service-job@17.4.0 + - @objectstack/metadata-core@17.4.0 + - @objectstack/account@17.4.0 + - @objectstack/setup@17.4.0 + - @objectstack/verify@17.4.0 + - @objectstack/observability@17.4.0 + - @objectstack/plugin-audit@17.4.0 + - @objectstack/service-cache@17.4.0 + - @objectstack/service-messaging@17.4.0 + - @objectstack/service-package@17.4.0 + - @objectstack/service-queue@17.4.0 + - @objectstack/service-realtime@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 88d6e00c43..74c1c1f3ce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.3.0", + "version": "17.4.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 cc1006fc28..ecfe5b62db 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,153 @@ # @objectstack/client-react +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [b1b978c] +- Updated dependencies [cf74a11] +- Updated dependencies [7beaaa3] +- Updated dependencies [e944fdb] +- Updated dependencies [7092d63] +- Updated dependencies [92dc937] +- Updated dependencies [31e7542] +- Updated dependencies [29bef09] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [61b4eb3] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/client@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index af773183e3..a79b601ad8 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.3.0", + "version": "17.4.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 aaffdbb72c..a8ae546a04 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,469 @@ # @objectstack/client +## 17.4.0 + +### Minor Changes + +- 68437d4: The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded. + + `POST /api/v1/automation/:name/runs/:runId/resume` answers a run that consumed its pause and then failed with `400 FLOW_FAILED`, and until now its `error.details` carried the run's two artefacts only (`errorMessage`, `summary`). The engine's own verdict was dropped at the door: `AutomationResult.status: 'stranded'` — a run that is terminally failed *but* repairable by an explicit operator verb, because the pause a durable decision was waiting on is gone with the failure — reached the wire as the same `400` a plain terminal failure does, so an HTTP-only caller could not tell "beyond reach" from "repair waiting". + + - **`@objectstack/spec`** declares `ResumeFailureDetailsSchema` (`@objectstack/spec/api`): `{ runId, status?: 'failed' | 'stranded', repairable }` — the machine-readable shape of a resume failure told to the caller, declared once so every carrier of the family ruling spells the same members. + - **`@objectstack/runtime`**: the resume door's `400 FLOW_FAILED` details now carry that structure beside `errorMessage` / `summary`. `runId` is the run the resume was addressed to; `status` is the engine's own stamp, forwarded verbatim when it set one and never synthesised (the subflow-child-failed exit stamps none today); `repairable` is `status === 'stranded'` and is **always present on this arm** — present-and-false on a plain terminal failure, deliberately, so an absent member reads as an older server rather than as "not repairable". The code stays `FLOW_FAILED` (no `FLOW_STRANDED` sibling is minted), so a client that treats it as terminal keeps working and one that wants to offer a repair branches on `details.repairable`, never on the message text. The trigger door and `/actions` are unchanged: they never resume, so the member is absent there and absent means "not a resume". + - **`@objectstack/client`**: `automation.resume` documents the new members. +- b1b978c: fix(client)!: the `auth.*` family declares the wire shapes better-auth actually sends — thirteen published `Promise< any >` returns narrowed (#14313) + + **BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is. + + + + Card 2 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, and every timestamp is declared as the ISO-8601 `string` the wire carries — no `Date`, no revival layer. + + ## What changed + + Thirteen `auth.*` methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change (35 entries before, 22 after): + + | method | resolved to (before) | resolves to (now) | + |:--|:--|:--| + | `client.auth.updateUser(data)` | `any` | `AuthStatusReceipt` | + | `client.auth.changePassword(req)` | `any` | `AuthPasswordChangeResult` | + | `client.auth.setInitialPassword(req)` | `any` | `AuthSetInitialPasswordResult` | + | `client.auth.changeEmail(req)` | `any` | `AuthStatusReceipt` | + | `client.auth.sendVerificationEmail(req)` | `any` | `AuthStatusReceipt` | + | `client.auth.verifyEmail(params)` | `any` | `AuthEmailVerificationResult` | + | `client.auth.sessions.revoke(token)` | `any` | `AuthStatusReceipt` | + | `client.auth.sessions.revokeOthers()` | `any` | `AuthStatusReceipt` | + | `client.auth.sessions.revokeAll()` | `any` | `AuthStatusReceipt` | + | `client.auth.twoFactor.verifyTotp(req)` | `any` | `AuthTwoFactorVerificationResult` | + | `client.auth.twoFactor.disable(req)` | `any` | `AuthStatusReceipt` | + | `client.auth.twoFactor.verifyBackupCode(req)` | `any` | `AuthTwoFactorVerificationResult` | + | `client.auth.accounts.unlink(req)` | `any` | `AuthStatusReceipt` | + + `AuthWireUser`, `AuthStatusReceipt`, `AuthPasswordChangeResult`, `AuthEmailVerificationResult`, `AuthTwoFactorVerificationResult` and `AuthSetInitialPasswordResult` are newly exported from `@objectstack/client`. Twelve of these routes are served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap and none is introduced; `setInitialPassword` is ObjectStack's own mount and answers the platform's `{ success: true }` envelope. + + ## The exact reads that stop compiling + + Everything below compiled before only because `any` is assignable to, and indexable by, everything. + + ```ts + const r = await client.auth.updateUser({ name: 'Ada' }); + r.user; // now TS2339 — the route answers `{ status: true }`, NOT the updated user + r.data; // now TS2339 — these routes carry NO envelope + + const cp = await client.auth.changePassword({ currentPassword, newPassword }); + cp.user.createdAt.getTime(); // now TS2339 — the wire sends an ISO-8601 STRING, not a Date + new Date(cp.user.createdAt); // the correct rewrite + cp.token.length; // now TS18047 — `token` is `string | null` (null unless other sessions were revoked) + + const v = await client.auth.verifyEmail({ token }); + v.user.email; // now TS18047 — `user` is `AuthWireUser | null` (null on a plain verification) + + const ok = await client.auth.setInitialPassword({ newPassword }); + ok.status; // now TS2339 — ObjectStack's mount answers `{ success: true }`, not `{ status }` + + const t = await client.auth.twoFactor.verifyTotp({ code }); + t.user.locale; // now TS2339 — ObjectStack's own sys_user columns are not on better-auth's wire user + ``` + + A caller that only read `status`, `success`, `token` (guarding `null`) or the base user columns needs no change. + + ## Timestamps: ISO-8601 `string`, never `Date` + + `AuthWireUser.createdAt` / `updatedAt` (and `banExpires`) are the vendor's `Date`-typed fields. The adapter is declared `supportsDates: false`, better-auth revives the stored string into a `Date` server-side, and `JSON.stringify` puts an ISO-8601 string back on the wire — measured `"createdAt":"2026-09-07T07:02:20.593Z"` on a real SQL driver. They are declared `string`, a type-level pin holds them there, and no revival layer exists in the SDK. + + ## Where the vendor's own declarations were the wrong answer + + - `updateUser`'s OpenAPI stub promises `{ user }`; its handler answers `{ status: true }` and puts the new fields into the session cookie. The receipt is what is declared. + - `verifyEmail`'s stub declares `user` required; the handler answers `user: null` on a plain verification and the updated user only on a change-email verification. + - A nullable column (`image`, `banReason`, `banExpires`) arrives as `null` on the SQL drivers and as an ABSENT key on a store that does not materialise unset columns — both measured — so each is `?: … | null`. + + ## `auth.deleteUser` is deliberately NOT bound + + The fourteenth method keeps its `Promise< any >` and its ledger entry. Its route is switched off by maintainer ruling (2026-08-12 on #7735; `auth-route-ledger.ts` books it `disabled`), and measured against a real server it answers HTTP 404 with a ZERO-BYTE body once the last-local-credential guard is satisfied — so `this.fetch` throws before `res.json()` ever runs and the method has no success path a caller can observe. No declared return type can be honest for a value the runtime never delivers. That the shrink-only ledger still carries exactly this one `auth.*` entry is the mechanism working. +- 7beaaa3: fix(client)!: `oauth.applications.delete` resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete (#15451) + + **BREAKING** on two independent axes, and it makes a published method usable for the first time. Before this change `client.oauth.applications.delete(id)` **rejected on every successful delete** — there was no success path a caller could observe. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`); the version number is not the migration signal here, this entry is. + + + + The fifth and last method of the `oauth.*` family, and the one #14312 / PR #15445 deliberately could not close: its ruling fenced that card to *narrowing published return types*, and no declared return type could be true while the `res.json()` call stood. + + ## The defect, measured end to end + + Real `betterAuth` + real `@better-auth/oauth-provider` over the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the **real** `ObjectStackClient` with only the socket stood in for: + + ``` + POST /api/v1/auth/oauth2/delete-client -> 200 · 0 bytes + content-type: application/json + content-length: (absent) + through the client, BEFORE -> REJECTED: SyntaxError | Unexpected end of JSON input + the row, server-side -> ALREADY GONE (get-client answers 404 not_found) + through the client, AFTER -> RESOLVED | undefined + ``` + + The handler returns nothing and the vendor declares the endpoint `void`. `res.json()` had nothing to parse, so the method rejected — *after* the delete had committed. A caller who did the obvious thing saw a failure, retried, and the retry failed **differently**, because the row no longer existed. + + ## What changes for a caller + + | | before | now | + |:--|:--|:--| + | a successful delete | rejects `SyntaxError` | resolves | + | the resolved value | `any` (unreachable — the promise never resolved) | `void` | + | deleting a client that is not there | rejects `not_found` | rejects `not_found` — unchanged | + | a malformed non-empty body | rejects `SyntaxError` | rejects `SyntaxError` — unchanged | + + ⚠️ **The `catch` you wrote around this call stops firing on success.** Code shaped like + + ```ts + try { await client.oauth.applications.delete(id); } + catch { /* the delete probably worked anyway */ } + ``` + + still compiles and still runs, but its catch block was executing on **every** successful delete and now executes only on a real failure. Any workaround that lived in there is now inert and can be deleted. And because the promise never used to resolve, a read off its resolved value — `(await …delete(id)).deleted` — was dead code that has never executed; it now stops compiling (TS2339), which is the compiler delivering the change at the call site. + + ## Why `void`, and not `{ deleted: boolean }` + + "Deleted" and "was already gone" **are** distinguished by the route, but on the error channel: a missing client answers 404 `{ error: 'not_found' }`, which the client already raises as a throw. The 200 answer carries zero bytes and therefore zero information, so a synthesised `{ deleted: true }` would be a shape the wire never sends and strictly less informative than the 404 a caller already receives. + + ## Why the emptiness is detected by reading the body + + Both shortcuts were measured against the real route and both are unusable: the status is **200**, not the `204` five other delete surfaces in this client key off, and the response carries **no `content-length` header at all** — so a header test would never fire and would leave the defect in place while looking like a fix. The body itself is the only thing that answers. + + A non-empty body is still parsed and its failure still thrown, so **the only behaviour this change moves is the zero-byte case**: a malformed response stays loud, and the day this route grows a payload, surfacing it is a deliberate widening of the return type rather than a silent change of shape. + + `packages/client/exported-any-returns.json` loses this method's entry in the same change — the ledger is shrink-only, so the entry goes **with** the binding. Its last `oauth.*` entry is now gone; 35 sites remain open. +- e944fdb: fix(client)!: the `oauth.*` family declares the wire shapes better-auth actually sends — four published `Promise< any >` returns narrowed (#14312) + + **BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is. + + + + Card 1 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, and better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, not the wire fact. + + ## What changed + + Four methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change: + + | method | resolved to (before) | resolves to (now) | + |:--|:--|:--| + | `client.oauth.applications.register(req)` | `any` | `OAuthApplicationRegistration` | + | `client.oauth.applications.get(id)` | `any` | `OAuthApplication` | + | `client.oauth.applications.getPublic(id)` | `any` | `OAuthApplicationPublic` | + | `client.oauth.consent(req)` | `any` | `OAuthConsentResult` | + + `OAuthApplication`, `OAuthApplicationRegistration`, `OAuthApplicationPublic` and `OAuthConsentResult` are newly exported from `@objectstack/client`. These four routes are served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap, and none is introduced. + + ## The exact reads that stop compiling + + Everything below compiled before only because `any` is assignable to, and indexable by, everything. + + ```ts + const app = await client.oauth.applications.get('c_1'); + app.data; // was fine; now TS2339 — these routes carry NO envelope + app.anythingAtAll; // was fine; now TS2339 + + const pub = await client.oauth.applications.getPublic('c_1'); + pub.client_secret; // now TS2339 — the public projection hand-picks 7 columns + pub.grant_types; // now TS2339 — same reason + pub.disabled; // now TS2339 — same reason + + const decision = await client.oauth.consent({ accept: true }); + decision.client_id; // now TS2339 — consent answers `{ redirect, url }` + + // Timestamps are RFC 7591 NUMBERS (Unix epoch seconds), so a caller that + // guessed `Date` or ISO `string` now fails: + new Date(app.client_id_issued_at!).toISOString(); // TS2769: number is not a Date arg + app.client_id_issued_at!.slice(0, 10); // TS2339: not a string + new Date(app.client_id_issued_at! * 1000); // the correct rewrite + ``` + + A caller that only read `client_id`, `client_secret`, `redirect_uris` or `url` needs no change. + + ## Timestamps: `number`, not `Date` and not ISO-8601 + + The ruling ordered every `Date`-typed field declared as an ISO `string` and forbade both a `Date` declaration and a runtime revival layer. **This family has no `Date` field to convert.** RFC 7591 carries `client_id_issued_at` and `client_secret_expires_at` as Unix-epoch SECONDS, and the provider converts its stored `Date` to a number before serialising, so the wire sends neither a `Date` nor an ISO string. Both are declared `number`, and a type-level pin holds them there. The ruling's prohibitions are satisfied: nothing declares a `Date`, and no revival layer exists. + + ## Two places better-auth's own types were the wrong answer + + Read off the wire against a real server, not off the vendor's `.d.ts`: + + - `getPublic` is declared `OAuthClient` — the full row — but its handler hand-picks seven columns. `OAuthApplicationPublic` is that projection, derived with `Pick` so it cannot drift from its parent. Its `redirect_uris` is always `[]` on this route and carries no information. + - `user_id` and `application_type` are declared nullable by the vendor, but the serialiser folds a null column to `undefined`, so `null` is unreachable and is not declared. + + ## `oauth.applications.delete` is deliberately NOT bound + + The fifth method of the family keeps its `Promise< any >` and its ledger entry. Its route answers HTTP 200 with a zero-byte body, so its `res.json()` rejects with a `SyntaxError` on every successful delete. No annotation can be honest while that call stands, and binding it needs a behaviour change — a decision beyond this card's type-narrowing scope. That the shrink-only ledger still carries exactly this one entry is the mechanism working. +- 7092d63: fix(client)!: the `organizations.*` family declares the wire shapes better-auth actually sends — nineteen published `Promise< any >` returns narrowed, twenty ledger entries closed (#14314) + + **BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`) — the version number is not the migration signal here, this entry is. + + + + Card 3 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, and every timestamp is declared as the ISO-8601 `string` the wire carries — no `Date`, no revival layer. + + ## What changed + + Nineteen `organizations.*` methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change — together with the entry for `organizations.invitations.resend`, which has no annotation of its own and inherits `invite`'s (22 entries before, 2 after): + + | method | resolved to (before) | resolves to (now) | + |:--|:--|:--| + | `client.organizations.create(req)` | `any` | `OrganizationCreateResult` | + | `client.organizations.update(id, data)` | `any` | `OrganizationEchoWire` | + | `client.organizations.setActive(id)` | `any` | `OrganizationWire \| null` | + | `client.organizations.get(id)` | `any` | `OrganizationFullWire \| null` | + | `client.organizations.listMembers(id)` | `any` | `OrganizationMembersPage` | + | `client.organizations.invite(req)` | `any` | `OrganizationInvitationWire<'pending'>` | + | `client.organizations.leave(id)` | `any` | `OrganizationMemberWithUserWire` | + | `client.organizations.delete(id)` | `any` | `OrganizationWire` | + | `client.organizations.removeMember(id, params)` | `any` | `OrganizationRemoveMemberResult` | + | `client.organizations.updateMemberRole(id, params)` | `any` | `OrganizationMemberWire` | + | `client.organizations.getActiveMember(id)` | `any` | `OrganizationMemberWithUserWire` | + | `client.organizations.invitations.cancel(id)` | `any` | `OrganizationInvitationWire<'canceled'>` | + | `client.organizations.invitations.accept(id)` | `any` | `OrganizationInvitationAcceptResult` | + | `client.organizations.invitations.reject(id)` | `any` | `OrganizationInvitationRejectResult` | + | `client.organizations.invitations.resend(inv)` | `any` (inherited) | `OrganizationInvitationWire<'pending'>` (inherited from `invite`) | + | `client.organizations.teams.create(req)` | `any` | `OrganizationTeamWire` | + | `client.organizations.teams.update(params)` | `any` | `OrganizationTeamWire` | + | `client.organizations.teams.delete(params)` | `any` | `OrganizationTeamRemovedReceipt` | + | `client.organizations.teams.addMember(params)` | `any` | `OrganizationTeamMemberWire` | + | `client.organizations.teams.removeMember(params)` | `any` | `OrganizationTeamMemberRemovedReceipt` | + + `OrganizationWire`, `OrganizationEchoWire`, `OrganizationCreateResult`, `OrganizationFullWire`, `OrganizationMemberWire`, `OrganizationMemberUserWire`, `OrganizationMemberWithUserWire`, `OrganizationMembersPage`, `OrganizationRemoveMemberResult`, `OrganizationInvitationWire`, `OrganizationInvitationAcceptResult`, `OrganizationInvitationRejectResult`, `OrganizationTeamWire`, `OrganizationFullTeamWire`, `OrganizationTeamMemberWire`, `OrganizationTeamRemovedReceipt` and `OrganizationTeamMemberRemovedReceipt` are newly exported from `@objectstack/client`. Every one of these routes is served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap and none is introduced. `@objectstack/spec/identity`'s `Organization` / `Member` / `Invitation` are deliberately NOT relayed: each declares `updatedAt` required, and the wire never carries it (the adapter's output transform walks better-auth's own schema, which has no such column); `InvitationStatus` IS relayed, narrowed to the literal each handler pins. + + ## The exact reads that stop compiling + + Everything below compiled before only because `any` is assignable to, and indexable by, everything. + + ```ts + const org = await client.organizations.setActive(id); + org.id; // now TS18047 — `setActive` (and `get`) answer `null` for an empty id with no active organization + if (org?.metadata) JSON.parse(org.metadata); // fine — on the READ routes `metadata` is the stored JSON text, `null`/absent when unset + (await client.organizations.get(id))!.metadata.plan; // now TS2339 — it is a string here, not an object + + const echo = await client.organizations.update(id, { metadata: { plan: 'pro' } }); + JSON.parse(echo.metadata); // now TS2345 — the two WRITE routes (`create`, `update`) echo `metadata` already decoded + + const deleted = await client.organizations.delete(id); + deleted.length; // now TS2339 — the route answers the organization ROW, not the id string the vendor's OpenAPI stub declares + deleted.updatedAt; // now TS2339 — `sys_organization.updated_at` never reaches the wire + deleted.createdAt.getTime(); // now TS2339 — ISO-8601 STRING, not a Date; `new Date(deleted.createdAt)` is the rewrite + + const m = await client.organizations.updateMemberRole(id, { memberId, role: 'admin' }); + m.member.role; // now TS2339 — the row is answered BARE, not as `{ member }` (the vendor's stub is wrong) + + const removed = await client.organizations.removeMember(id, { memberIdOrEmail }); + removed.member.user.email; // now TS18048 — `user` is joined on ONLY when the member was addressed by email + + const inv = await client.organizations.invite({ email, organizationId: id }); + if (inv.status === 'accepted') { /* now TS2367 — `invite` answers the literal `'pending'` */ } + + (await client.organizations.listMembers(id)).data; // now TS2339 — no envelope on any route of this family + ``` + + A caller that read `id`, `name`, `slug`, `role`, `email`, `members`, `total` or `message` off these values, or narrowed `null` where it can arrive, needs no change. + + ## Timestamps: ISO-8601 `string`, never `Date` + + `createdAt` on every row type, `updatedAt` on teams and `expiresAt` on invitations are the vendor's `Date`-typed fields. The adapter is declared `supportsDates: false`, better-auth revives the stored string into a `Date` server-side, and `JSON.stringify` puts an ISO-8601 string back on the wire — measured `"createdAt":"2026-09-07T09:27:01.545Z"` on a real SQL driver. They are declared `string`, a type-level pin holds them there, and no revival layer exists in the SDK. + + ## Where the vendor's own declarations were the wrong answer + + - `delete`'s OpenAPI stub declares the deleted id as a `string`; the handler answers the organization row. + - `updateMemberRole`'s stub declares `{ member }`; the handler answers the membership row bare, without `user`. + - `metadata` is one column with two wire forms: `create` and `update` decode it, every read route answers the stored JSON text (`setActive`, `get`, `delete`, `list`). + - `removeMember` joins `user` on only when the member was addressed by email; the by-id path strips it. + - Inside `get(...).teams` the vendor's `memberCount` is NOT stripped (it is on `teams.create` / `teams.update`). `teams.update` writes no timestamp of its own — `updatedAt` there comes from better-auth's team schema (`onUpdate` default, applied on every update) with the platform's `sys_team.updated_at` stamping behind it, measured on a real SQL driver; the default team minted at organization creation is written without `updatedAt` by the vendor and carries the platform's stamp, so `get(...).teams[].updatedAt` is declared optional as the safe direction. + + ## Not a behaviour change + + `getActiveMember(organizationId)` keeps sending its query parameter; the measured fact that the server ignores it and answers the session's ACTIVE organization is recorded in the method's JSDoc and filed separately — a body change is outside this family's ruled narrowing scope. +- e08892d: `cloneData` reports `droppedFields` like every other create face: `CloneDataResponseSchema` (`@objectstack/spec/api`) gains an optional `droppedFields` member of the same shape as `CreateDataResponseSchema`'s, and the `POST /data/:object/:id/clone` 201 body carries it whenever the engine stripped a static `readonly` column from the clone. + + A clone IS a create, and it is the one create shape that can carry a read-only column without the caller typing it: the source row is copied whole (`approval_status: 'approved'` included), `overrides` are applied on top, and the copy is inserted. Since the create-side strip moved into `engine.insert` that column has been stripped and logged at `warn` — but the 201 body said nothing, so a caller that cloned an approved record and read `record.approval_status: 'draft'` back had no field in the response telling it why, while `createData`, `createManyData`, `insertManyData` and every `batchData` row that created already answered on the wire. Maintainer ruling 2026-09-08 (option 1 on #15703): report it, the same way. + + - **`@objectstack/spec`** — `CloneDataResponseSchema.droppedFields`: `DroppedFieldsEvent[]`, optional, omit-when-empty — present ONLY when ≥1 field was dropped, and the clone still succeeded without them (status unchanged). The schema is declared AS PRODUCED, so the member and the producer land in one change. Additive: a client that reads only `object` / `id` / `sourceId` / `record` sees no difference. + - **`@objectstack/metadata-protocol`** — `cloneData` passes the engine the same `onFieldsDropped` listener `createData` wires and spreads the collected events onto its return as `droppedFields`. The strip itself is unchanged and still the engine's (`isSystem`-gated, `defaultValue` re-derived); what is new is that a copied-in or overridden readonly key is now named in the body instead of only in the server log. + - **`@objectstack/client`** — `CloneDataResult` (the declared mirror of `CloneDataResponseSchema`, the return type of `client.data.clone`) gains the same optional `droppedFields?: DroppedFieldsEvent[]`, so a TypeScript caller reads the member without a cast; its docblock no longer states that the clone producer emits no write-observability event. + + Body only, deliberately: the clone route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header (the single-record `POST /data/:object` and `PATCH /data/:object/:id` mounts do); the schema's `.describe()` says so rather than promising a header the route does not send. +- 61b4eb3: `client.meta.getHistory` answers the published `HistoryMetaItemResponse` on **both** of its exits, and the route ledger names the schema. + + **BREAKING (types):** the unscoped `client.meta.getHistory` declared a hand-written inline shape whose `actor` member was `string`. The door answers `null` there for every system-initiated write — boot sync, migration, a scheduled job — and the published schema declares it "never a sentinel string", so consumers that resolve the actor against `sys_user` must be able to tell "nobody" from "a user id". Reading `actor` without a null check compiled against a promise the door has never made; it no longer compiles. The same rebind closes the vocabulary of `op` (the ADR-0008 §2.4 change-log verbs, previously a plain `string`). + + Three members the inline shape omitted become reachable in the same move: `version` (the per-`(org,type,name)` lineage counter that `rollbackItem({ toVersion })` pins against), `previousName` (set on `op: "rename"`), and `ref.version`. `ref.org` was declared optional and is now what the producer always writes. + + The scoped twin — `client.environments.use(id).meta.getHistory` — carried no declaration at all: no return annotation, and the SDK's internal unwrap called with no type argument, so the published method resolved to `Promise` and every caller had to narrow by hand against nothing. It is the SAME mount as the unscoped exit, replayed against `/environments/:environmentId`, so it answers a byte-identical body; the two now name one type. Binding only one exit would have relocated that divergence rather than removed it, and the equality of the two declared types is pinned rather than left to review. + + `@objectstack/rest` is `patch`: the route-ledger row for `GET /api/v1/meta/:type/:name/history` now names `HistoryMetaItemResponseSchema`. Data only, in a package-internal module — no route, handler or emitted byte changes. The row could not name the schema before because the declaration (#12005) landed after the row was written. + + No wire byte moves anywhere in this change. `HistoryMetaItemResponseSchema` is a describe-only transcription of what `historyMetaItem` already returned, and the SDK's runtime path is untouched — only what the compiler knows about it. + + + +### Patch Changes + +- cf74a11: The client SDK reads the CRUD data prefix off the discovery document instead of restating `/data` as a literal, so a deployment that sets a non-default `crud.dataPrefix` is reachable through the scoped surface. + + `crud.dataPrefix` moves two things together: REST mounts every CRUD route under `${basePath}${crud.dataPrefix}`, and the discovery handler advertises the same value as `routes.data = ${realBase}${crud.dataPrefix}`. The SDK is the third surface describing those same paths, and its scoped half was not reading the value — it wrote `/data` into all seventeen of its data methods. On a deployment that moved the prefix, that half called paths the server does not mount, while the unscoped half of the *same* SDK called the right ones: the unscoped methods build `${baseUrl}${getRoute('data')}` and `routes.data` already carries the prefix. One SDK disagreed with itself about where the data routes are. + + - **`_dataPrefix()` recovers the prefix from the advertised routes.** `routes.data` is one string carrying two unknowns (`{realBase}{dataPrefix}`) and no discovery key carries either half alone, so the split is recovered in two steps. A value that already ends with the conventional `/data` *is* the default prefix — taken first, which is what makes the change unable to regress any deployment that works today: every later rule can only run in the branch where the previous single-literal code was already wrong. Otherwise `routes.metadata` supplies the missing equation, being `{realBase}{metadata.prefix}` over the same base, so the two advertised routes share exactly `realBase` plus whatever their prefixes share; cutting that common run back to its last `/` lands on the boundary. This also covers a document served from the environment-scoped mount, where both routes carry the same `/environments/{id}` segment. + - **It declines rather than guess.** Where the document does not determine the split — no advertised routes, no `routes.metadata`, or a derived prefix of `/` or empty — the derivation returns the conventional `/data`, which is byte-identical to the previous behaviour. This follows the rule the neighbouring `_apiBase()` already sets in this file, and it is why an unconnected client is unaffected. + - **`_apiBase()` strips the advertised prefix instead of the literal `/data`.** It previously declined whenever the prefix was non-default, because the only suffix it knew how to strip was `/data`. It now strips whatever `_dataPrefix()` read, so the base and the prefix are derived by one rule and cannot disagree. On every default-prefix deployment the result is unchanged. + + No new client option and no new configuration: the value is read from the server that already publishes it. A client that never calls `connect()` builds exactly the URLs it built before. +- 92dc937: The README's analytics and automation examples read the resolved payload. + + `client.analytics.query` / `analytics.meta` and `client.automation.trigger` stopped handing back the dispatcher's `{ success, data }` envelope in 17.0.0: each resolves to the payload itself. The README's namespace tour still showed all three as bare `await` calls with nothing reading the resolved value, so the package's own front page taught nothing about which shape comes back — neither wrong nor useful. Each of the three now assigns its result and reads one member of it: `report.rows` / `report.fields[0].name` (`AnalyticsResult`), `cubes[0].name` (the bare `CubeMeta[]`), `run.status` (`AutomationResult`) — the members those contracts actually declare, read off the payload rather than off a `data` wrapper. + + No behaviour changes; this is the README that ships inside the package. The docs site's Client SDK and Data API pages take the same treatment in the same PR. +- 31e7542: The README's namespace tour calls `approvals.approve` / `approvals.reject` with the decision object they declare, and `auth.register` with the field its schema requires. + + `approve` and `reject` take `(requestId: string, decision?: { actorId?: string; comment?: string; attachments?: string[] })`. The tour passed the comment as a bare string — `approve(requestId, 'LGTM')` — which a TypeScript reader hits as `TS2559` and a JavaScript reader does not hit at all: the string goes out as the request body where the route reads the decision object's fields, so the approval is recorded and its **reason is silently dropped**. In an approvals surface a lost reason is not a typo. The calls now read `{ comment: 'LGTM' }` / `{ comment: 'Incomplete' }`, the spelling the docs site's Client SDK page already carried. + + Type-checking the whole fence against the package's own built `dist/index.d.ts` found one more call in the same defect class — a live method given the wrong argument shape. `auth.register` takes `RegisterRequest`, whose schema declares `name: z.string()` as required (and pins the rejection of a request without it); the tour passed only `{ email, password }`, failing `TS2345`. It now passes `name` as well, again matching the Client SDK page. All 35 calls in the fence type-check clean against the built declarations after this change. + + No behaviour changes and no source change: this is the README, and `files` ships `README.md` inside the tarball, so correcting it moves what `@objectstack/client` publishes — it is the package's npm front page. +- 29bef09: The README's namespace tour documents the `ai` surface that exists, not the three methods v17 removed. + + `client.ai.nlq` / `.suggest` / `.insights` were deleted in 17.0.0 (#3718) — and no server in any repo ever mounted `/api/v1/ai/{nlq,suggest,insights}`, so they 404ed for the whole life of the namespace. The README's "AI Services" example still showed all three. Because `files` ships `README.md` inside the tarball, that example is the package's npm front page: a TypeScript reader copying it gets TS2339 on three properties that are not on `client.ai`, and a JavaScript reader gets a runtime `TypeError`. + + The block now shows the surface the client really exposes — `ai.chat` (with a read of `answer.content` / `answer.usage`), `ai.complete`, `ai.models`, `ai.conversations.list`, `ai.agents.chat`, `ai.pendingActions.list` — every call type-checked against the package's own published `dist/index.d.ts`. It also names the condition a reader will otherwise hit unexplained: the AI routes are served by `service-ai` (a Cloud/EE package), and an environment without it answers 501 rather than 404, with the remedy discovery reports under `services.ai`. + + No behaviour changes. `patch` rather than no changeset because the README is a published file of this package, so correcting it changes what `@objectstack/client` ships; the docs site's Client SDK page already carried this correction and is untouched here. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 7495670cd9..de67ac3a0f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.3.0", + "version": "17.4.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 9065017f42..52cdcec06c 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/cloud-connection +## 17.4.0 + +### Patch Changes + +- 6b66ec7: Fix: the marketplace install-local routes now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply at these doors. + + Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran here, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. + + The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage and answers 503 rather than admitting the caller. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [429ec1e] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [c1eafe6] +- Updated dependencies [68437d4] +- Updated dependencies [44c849c] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [68f8f77] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [ce8bfc9] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [3e560da] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [f2f6684] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [ac6213e] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [8a12067] +- Updated dependencies [de75e40] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [c1d8f98] +- Updated dependencies [8744de9] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index 035dedc3d3..a25b5b7508 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.3.0", + "version": "17.4.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 6285b514ce..5c396bf675 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/connector-mcp +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 16ce97795e..86306b8e56 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.3.0", + "version": "17.4.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 2cf5cc8ea3..588f2ec2e4 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/connector-openapi +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 2119334470..3a91d73a94 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.3.0", + "version": "17.4.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 6bae321440..db1082fea9 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/connector-rest +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 86a2426722..4246e3be02 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.3.0", + "version": "17.4.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 e679c05976..b05acc32b6 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/connector-slack +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index b65c234370..01f785b5a2 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.3.0", + "version": "17.4.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 91cb8d2cae..073d4fc2af 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,92 @@ # @objectstack/console +## 17.4.0 + +### Minor Changes + +- 30b0990: Console (objectui) refreshed to `53ded82bf7a4`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 23 releasing of 28 changesets added across 34 non-merge commits; omitted: 5 release-nothing changesets, 7 commits carrying no changeset (they ship no package code). + + - **minor** — Array filters on analytics aggregates were posted un-lowered and refused by the runtime with 400; they are now lowered to the canonical `FilterCondition` before the wire. (objectui `53ded82bf`) + - **minor** — Retire `ChatbotSchema.displayMode` — and its copy on `ChatbotFloatingSchema` — as an ADR-0049 retirement tombstone, and remove the `chatbot-floating` registration's "Display Mode"… (objectui `3e377c931`) + - **minor** — `ChartDataSeriesSchema` (and its TS twin `ChartDataSeries`) now REFUSES `chartType` on a chart series BY NAME and points at `type` — the renderer-internal spelling the non-strict… (objectui `caf477fa8`) + - **minor** — `DataScopeManager` now **denies** a row when a row-level scope rule carries an operator its evaluator does not implement. It used to **admit** the row. (objectui `83c77dc30`) + - **minor** — One home for the `datetime` display convention (objectui#7443). (objectui `81a2eb1fb`) + - **minor** — **`BaseSchema.visible` / `.hidden` / `.disabled` now declare the CEL envelope object the renderer already evaluates, as one named wire type** (objectui#7530, maintainer ruling 202… (objectui `c354ce5db`) + - **minor** — **Breaking for authored metadata:** `MarkdownSchema.sanitize` and `MarkdownSchema.components` are RETIRED (objectui#6972, ADR-0049 enforce-or-remove). A `markdown` node that autho… (objectui `446d93d4e`) + - **minor** — One named, importable authoring-face type per `plugin-chatbot` registration: `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` join `ChatbotSchema` (objectui#7655, under the obj… (objectui `4ce14f125`) + - **minor** — Six user-visible fixes across the maker surface, the assistant rail and the dataset captions. (objectui `64dae8e71`) + - **minor** — `DrillDownConfigSchema` is the zod mirror of `DrillDownConfig`, and both declarations that carry `drillDown` reference it — `ChartSchema` (`zod/data-display.zod.ts`) and `ObjectDa… (objectui `52c8cf741`) + - **minor** — `ObjectGallerySchema` and `ObjectDataTableSchema` are members of `ObjectQLComponentSchema` on both faces — the TS union in `objectql.ts` and the zod union in `zod/objectql.zod.ts`… (objectui `52c8cf741`) + - **minor** — `ChartDataSeriesSchema` (and its TS twin `ChartDataSeries`) declares the six series keys the renderer reads — `label`, `variant`, `opacity`, `dashArray`, `stack`, `yAxis` — which… (objectui `8fe8e5c16`) + - **minor** — `AlertDialogSchema` now declares the four keys the `alert-dialog` renderer actually reads (objectui#7104): `content` (the dialog body, `SchemaNode | SchemaNode[]` like every sibli… (objectui `8ad218d58`) + - **minor** — **BREAKING** — `SchemaRegistry['kanban']` stops describing a component it cannot name (objectui `bc640ec56`) + - **minor** — One authority per exported type name, batch 3 of objectui#6349: `ComboboxOption`, `NamedActionDef`, `OrgTranslate`. (objectui `6e8863093`) + - **minor** — **Removes two published exports.** Retire the `MobileResponsiveConfig` and `GestureConfig` types (objectui#7519, ADR-0049 enforce-or-remove). Both names are deleted from `@object-… (objectui `51eb51558`) + - **minor** — Retire `FloatingChatbotConfig.triggerIcon` (objectui#7654, ADR-0049 enforce-or-remove). (objectui `a3eb5d07a`) + - **minor** — One shared record-source ladder, five plugins delegate (objectui#7632). (objectui `ce2aaefe1`) + - **patch** — The published `@default` documentation on two `layout.ts` members now matches the value the renderer actually applies. `ContainerSchema.maxWidth` documented `'lg'` while `containe… (objectui `e546222b3`) + - **patch** — Declare `avatar` and `avatarFallback` on `ChatMessage`, on both faces (objectui#7295 — the residue of objectui#4424, whose `RuntimeOnlyMessageKeys` named only the three keys API m… (objectui `858cd72e3`) + - **patch** — `ClassNameStylePropsSchema` describes itself by its two keys (objectui#7578). (objectui `9587fc959`) + - **patch** — Declare `wrapperClass` on `CheckboxSchema`, on both faces (objectui#6938 — the residue of that card; its `context-menu` half landed with objectui#6939 group 1). (objectui `b74a8598d`) + - **patch** — The standalone runner renders `AppAction.items` from its declared type only, which makes `AppActionSchema.onClick`'s retirement message true again (objectui#6854, maintainer rulin… (objectui `adb2a86db`) + + ⚠️ 2 of these carry a breaking change: 2 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 7 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(hooks): read the edited path from the key the routed tool carries (#7686) (objectui `565f2b6aa`) + - _(no changeset)_ fix(schema-catalog): drop the undeclared `variant` key from the dropdown-menu Delete item (#7717) (objectui `f96a781c8`) + - _(no changeset)_ fix(hooks): an escaped quote inside a double-quoted word does not close it (#7695) (objectui `6eebc54b6`) + - _(no changeset)_ docs(skills): correct six wrong shipped-API facts in the published objectui guides (#7677) (objectui `ab771d2dd`) + - _(no changeset)_ docs(skills): record ComponentInput's five ADR-0049 tombstones in the plugin guide (objectui#7636) (#7647) (objectui `09163884f`) + - _(no changeset)_ docs(agents): state the changeset gate's full population, not `src/` alone (#7640) (objectui `0b1e39d4a`) + - _(no changeset)_ docs(charts): rewrite the inline-series chart examples into the model the renderer implements (#7679) (objectui `d3f5256ce`) + + + + objectui range: `a472b07167a3...53ded82bf7a4` +- 236f2df: Console (objectui) refreshed to `a472b07167a3`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 15 releasing of 18 changesets added across 29 non-merge commits; omitted: 3 release-nothing changesets, 11 commits carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — Converge the lookup/user widget metadata on the spec's camelCase — one concept, one spelling (objectui#7155, maintainer ruling A′ of 2026-09-03, director decision batch #19). (objectui `351eb3181`) + - **minor** — **BREAKING** — One authority for `KanbanSchema` / `KanbanColumn` / `KanbanCard`: the bare names now belong to `@object-ui/plugin-kanban` (objectui#6172, closing the cross-package half of objectu… (objectui `2c71482ea`) + - **minor** — Retire `ComponentInput.inputType` — the fifth and last key objectui#5905 named (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-31, option B). (objectui `1ec291c0d`) + - **minor** — `@object-ui/core` publishes `resolveRecordSourceObjectName`, the ONE reader for "which object is this block bound to" (objectui#7627). (objectui `b041b9c0c`) + - **minor** — **Published TS surface narrowed:** `DashboardComponentSchema` no longer declares the dashboard-root `title` member (objectui#7623). (objectui `5d0876c5c`) + - **minor** — **BREAKING** — BREAKING (`@object-ui/components`): the chart primitives — `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle` and the `Char… (objectui `7bf244bea`) + - **minor** — ListView: fold `data={{ provider: 'object', object }}` onto `objectName`, and read the author's view kind from `specType` / `type` (objectui#7477 — step 6 of #2890, released by th… (objectui `00d2fa682`) + - **minor** — Retire the dashboard-**root** `title` read across all five surfaces (objectui#7509, maintainer ruling 2026-09-04, decision batch #29, option C, under ADR-0049). (objectui `1cca678ba`) + - **minor** — **BREAKING** — Re-home the breakpoint layout vocabulary and delete the two dead responsive implementations (objectui#7580, maintainer ruling 2026-09-04, option A). (objectui `e62c44e7e`) + - **minor** — `@object-ui/types/zod`: the zod const `StylePropsSchema` is renamed to `ClassNameStylePropsSchema` (objectui#5928). **The old name is gone** — there is no deprecated alias and no… (objectui `24e027e93`) + - **patch** — Fix `extractToc` eating the underscores out of a `SCREAMING_SNAKE` heading, so its `#id` links resolve to the heading they name again (objectui#7667). (objectui `a472b0716`) + - **patch** — Remove `src/ui/toast.tsx`, an unreferenced primitive, and the dependency only it imported (objectui `2f61238b9`) + - **patch** — Fix `extractToc` deleting tag-shaped text that lives INSIDE an inline code span, so its `#id` links resolve to the heading they name again (objectui#7658). (objectui `90c6d090d`) + - **patch** — A record-page URL now names the object the clicked rows actually came from, in `ObjectTree` and `ObjectCalendar` (objectui#7638). (objectui `2ce2612df`) + - **patch** — fix(app-shell): the object-field options editor no longer drops `default` and `visibleWhen` on save (objectui `97c3e1972`) + + ⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 11 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(scripts): check-doc-links resolves the #fragment, not just the file (objectui#7644) (#7657) (objectui `f7cf7e8a9`) + - _(no changeset)_ docs(plugin-chatbot): document chatbot-floating's seven declared inputs keys (objectui#7594) (#7656) (objectui `8e501cb97`) + - _(no changeset)_ docs(agents): record the never-approve seat rule beside the governed never-list (#7630) (objectui `2e99852ca`) + - _(no changeset)_ refactor(examples): drop the inert root `title` from six catalog dashboards (#7634) (objectui `46cde8264`) + - _(no changeset)_ docs(check-skill-examples): drop the stale zero-jsonc-fences claim (#7631) (objectui `0b24d7f85`) + - _(no changeset)_ docs(governed-guard): replace the retired sha pin with the ruled approval-record predicate (#7616) (objectui `11edab88f`) + - _(no changeset)_ docs(skills): split multi-document JSON fences, drop the `...` elisions, mark every parsing fence (#7608) (objectui `89d6adf37`) + - _(no changeset)_ fix(scripts): judge spec citations at member granularity, and stop the header teaching a retired filter (objectui#7513) (#7617) (objectui `d28d87bf4`) + - _(no changeset)_ fix(governed-guard): an authorised approval record satisfies the queue leg on any commit (#7606) (objectui `0d8fd7ce3`) + - _(no changeset)_ chore(deps): Bump fumadocs-core from 16.14.4 to 16.15.4 (#7059) (objectui `1bae75bb8`) + - _(no changeset)_ docs(claude-md): collapse the two AGENTS.md excerpts to rule + hook + pointer (#7600) (objectui `c70ebaaeb`) + + + + objectui range: `00d3f09c500c...a472b07167a3` + ## 17.3.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index f0d7d6a926..8e4edce2a2 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.3.0", + "version": "17.4.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 5fc45598f2..c39eb15b9e 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,644 @@ # @objectstack/core +## 17.4.0 + +### Minor Changes + +- 2ed6be6: Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. + + A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule. + + Two changes, and neither moves what a rule evaluates to: + + - **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`. + - **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects. + + `error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list. +- b0529e1: fix(core): `ResolvedAuthzContext.authRefusal` is removed — a published member nothing ever read (#14273) + + **BREAKING** published-type narrowing, shipped as `minor` under the repo's + launch-window convention for breaking changes. `ResolvedAuthzContext` — the + envelope `resolveAuthzContext` answers, exported from `@objectstack/core`'s + root entry — loses its optional `authRefusal?: { reason; message }` member. + Maintainer ruling 2026-09-02 (option A, ADR-0049 enforce-or-remove), + re-affirmed 2026-09-03 as A1 with the carriers a published narrowing owes + once the type was measured as public API: the member was written by the two + posture-conditional API-key refusals (`organization_required` at admission, + `organization_membership_ended` after grants) since #8287 and read by nothing + — zero runtime readers across every transport and consumer in the repo for + its whole life; only test assertions ever looked at it. + + What changes: + + - `ResolvedAuthzContext` no longer declares `authRefusal`. Code that reads + `ctx.authRefusal` stops compiling (`TS2339`); at runtime the property was + already absent from every resolved context except the two refused ones. + - The two refusals themselves are UNCHANGED: they still fire, still fail + closed (no `userId`, empty grants), and every transport still answers the + generic anonymous `401 UNAUTHENTICATED`. No status code, body or header + moves — a holder of someone else's key learns nothing, exactly as before. + - The refusal REASON is observable on exactly one surface, and it is not the + envelope: the server-side `[security] API key refused (reason) ...` `warn` + line at the decision point (#15256 / 2A), which names the key row id, + principal and organization for the operator. The pins that kept the two + reasons distinguishable through the field now read that line. + - `ApiKeyRefusalReason` and `ApiKeyAdmission` are unchanged — the reason + vocabulary still exists; it just no longer has a copy on the resolved + context. + + **Migration.** A consumer that read `ctx.authRefusal` deletes the read; there + is no replacement on the envelope, by design — disclosing the reason to a + caller (option B) was ruled out as a security-boundary question, and the + recorded fallback if a reader ever appears is an audit-side outlet (option C), + never the wire. Fail-closed handling keys on the absent `userId`, as every + in-repo transport already did. An operator who needs the reason reads the + server log line. + + +- 66dc6ab: Plugin startup elapsed time is now reported as `durationMs` — the unit-bearing name the spec contract for the same result declares. `startTime`, which never held a start time, is deprecated and still populated. + + `PluginStartupResult.startTime` (`packages/core/src/plugin-loader.ts`) has always been assigned `Date.now() - startTime`, an elapsed duration, on both the success and the failure path. The name therefore asserts the opposite of the value: a reader who correctly takes `startTime` for an instant and writes `Date.now() - result.startTime` gets an age near the epoch rather than a wait. That is the one failure mode a unit convention cannot rescue — an ambiguous name makes someone stop and check, this one lets them proceed confidently wrong. + + This is not a naming preference but a divergence between what is declared and what is enforced. `packages/spec/src/kernel/startup-orchestrator.zod.ts` declares `durationMs: z.number().min(0)` — "Time taken to start the plugin in milliseconds" — for the same measure on the same result, the outcome of starting one plugin; the bare `duration` spelling is retired there with a `retiredKey()` tombstone whose prescription is "Rename the key to `durationMs`", because a duration-shaped number carries its unit in its key name, never only in describe prose. The contract surface was already correct and `packages/core` had drifted away from it. The same computation already has an honest name twelve lines above the defect in the same file: `PluginLoadResult.loadTime` carries the identical `Date.now() - startTime` under a name that does not lie. + + Three sites move, and every one of them is additive — nothing is removed, so no consumer has to change anything on this release: + + - `PluginStartupResult` gains `durationMs?: number`. `startTime?: number` stays, still carrying the same value, marked `@deprecated` with a doc comment that states plainly it is elapsed milliseconds and not an instant. + - `ObjectKernel.getPluginStartupDurations()` is added; `getPluginMetrics()` becomes a `@deprecated` delegating alias returning the same map. + - The private `pluginStartTimes` map is renamed `pluginStartupDurations` (private; no reader outside `kernel.ts` in this repo or in the pinned `objectui` sibling). + + Migration, where you want it: read `result.durationMs` where you read `result.startTime`, and `kernel.getPluginStartupDurations()` where you called `kernel.getPluginMetrics()`. The values are identical, so the change can be made at leisure; both old spellings keep working until they are removed. + + ADR-0087 disposition: no migration-ledger entry, and none is required. Nothing is retired by this release — the old member and the old method both remain, populated and callable, which is ADR-0087's L1 outcome (the old shape keeps loading while the fleet moves) rather than a retirement. There is also nothing for `objectstack migrate meta` to rewrite: `packages/core/src/plugin-loader.ts#PluginStartupResult` is a runtime TypeScript interface with no Zod schema, no `packages/spec` declaration and no stored representation — the `PluginStartupResult` in `packages/spec/src/kernel/startup-orchestrator.zod.ts` is a separate, differently-shaped declaration that this change does not touch, and that schema's own `duration` tombstone entry (`packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts`) records that core's interface is not a reader of it. Core simply does not adopt the retired spelling. When the deprecated spellings are removed, that removal is the change that carries the ledger disposition. +- 2025b1f: `kernel.use()` now enforces the declared plugin contract. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at load instead of being stored and mounted. + + **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`). **This refuses input the runtime accepted before**, which is also why it is not a `patch`: `PluginSchema` had zero runtime callers, so every constraint it declared beyond `name`, `init` and semver was a declaration with nothing behind it. The sharpest reading of that gap, one input and two answers: `defineStack` accepted `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it — and only one of those answers was on the path a real plugin takes. Maintainer ruling of 2026-09-06 (ADR-0049 enforce-or-remove): the protocol is the baseline, the runtime aligns to it. + + **Exactly what is newly refused: all EIGHT declared keys, not three.** The schema declares nine optional keys; the loader excludes `version` (below), so enforcement reaches these eight, each refused with the offending key named in the message: + + - **`id`** — a non-string, or the empty string (`z.string().min(1)`). + - **`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. + - **`description`** — a non-string. + - **`author`** — a non-string. An object such as `{ name: 'x' }` is refused; the declared type is a plain string. + - **`homepage`** — a non-string, or a string that is not a URL. + + **`null` is refused on every one of the eight.** These keys are `.optional()`, which admits absence and `undefined` — never an explicit `null`. A plugin object that spells "no value" as `null` on any of the eight loaded before and is refused now. + + **What a refusal looks like.** It travels the loader's existing plugin-load error path — no new error channel — carrying the stable code `PLUGIN_CONTRACT_VIOLATION` at the head of the message and on the error's `code` property, and naming the plugin plus the first violated key: + + ``` + PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared + plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|… + ``` + + A wrong `type` is therefore diagnosable at boot rather than at route mount. The code is a **boot refusal**, not wire vocabulary: it is raised before any HTTP boundary exists, and no door answers with it. + + **What is STILL ACCEPTED — the door is not narrowed past those eight keys.** Measured on this tree, not assumed: + + - **Unknown keys still pass.** `PluginSchema` is a plain `z.object` with **no `.strict()`** — the strip posture — and the parse output is discarded, so a valid plugin carrying four keys the schema never declares loads, and is stored as the very object that was passed in with all of its keys intact. A plugin is refused for what it says about a **declared** key, never for saying something extra. + - **A version-less plugin still loads**, exactly as before. + - **A plugin declaring no `type` still loads and still stores no `type`**: `PluginSchema`'s `.default('standard')` is **not** written back. + - **A class-based plugin keeps its identity, its prototype and its prototype methods.** The plugin object is validated, never replaced: `safeParse` is read for `success` and its output discarded, because a copy destroys the prototype chain of class-based plugins — the reason `PluginLoader.toPluginMetadata` is a cast. That survival is pinned by test, not asserted in prose. + - **`version` is excluded from this enforcement entirely**, so `1.0.0-alpha.1` and `1.0.0+20230101` still load. The schema spells `version` as `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion` implements the full grammar and accepts them — deliberately, pinned by `plugin-loader.test.ts`. Enforcing the narrower spelling would retire that capability silently, so the loader's check remains authoritative for `version`. Reconciling the two spellings is spec work, tracked separately. + + **Blast radius, measured rather than assumed.** Every in-repo plugin object declares a `type` inside the closed set (`standard` ×62, `server` ×2, `driver` ×2, `objectql`, `app`), and the repo contains no producer of `slug` or `homepage` on a plugin object at all — so no in-repo plugin changes behaviour. Externally authored plugins are the population this reaches, and they are exactly the population that never met the compile-time `Plugin.type` union either. + + +- 51ae731: `LiteKernel.use()` now enforces the declared plugin contract — the same check, the same refusal, as `ObjectKernel.use()`. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at registration on **both** published kernels instead of on one. + + **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.** Until this release `LiteKernel.use()` wrote the object straight into its registry: `PluginSchema` was run by `PluginLoader.validatePluginContract` only, and `PluginLoader` is reached from `ObjectKernel.use()` alone. So the same plugin was accepted by one kernel and refused by the other — a `type: 'ui'` plugin with no `slug` was refused by `ObjectKernel` with `PLUGIN_CONTRACT_VIOLATION` and mounted a route on `LiteKernel`. `AGENTS.md` names `LiteKernel` for tests, serverless and edge, so the lenient kernel was the one authors develop against and the strict one was production: a plugin could be green in vitest and refused at boot. Maintainer ruling of 2026-09-08 (option A, under the precedent that the two kernels converge rather than diverge): `LiteKernel` validates too. + + **Exactly what `LiteKernel.use()` newly refuses** is exactly what `ObjectKernel.use()` has refused since the `kernel.use()` enforcement release: all EIGHT declared 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. + - **`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 eight**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. + + **What a refusal looks like — one refusal, from either kernel.** The check is now one function (`assertPluginContract`, package-internal) that both kernels call, so the code and the message are produced once: + + ``` + PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared + plugin contract at 'slug': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` plugin must declare `slug` — … + ``` + + `LiteKernel.use()` is synchronous and throws that error as-is, so the stable code is on the error's `code` property as well as at the head of the message. `ObjectKernel.use()` is unchanged: it still re-wraps a failed load as `Failed to load plugin: - `, its existing wrapper for every load failure. The text after that prefix is byte-for-byte the `LiteKernel` message for the same input, pinned by test. + + **What is STILL ACCEPTED on `LiteKernel` — the narrowing stops where `ObjectKernel`'s does.** Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the very object passed in). A version-less plugin still loads, and so do `1.0.0-alpha.1` and `1.0.0+20230101`: `version` is excluded from the schema check on both kernels, and `LiteKernel` — which has never judged `version` — still does not. A plugin declaring no `type` still loads and still stores no `type`. A class-based plugin keeps its identity, its prototype and its prototype methods. And `PluginLoader`'s structural checks (`name`, `init`, semver) stay the loader's own: the convergence is on the schema, not on the loader. + + **Ordering, stated because it is observable.** `LiteKernel.use()` checks its state first (a kernel past bootstrap still says `Cannot register plugins after bootstrap has started`, never `PLUGIN_CONTRACT_VIOLATION`), then the contract, then registers — so a refused plugin never reaches the registry and cannot supersede an earlier registration under its name. + + **Blast radius, measured before landing rather than assumed.** Across this repository's suites, 813 `LiteKernel.use()` calls were reachable; 807 were accepted by the schema unchanged and the six refusals came from three test-local fixture objects in two files — zero product or library code. Externally authored plugins registered on `LiteKernel` are the population this reaches, and they are exactly the plugins that would already have been refused by `ObjectKernel` at production boot. + + **Migration.** There is nothing to rename. A plugin refused on `LiteKernel` now was already refused on `ObjectKernel`; fix the named key: give `type` a value from the closed set (or drop it — an absent `type` reads as `standard`), declare `staticPath` and `slug` on a `type: 'ui'` plugin, spell `slug` in `[a-z0-9-_]`, make `homepage` a URL, and never `null` a declared key. The refusal names the plugin and the first violated key. + + +- cf9bda4: The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) + + `i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one. + + The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key. + + What changed: + + - **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with. + - **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged. + - **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared. + + A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix. + + `getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. +- 2a3decc: `PluginSchema` now REQUIRES `staticPath` and `slug` when `type` is `'ui'`, and core's `Plugin` interface inherits every `PluginSchema` key from `PluginDefinition` instead of restating two of them. + + **BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). `packages/spec/src/kernel/plugin.zod.ts` described `staticPath` and `slug` as *"Required for type=\"ui\""* while declaring both `.optional()`, with nothing behind the prose; since `kernel.use()` runs the schema on the boot path (#16049), that was a promise the runtime visibly did not keep. This is the spec half of #16049, split by director ruling (decision batch #58, 2026-09-06). + + **Exactly what is newly refused.** A plugin object with `type: 'ui'` that omits `staticPath`, omits `slug`, or spells either as `undefined`. Nothing else: every other declared type (`standard`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`), and a plugin declaring no `type` at all, still parses with neither key. A PRESENT value is judged exactly as before — `slug` keeps its `/^[a-z0-9-_]+$/` regex, `staticPath` stays any string, and the empty string is not refused by this change. + + **What a refusal looks like.** One zod issue per missing key, `path` naming the key, the new stable code `PLUGIN_UI_REQUIRED_KEY_MISSING` (exported from `@objectstack/spec/kernel`) at the head of the issue `message` and on the issue's `params.code`. At `kernel.use()` it rides the existing `PLUGIN_CONTRACT_VIOLATION` envelope unchanged, because the loader surfaces the first issue's `path` and `message` and reads nothing else: + + ``` + PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared + plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` + plugin must declare `staticPath` — the absolute path of the static assets it + serves. Declare it, or drop `type: 'ui'` if this plugin serves no assets. + ``` + + **The fix for an affected plugin** is the one the message names: declare both keys (`staticPath`: the absolute path of the assets it serves; `slug`: the URL segment it is mounted under), or drop `type: 'ui'` if the plugin serves no assets. There is no fallback to lean on: the Hono server's `slug || name.split('/').pop()` derivation is no longer reachable through the kernel, because the object is refused before it is stored. + + **`@objectstack/core` — `Plugin` derives its metadata keys.** `Plugin` now `extends PluginDefinition` (`z.input`), so `id`, `type`, `staticPath`, `slug`, `default`, `version`, `description`, `author` and `homepage` are ONE declaration shared with the schema the kernel enforces. Additive for every existing implementer: `type` and `version` keep the shapes they had (`type` is still `PluginType | undefined`, pinned type-equal in `packages/rest`; `version` still `string | undefined`), and the seven other keys are new optional members. A `ui` plugin can now carry `staticPath` / `slug` without widening its own type. Runtime-only members (`name`, `dependencies`, `optionalDependencies`, `requiresServices`, `providesServices`, `init`, `start`, `destroy`) stay declared on the interface. + + **Blast radius, measured.** No in-repo plugin object outside test fixtures declares `type: 'ui'` (searched `packages/`, `apps/`, `examples/` non-dist sources for a `type` key or class field holding the literal `'ui'`: three test files, nothing shipped), so no in-repo composition changes behaviour. Externally authored `ui` plugins that relied on the slug derivation, or declared no assets, are the population this reaches — and they are refused at boot, by name, with the key to add. + + +- cc00df2: feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919) + + + + **ADR-0087 disposition: registered**, as `plugin-security-scanner-retired` in + `MIGRATIONS_BY_MAJOR[18].semantic` — a **D3 semantic** entry, not a D2 conversion, + and so not the metadata migration the ruling excludes. The class has no spec schema + and never had one, so there is no authorable key to tombstone with `retiredKey()` + and no stored `sys_metadata` row a conversion could rewrite: a scanner was + constructed per call and every result lived in a per-instance Map discarded with the + object, so `applyConversionsToStoredItem` has no seam that would ever see one. An + entry is nevertheless owed rather than optional, because this changeset carries a + real consumer prescription — the enforced channel is tsc at the import site, and for + any consumer it does not reach, the ledger and the generated upgrade guide are the + only channel there is. Same disposition as `contracts.IDataDriver.findStream` and + `actor-user-roles-to-positions`. + + **BREAKING** — `PluginSecurityScanner` is removed from `@objectstack/core`, + together with its two companion types `ScanTarget` and `SecurityIssue`. Landing + as `minor` under the repo's launch-window convention for breaking changes. + **There is no replacement**, and none is planned. + + ⚠️ **The out-of-repo consumer population for these three exports is NOT + MEASURED.** This changeset can state only what was measured *inside* the + sources this repo can read: zero constructors in objectstack, zero in objectui + at the pinned sha, and zero in the deleted example itself. How many published + consumers of `@objectstack/core` import the class is unknown — no download, + dependent or source telemetry was consulted. Read the removal as breaking for + an unmeasured population, not as a removal proven to break nobody. + + ## Why it was removed rather than repaired + + The class was a shell that reported success. `scan()` composed five private + scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, + `scanConfiguration`) allocated an empty issue array, logged, and returned it + with no code in between — none could report a finding for any input. The fifth, + `scanDependencies`, ran a real loop but matched only against an in-memory + vulnerability database whose sole writer, the public `addVulnerability`, had + zero callers; `updateVulnerabilityDatabase()` logged twice and fetched nothing. + The database was therefore empty on every code path that has ever executed, so + no issue was ever produced, the score stayed 100, and the result was + `status: 'passed'` for every plugin the scanner was ever handed — a malicious + one included. + + A security control that cannot fail is worse than no security control, because + callers rely on it. Repair — writing a real vulnerability scanner — was refused + by name: it is a feature with a design surface and no demand, not a defect fix. + + ## FROM → TO + + ```ts + // FROM — compiles today, and passes every plugin it is given + import { PluginSecurityScanner } from '@objectstack/core'; + + const scanner = new PluginSecurityScanner(kernel.logger); + const result = await scanner.scan({ pluginId, version, dependencies }); + if (result.status === 'passed') { await kernel.use(plugin); } + + // TO — delete it. The condition above was always true. + await kernel.use(plugin); + ``` + + **The one-line fix:** delete the import and every call; no symbol replaces it. + If your code branched on `result.status`, take the `'passed'` branch — that is + the only branch it ever took. + + **If you were relying on it for actual security**, you were not getting any. + Audit dependencies with the tools built for it (`npm audit` / `pnpm audit`, + Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited + third-party plugin as untrusted code. What ObjectStack does still enforce is + artifact **integrity and signatures** (`verifyPluginArtifactIntegrity`, the + plugin signature verifier — "is this what the publisher signed?", never "is + this safe?"), explicit plugin **permissions**, and the sandbox **resource + limits**; all three are unchanged. + + Removed under ADR-0049 enforce-or-remove, per the maintainer ruling of + 2026-09-05 (director summon #14, decision batch #42). The retirement is pinned + as an export-list assertion on both barrels in + `packages/core/src/security/security-scanner-retirement.pin.test.ts`. + +### Patch Changes + +- 6f94458: fix(core): narrow the operation-private-keys pin's scanner to `.ts`, so it judges exactly the population turbo re-runs it for (#15090) + + `packages/core/src/security/operation-private-keys.pin.test.ts` filtered its + candidate set with `/\.tsx?$/` — `.ts` **and** `.tsx` — while this package's + declared radius in the cross-package declaration table is a `packages/**` + subtree glob ending in `.ts`. So the pin judged a population **strictly wider** + than the one either scoping layer of `check:cross-package-test-inputs` knows + about: Layer A never unions this package into the test shard when a `.tsx` file + changes, and Layer B never moves the `test` task's cache hash for one. A `.tsx` + file under `packages/` declaring its own `OPERATION_PRIVATE_KEY_PREFIX` or + `withoutOperationPrivateKeys` was therefore scanned by the pin and invisible to + CI's scoping — landing on `main` with every PR green and then reddening whichever + unrelated PR next touched a `.ts` file. That is the #7802 shape the declaration + table exists to close, one extension wide. + + Repaired by narrowing the **scanner**, not by widening the **glob** — and that + asymmetry is measured rather than assumed. On `b548e438d`, adding a `.tsx` glob + to this package's roster entry and re-deriving `check:cross-package-test-inputs`' + watch hints flips the dispatch-gates self-test case *"nor a .tsx test file inside + it"* from true to false, with the added glob itself as the covering hint. That + case is a live specimen for "a test class the hint route cannot reach", so the + red is real and re-pointing it is a decision in another lane, not a fixup. + + What the boundary costs, measured on the pin's own surface (tracked **plus** + untracked, ignored paths excluded) at `b548e438d`: **5408** `.ts` files scanned, + 8 of them mentioning a guarded symbol; **8** `.tsx` files excluded, **0** of them + mentioning either symbol. The loss is empty today — and that reading is no longer + transcribed and trusted. A new case re-measures it on every run: it asserts the + excluded `.tsx` population is non-empty (so the boundary is an exclusion and not + an empty tree describing itself), that the filter really drops those files, and + that none of them declares either symbol. Ablation, with the restore proven by + blob hash rather than by exit code: re-widening the scanner reddens it while the + offender assertion stays green — which is precisely the failure mode, since a + wider scanner reads as coverage CI never runs — and planting a `.tsx` + redeclaration reddens it with a message that says the choice is a second-gate + trade, not a one-line widening. + + The correspondence between scanner and glob is now stated at **both** ends: the + pin's header and the declaration table's entry for this package. No published + surface moves — the only source file edited is a test. +- 6e67b86: refactor(core): the authz context's time-zone probe is now the shared value-domain predicate, not a third copy of it + + `resolve-authz-context.ts` carried a module-private `isValidTimeZone` — the + `Intl.DateTimeFormat` probe, re-stated. It was the third copy of one + definition, alongside `@objectstack/spec/shared`'s `isValueDomainMember` and + `service-settings`' own re-statement. `coerceTimeZone` now calls + `isValueDomainMember('iana_time_zone', …)` and the copy is gone. + + **No behavioural change, measured rather than asserted.** The two predicates + were run over a shared 4,058-input corpus — the zones + `Intl.supportedValuesOf('timeZone')` omits (`UTC`, `Asia/Kolkata`, + `Europe/Kyiv`, `Asia/Ho_Chi_Minh`, `US/Eastern`, `GMT`), every member of that + enumeration plus its case- and space-padded variants, refusals, `Etc/` and + offset spellings, legacy aliases, and fuzz — with **zero disagreements**, and + the same zero at the `coerceTimeZone` level. The call site's own + pre-processing (trim, stringify a non-string, refuse blank) is unchanged. + + What this buys is drift resistance, not a fix: core's time-zone acceptance now + sits under the shared pins, so a future "modernisation" to + `Intl.supportedValuesOf('timeZone')` — which would silently narrow what the + authz context accepts, since that enumeration omits this platform's own + default `UTC` — turns a test red instead of shipping. +- e9fcd6b: feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478) + + + + **BREAKING** — fourteen published `kernel/` duration keys are renamed and + tombstoned. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescriptions are registered under protocol + major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, + 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`. + Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**, + and the whole-tree count falls **36 → 22**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `EventPersistence.retention` | `retentionDays` | days | + | `EventSourcingConfig.retention` | `retentionDays` | days | + | `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds | + | `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds | + | `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds | + | `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds | + | `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds | + | `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days | + | `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours | + | `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds | + | `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds | + | `StartupOptions.timeout` | `timeoutMs` | milliseconds | + | `PluginStartupResult.duration` | `durationMs` | milliseconds | + | `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds | + + **Every value is unchanged** — only key names move, and every default moves with + its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to + 365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the + authoring site (input type `never`) and fails the parse with the rename + prescription rather than a bare unrecognized-key error. + + ## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace + + **`responseTime` meant two different units on two kernel shapes.** On + `PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a + publisher promises to answer a vulnerability report); on + `PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So + `responseTime: 24` was a day on one shape and a fortieth of a second on the + other, with nothing at the authoring site to tell them apart. They land on + `responseTimeHours` and `responseTimeMs` respectively — do not let one + find-and-replace rewrite both. + + **`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision + was already costing prose: the protocol lifecycle page carried a standing + paragraph whose only job was telling the two apart. `metrics.uptime` becomes + `metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a + separate, unchanged surface and must not be renamed with it. + + A third split worth reading before you migrate: `estimatedDurationSeconds: 120` + is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent + measurements of the same package install carried two different units, and no + parse can catch a value moved between them — both bounds accept any + non-negative integer. + + ## Dispositions — five semantic entries, no D2 conversion + + Justified per key rather than defaulted, and this card's answer is uniform: + **none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs + over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`, + `upgrade` or plugin-security root — none of these twelve defs is a stack + collection member or a registered metadata kind stored as a `sys_metadata` row, + so the conversion chain has no seam that would see one. They are host + construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`, + `MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and + runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`, + `StartupOrchestrationResult`, `UpgradePlan`, + `PackageDependencyResolutionResult`). Each therefore carries a **semantic** + entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on + one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling + B prescribes for a key that is not authorable metadata. All fourteen are + registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + + ## Keys deliberately left alone + + `EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and + `MultiVersionSupport.rollout.percentage` is a proportion — neither is a + duration, so neither has a unit to carry and both keep their names. + `RuntimeConfig.resourceLimits.timeout` names its unit only in the JSDoc above + the key ("Execution timeout in milliseconds"), a channel + `check:duration-unit-keys` does not read: it reads `.describe()` and + `.meta({ description })`, and this key's describe ("Maximum execution time") + names none. The gate therefore lists it among the duration-shaped keys but + deliberately does not judge it — neither an offender nor an exemption — so it is + outside this rename; that JSDoc-channel gap is filed as #15939. A pin test + asserts the key still parses bare, so a later sweep cannot read the four + security renames as "every timeout on that file". + + ## Readers moved in the same PR, at the same magnitude + + `@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() - + startTime`), the kernel and contracts test suites, and the hand-written + `content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now + states the collision the rename removes. + + ⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local + `PluginStartupResult` interface — a different type, carrying `startTime` rather + than any duration key. It is not a reader of this schema, it is untouched by + this rename, and the divergence between the two shapes is tracked separately. +- c78c918: Documentation: the manifest surface no longer describes itself as an open object. + + `ManifestSchema` became a `strictObject` when the manifest surface was closed against unknown keys, but five prose sites still described the earlier posture. They shipped, so an author (or an AI writing metadata) reading the declarations was told the manifest tolerates undeclared keys — while the runtime rejects them by name and offers the declared spelling for a near miss. Prose that contradicts a tightened contract teaches exactly the wrong reflex, so each site now states the current refusal rather than merely dropping the old claim: + + - `AssembledPackageBodySchema`'s docblock no longer explains its lack of a `strictObject` spelling by calling `ManifestSchema` open. The posture is inherited: the schema is `ManifestSchema.extend(...)`, and `.extend()` carries the base's unknown-key handling, so an undeclared key on an assembled body is refused — measured, with the rename suggestion intact. + - The artifact-registration seam kept the half of its reasoning that still holds (the schema applies defaults, so a parsed clone would not be byte-identical) and retired the half that does not ("Zod strips undeclared keys") — the key is now refused at that parse rather than dropped from the clone. + - The `os compile` per-package rule pass explains why a body may be re-read as its own manifest: nothing parses that superset, and against `ManifestSchema` it would now be refused. + + No schema, behaviour or export changed; `check:api-surface` and the generated reference pages are unmoved. +- 4771bd9: The `Server is ready` line now reports the degraded boot it is standing on, instead of printing a green `✓` over it. + + `✓ Server is ready` and the kernel's `System started with degraded capabilities. Missing core services: …` were two statements about one boot, produced by two packages — the banner in `@objectstack/cli`, the conclusion in `@objectstack/core` — with **no data path between them**. So the ready signal did not depend on the thing that broke, and therefore could not report it. Measured twice within a day, from unrelated causes: an objectui CI boot where the auth plugin failed and not one `sys_*` table existed, and this repo's own weekly registry canary on the published `npx create-objectstack@latest` on-ramp, where the tick printed directly **above** four boot warnings. In the second case the ready line carried no weight in the job's verdict at all — it was present, green, wrong, and believed by nobody. + + - **The data path.** `ObjectKernel.validateSystemRequirements()` now publishes the list it had already computed — the same array behind its own warning — on the kernel's service registry, which is the seam boot facts already cross to reach the banner (`serve` reads `auth` and `seed-summary` off it the same way). No member and no type is added to `@objectstack/core`'s public surface, and nothing re-derives which services count as `core`: that judgement stays in `ServiceRequirementDef` alone. + - **The line.** On a degraded boot the banner prints `⚠ Server is ready — DEGRADED: missing core services: `, naming exactly what the kernel found missing. On a healthy boot the ready block is byte-for-byte unchanged, so an ordinary boot's output does not move. + - **Readiness is NOT made strict.** Nothing about what boots, binds, or exits changes. A machine deliberately running without auth still starts, still prints ready, and still exits 0 — the line just says what state it is ready in. +- d4f9b2a: A session whose active organization is no longer one the user belongs to now resolves with no active organization instead of that one's data. + + Under a wall-enforcing tenancy posture (`isolated` / `group`), `resolveAuthzContext` took a browser session's stored `activeOrganizationId` as the request tenant without ever comparing it to the user's current memberships — the framework's only such comparison was gated on an API-key principal. A session whose owner had been removed from an organization therefore kept reading that organization's rows and writing into it until the session expired on its own (7 days by default), including when the removal went through the product's own offboarding path. + + That claim is now vetted: if it is not in the caller's `accessible_org_ids`, it is dropped and the context resolves with no active organization at all, which the tenant wall already fails closed on (reads resolve to nothing; a tenant-scoped write is refused by ADR-0123 D2). The principal is **not** refused — a session is a person who may hold memberships elsewhere, so they stay signed in and can switch to an organization they are actually in. The API-key arm is unchanged: a key is its organization binding and is still refused outright. The wire is unchanged; the drop is reported to the operator as a single server-side `warn`. +- a727043: fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200 + + Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an + organization its owner is no longer a member of **read and wrote that + organization's rows** on the wiring the open core actually builds. Not a silent + empty set — a GET that returned the other organization's records, and a POST + that landed a row read back from the store carrying that organization's id and + the ex-member as its creator. An organization-less key on the same deployment + read `200` with an empty set, which is the silent failure the wall exists to + replace. + + The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the + effective tenancy posture from a per-request kernel, and on the single-kernel + wiring there is no per-request kernel — so the posture was `undefined` on every + request, and both posture-conditional API-key refusals are gated on it: + `organization_required` in `api-key.ts` and `organization_membership_ended` in + `resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was + active the whole time; it compares against the caller's active organization, + and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim + — the holder's own stored claim. Enforcing the wall is what let the ex-member + through, because the one fact that would expose the ended membership was not an + input to the layer that could act on it. + + The single-kernel branch now derives the posture from a provider `rest-api-plugin` + wires to the lone local kernel's `tenancy` service, in the same shape as the + auth-service provider beside it. A host that registers no `tenancy` service is + unchanged and still admits: there is no wall on such a deployment, so there is + nothing for an organization-less key to be walled out of. A `tenancy` service + that was registered and **failed to build** is an outage and answers `503`, not + an admission — a posture that could not be read is not a posture that is absent. + + Refusals are now also said out loud on the server side, at `warn`, where each + one is decided: the key's row id (never the credential or its hash), the + principal, the organization and the reason. **The wire is unchanged** — both + refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the + body, so a holder of someone else's key learns nothing a plain 401 does not + already tell them. The operator, who previously had a key that was neither + revoked nor expired and a 401 that said nothing, now has a line to find. + + Behaviour that does not move: a current member's key on the same route still + returns its rows and still writes; a request with no credential still answers + 401; and an unknown, revoked or expired key is not a refusal at all, so a key + scanner produces no log volume. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- f89812e: Five source comments in `@objectstack/cli` and `@objectstack/core` stop attributing unpack-time `manifest.integrity` re-verification to the cloud control plane and name the owner this repo has already ruled: the **future runtime loader** (ADR-0025 §3.5 steps 4–7). The enforce leg stays tracked on #11331. + + `packages/spec`'s `manifest.zod.ts` was corrected to that owner in an earlier change, and these five sites were left behind — so the repo stated both things at once. A comment that names the wrong owner costs nobody a build, but it teaches a reader (and a reading AI) to expect a verification that no component performs and that ADR-0025's own status line records as unimplemented. + + - `packages/cli/src/utils/osplugin.ts` — the `.osplugin` packaging docblock, and the `sriDigest` TSDoc. + - `packages/cli/src/commands/plugin/publish.ts` — the integrity-preflight comment. + - `packages/core/src/security/index.ts` — the `verifyIntegrity` export comment. + - `packages/core/src/security/plugin-artifact-integrity.ts` — the verifier's own module docblock, which had explained the module's byte-for-byte portability *by* the wrong owner. It now explains it by the leg itself: the module stays portable to whatever runs unpack-time re-verification. + + **What does NOT change.** The other half of every one of these comments — the digest map is computed by `os plugin build` and self-checked by the `os plugin publish` preflight — is true and is kept verbatim. No accept set, export, signature or runtime behaviour moves; the diff is comment prose only. + + **What moves for consumers, measured on the built output.** `@objectstack/cli` ships `dist/`, and the `sriDigest` TSDoc rides into `dist/utils/osplugin.d.ts`, so an editor's hover on `sriDigest` stops naming the control plane. `@objectstack/core`'s two sites do **not** reach its published bundle — a module docblock and a line comment above an `export {}` are both dropped from `dist/index.d.ts` — so nothing in that package's shipped bytes moves. It is declared here anyway because the pre-correction attribution is quoted in `packages/core/CHANGELOG.md`, a generated record that may not be hand-edited; a changeset naming the package is the only way the correction reaches that published record. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index bb40c5c65e..d248e9e3d7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.3.0", + "version": "17.4.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 e69af5c1aa..ea590320fe 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,102 @@ # create-objectstack +## 17.4.0 + +### Minor Changes + +- 4998efa: Scaffolded projects now ship a CI workflow, and a `lint` script for it to run. + The blank template carries `.github/workflows/ci.yml` — one job, on `push` and + `pull_request`: checkout, pnpm, Node 22, `pnpm install --frozen-lockfile`, then + `pnpm validate`, `pnpm lint` and `pnpm typecheck`. + + `lint` is new to the template (`objectstack lint`). It is not a second spelling + of `validate`: the two share the authoring-rule engine but only `lint` runs the + hook-body lowering check, which catches handlers that have silently stopped + lowering to metadata-only bodies — a change of deployment shape produced by a + refactor that looks like tidying. + + The scaffolder already created `.github/` at runtime for a single file + (`copilot-instructions.md`) while the template's gates shipped as npm scripts + nothing ever ran, so a fresh project started with no CI at all — and ObjectStack + metadata mistakes fail silently at runtime, which makes `objectstack validate` + the only place they surface early. That gate is now unskippable for a human and + for an AI agent authoring metadata in the project, instead of advisory. + + Existing projects are unaffected; copy the file from a fresh scaffold to adopt + it. +- 813d6c5: `npx create-objectstack` now declares the same TypeScript range as `os init` and + `os create`, and the value is generated rather than restated. + + Three scaffolders write a new project's `package.json`, and the range that + decides whether that project type-checks at all had split: `os init` and + `os create` emitted `typescript: ^5.3.0` from a shared emission policy, while + this package's bundled template carried `^6.0.0`. Two projects created the same + day got different TypeScript **majors** depending on which documented entry + point the reader followed. + + - **What changed for a scaffolded project.** Its declared `typescript` + devDependency floor moves from `^6.0.0` to `^5.3.0`. Both resolve to the same + installed compiler on a fresh install; what moves is the floor the project + **declares**, and a floor is a support promise. `^5.3.0` is the promise the + docs already make — "ObjectStack works with TypeScript 5.3+" on the getting + started page, "TypeScript 5.3.0 or later" in the deployment troubleshooting + page — and it is measured rather than assumed: TypeScript 5.3.3 type-checks + every shape these scaffolders emit with results identical to 6.0.3. The repo's + own `typescript@^6.0.3` devDependency is deliberately not this value; the same + doc sentence states both halves ("…but the project itself is built and tested + against TypeScript 6.x"). `engines.pnpm` was already in agreement and is now + held there by the same mechanism. + - **Why the value is generated.** This package cannot import from + `@objectstack/cli`: the dependency edge runs the other way, and the `npx` + package must not pull the CLI's package closure. So the values are stamped + into the bundled template at build time by + `scripts/sync-scaffold-emission-policy.mjs`, read out of the same + `SCAFFOLD_*` constants the other two scaffolders import, and + `pnpm check:scaffold-emission-policy` reddens the moment the inlined values + disagree with that source. Editing the two into agreement by hand would have + left them free to diverge again on the next move, silently, for the same + structural reason — which is how they diverged the first time. +- fd75728: Scaffolded projects now install the AI skills bundle for **one** agent runtime + instead of every runtime the skills CLI knows, so the bundle is committed once. + + **Route B of the two the card offered was taken**, and the choice was measured + rather than argued. Against `skills@1.5.23` and the 11-skill catalog, the old + `--all` (shorthand for `--skill '*' --agent '*' -y`) wrote the same bundle to + three destinations — `.agents/` (46 real files, 604,102 B), `agent/` (46 real + files, 602,682 B, identical bodies with re-serialised frontmatter) and + `.claude/` (11 symlinks into `.agents/`). The template's `.gitignore` excluded + none of it, so a new project's first `git add -A` staged 22 `SKILL.md` paths + plus 11 symlinks. That reached the initial commit of a real app before anyone + noticed. + + The scaffolder now runs + `npx skills add objectstack-ai/objectstack/skills --skill '*' --agent claude-code -y`, + which writes 46 real files to `.claude/skills/` and nothing else: 11 staged + `SKILL.md` paths, no symlinks, and a clone of that commit has readable skill + files on every platform. + + Route A (keep `--all`, exclude the duplicates in the template `_gitignore`) was + built and cloned, not reasoned about, and both of its shapes were rejected. + Ignoring `.agents/` and `agent/` while committing `.claude/` gives a fresh + cloner 11 dangling symlinks and zero readable `SKILL.md`. Ignoring only + `agent/` works on POSIX but commits 11 symlinks that a `core.symlinks=false` + clone — git-for-Windows' default — materialises as ordinary files whose whole + content is the link target. `--all --copy`, the other way to make `.claude/` + real, fans out to 56 destination directories totalling 33.8 MB. A denylist is + also the wrong shape regardless of which paths it names: this package does not + choose the destination set, the skills CLI does, and it moves with that + package's releases. + + The cost is the multi-runtime default, and it is paid in the open: the closing + summary now always prints an **AI Skills** block naming where the bundle landed + and the one-line command for any other runtime, one agent at a time. The + bundle is identical whichever agent is named. + + Existing projects are unaffected. To shrink one that already carries the + triplicate, delete `.agents/` and `agent/` and re-run the single-agent command + above; `skills-lock.json` records source and hash, not paths, so it does not + change. + ## 17.3.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 57c3951b1d..70706504fe 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.3.0", + "version": "17.4.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 b586243483..e1b5e6f1f2 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,278 @@ # @objectstack/driver-memory +## 17.4.0 + +### Minor Changes + +- 10d05bb: fix(driver-memory)!: an array comparand is refused instead of compared by reference, and a `Date` comparand is compared by time value + + + + `checkCondition` routed both `Date` and `Array` into `value == condition`, under a comment two lines above calling that "exact match" — and a comment seven lines below giving `==`'s reasons, all three of which are about primitives ("undefined/null mismatch", "string/number coercion", "convenient in weakly typed queries"). Between two objects `==` does no conversion at all: it compares REFERENCES. So the arm was neither exact nor a match, and the two adjacent comments described something the code could not do. + + **BREAKING** accept-set narrowing on a published driver, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`): a purely additive widening takes at least `minor`, and a narrowing is not the additive bucket, so `patch` is refused here — the public surface really does move. The two halves take DIFFERENT dispositions, and the difference is the contract's rather than this driver's: + + **An ARRAY comparand is refused.** `@objectstack/spec`'s comparand door names an array outside `$in`/`$nin`/`$between` as a position it deliberately does not rule, leaving it "to the layers that already answer it"; `ACCEPTED_FILTER_COMPARAND_TYPES` has no array member, and `driver-sql` refuses one with its own message. This driver now answers the same way, in the ADR-0112 envelope its every other filter refusal speaks (`code: 'INVALID_FILTER'`, `status: 400`), with a message naming the field, the received shape, the accepted set and the operators that DO take a list. The refusal is raised from `assertFilterConditionShape` — the one gate every face of this package runs before it evaluates anything — so the live query path, the reference matcher and the analytics face cannot disagree about it. + + Why refused rather than made to work, measured on one row `{ tags: ['a','b'] }` with the filter `{ tags: ['a','b'] }` before this change: the live query path returned the row (mingo deep-equals arrays) and the reference matcher returned none (`==` compared references). One filter, one package, two answers, neither reported — the same shape as this package's `{ field: {} }`, malformed-`$between` and non-boolean-`$null` refusals. Converging UP to deep equality was available and is not what landed: the contract declines to rule the cell, and every sibling that has answered it declines the semantics. + + **A `Date` comparand is EVALUATED, not refused** — the opposite disposition, because the contract does rule this one. `Date` is a member of `ACCEPTED_FILTER_COMPARAND_TYPES`, and `FILTER_COMPARAND_TYPE_CASES` requires that a Date comparand "must pass the door and execute everywhere". It is now compared by time value, arm for arm with `@objectstack/formula`'s `looseEq` — the sibling record-at-a-time matcher this face's conformance suites are held against — which also settles the case that actually reaches a stored row: a declared `datetime` is canonicalised to ISO TEXT on write (ADR-0053 D-B1), so a `Date` comparand meets a STRING, and `==` stringified the Date to `"Wed Jan 01 2026 …"`, which no ISO value equals. `$eq` / `$ne` take the same equality, so one predicate no longer answers two ways depending on which spelling the author used. An Invalid Date has no time value and so equals nothing, itself included. + + **What is deliberately NOT changed**, and is now pinned so a later edit cannot take it away by accident: a SCALAR comparand against a stored ARRAY. `{ tags: 'a' }` against a row storing `['a','b']` still answers false, and `{ tags: 'a,b' }` against that row still answers true, because `==` stringifies the stored array to `"a,b"`. That is a third bad direction of the same operator, but it is on the VALUE side, and the comparand door judges comparands. It is recorded, not repaired. The list operators (`$in` / `$nin` / `$between`), for which an array is the declared comparand, are untouched; so is the text family, whose comparand disposition this package's refusal gate had already recorded as deliberately fail-closed. +- e9fcd6b: feat(driver-memory)!: the file-persistence auto-save interval names its unit (#15680, ruling B on #14478) + + + + **BREAKING** — `InMemoryDriverOptions.persistence.autoSaveInterval` and + `FileSystemPersistenceAdapter`'s `autoSaveInterval` constructor option are both + renamed to **`autoSaveIntervalMs`**, following the `@objectstack/spec` rename of + the authored keys on both persistence arms. + + Same value, same milliseconds, same 2000 default, same `setInterval` cadence. The + option was always milliseconds — it is passed straight to `setInterval` — and the + spec's `min(100)` bound is what made the bare name dangerous rather than untidy: + 100 reads as a plausible number of seconds, so an author who guessed the unit + wrong cleared the bound, was refused nowhere, and saved a thousand times more + often than intended. + + Both persistence arms move together: `type: 'auto'` resolves to this same file + adapter and forwards the same field, so this package reads exactly one spelling + rather than two. + + ```diff + - new InMemoryDriver({ persistence: { type: 'file', autoSaveInterval: 5000 } }) + + new InMemoryDriver({ persistence: { type: 'file', autoSaveIntervalMs: 5000 } }) + ``` +- 2003259: fix(driver-memory): `find()`, `findOne()` and `create()` publish their declared types (#14435) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the same shape #13878 landed on `update()` / `upsert()` one door over, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). + + `IDataDriver` has always declared `Promise[]>`, `Promise | null>` and `Promise>` on these three doors. The emitted `.d.ts` published `Promise`, `Promise` and `Promise>`: the return types of `find` and `findOne` were INFERRED through the backing store's `any[]` rows (`private db: Record` to `getTable()`), and `create` carried an explicit annotation that itself spelled `Record`. They are now declared as the contract declares them. + + What this asks of a consumer holding a concrete `InMemoryDriver`: a caller that reads fields off a `findOne()` result narrows the `null` arm first — the arm the driver has always been able to answer with (`results[0] || null`) and that no caller was ever asked to handle; and a caller that leaned on `any` to read a member off a `find()` row or a `create()` result now types it, since the rows are `Record`. A consumer whose receiver is typed as `IDataDriver` sees no change at all — that declaration already said this. + + The parameters are deliberately untouched: `create(data: Record)` stays as it is, because narrowing an INPUT would be a second, unrelated break, and method parameters compare bivariantly against the contract's `Record`. No runtime behaviour changes; the store keeps its `any[]` rows, which the card measured to cascade if re-typed. + + +- 1cf7392: `driver-memory` analytics honours `AnalyticsQuery.timezone` when it resolves a string `dateRange`, instead of accepting the field and answering on UTC (#16042) + + `AnalyticsQuerySchema` declares `timezone` optional with no default precisely because an absent value is a meaningful state the engine resolves (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and `service-analytics` resolves that whole chain and writes the answer into `query.timezone` before a driver ever sees it. `parseDateRangeString()` never read it: a caller asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was accepted, warned about nothing, and answered on the UTC day. + + ⚠️ **This changes which rows a query answers for a caller already passing `timezone`.** Measured at `2026-09-06T20:00:00Z` with `timezone: 'Asia/Shanghai'`, `'today'`: + + | | window | rows selected, from the same 8 probes | + |:--|:--|:--| + | before | `[2026-09-06T00:00:00.000Z, 2026-09-07T00:00:00.000Z)` — the UTC day | `06T00:00:00.000Z`, `06T15:59:59.999Z`, `06T16:00:00.000Z`, `06T23:59:59.999Z` | + | after | `[2026-09-06T16:00:00.000Z, 2026-09-07T16:00:00.000Z)` — Shanghai's day | `06T16:00:00.000Z`, `06T23:59:59.999Z`, `07T04:00:00.000Z`, `07T15:59:59.999Z` | + + Four rows either way, and **two of the four are different rows**: `2026-09-06T00:00:00.000Z` and `2026-09-06T15:59:59.999Z` leave the answer (they are yesterday in Shanghai), `2026-09-07T04:00:00.000Z` and `2026-09-07T15:59:59.999Z` join it (they are today in Shanghai). Both row sets are asserted against the real `MemoryAnalyticsService.query()` entry, in the same test, so the before is measured rather than recalled. + + A query carrying **no** timezone is byte-identical to before: `zonedDateStartToUtcMs` returns plain UTC midnight for an unset, `'UTC'`, or unknown zone, so #15825's repair — the common case — is untouched, and both of its pins stay green. + + Two halves, each of which fails silently on its own and each of which is pinned: the reference timezone decides **which** calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern), and **where that day begins as an instant** (`zonedDateStartToUtcMs` — that zone's local midnight, which is what ADR-0053 already specifies for a `datetime` bound in `service-analytics`' drill ranges). Resolving only the first would anchor to the zone's calendar day and then cut it at UTC midnight — a window that is neither the UTC day nor the zone's. The end bound is a calendar step, never `+ 86_400_000`: on `America/New_York`, 2026-03-08 is 23 hours long. +- 5f4f1f6: `driver-memory` analytics resolves `dateRange` on the UTC calendar, so `'today'` and `last N ...` stop being offset by the process timezone (#15825) + + `MemoryAnalyticsService.query()` lowers a string `dateRange` through + `parseDateRangeString()`, and that function built its window on the **local** + calendar and rendered it as **UTC**. Two independent defects lived in it. + + **1. The window boundary was local midnight.** `new Date(y, m, d)` constructs + local midnight; `toISOString()` renders that instant in UTC. So in any process + not sitting at UTC, the `'today'` bucket was the **local** day expressed as a + UTC range. Measured 2026-09-05, with the clock at `2026-09-05T20:51Z`: + + | `TZ` | `'today'` window produced | the UTC day it should be | + |:---|:---|:---| + | `UTC` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | (agrees) | + | `Asia/Shanghai` | `2026-09-05T16:00Z` → `2026-09-06T16:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + | `America/Los_Angeles` | `2026-09-05T07:00Z` → `2026-09-06T07:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + | `Europe/Berlin` | `2026-09-04T22:00Z` → `2026-09-05T22:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + | `Asia/Kolkata` | `2026-09-05T18:30Z` → `2026-09-06T18:30Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + + That is wrong on **every day of the year**, with no DST transition needed. + + **2. The `last N ...` legs mixed two calendars.** `setDate(getDate() - n)` is + local arithmetic and `toISOString()` is a UTC rendering. `setDate` preserves + wall-clock time, so the instant moves `n × 24h` only while every local day in + the window is 24 hours long; across a DST transition it moves 23h or 25h and + the window start slips an hour. `setMonth` / `setFullYear` are the same class, + and can move it by a whole day: at `America/New_York` with the clock at + `2026-01-01T12:00Z`, `last 1 month` started at `2025-12-02T00:00Z` instead of + `2025-12-01T00:00Z`. + + **⛔ The two do not fix each other**, which is the easiest thing to get wrong + here: `setUTCDate` alone leaves the local-midnight boundary in place, and + `Date.UTC` alone leaves the arithmetic mixed. Both are repaired, each is pinned + by its own file, and each was ablated on its own to prove the separation. + + **Why UTC and not "any consistent calendar".** The rest of the platform + resolves a bare date to the UTC day — `@objectstack/core`'s `{today}` + filter-token macro builds its reference day as + `new Date(Date.UTC(year, month - 1, day))` and falls back to UTC parts when the + context carries no timezone, and `{TODAY()}` in flow templates resolves to the + UTC day (#14852 repaired the identical two-calendar shape there). Before this + change the same analytics question asked through the driver's `dateRange` and + through a flow token could select **different rows in one deployment**. UTC is + also the terminal fallback of the engine's own resolution chain + (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2). + + **What did not change.** The parser's vocabulary, its `[range, range]` + fallback, and the shape of the emitted `$match` are untouched — this is a + calendar repair, not a rewrite. `AnalyticsQuery.timezone` is still not consulted + by this path; making the range tokens timezone-**aware** is a separate and + larger question, which #14852 also declined. + + **Who sees a difference.** Any deployment whose process is not at UTC: `'today'` + and `last N ...` now select the UTC day they always claimed to, so charts built + on a string `dateRange` shift by the process offset — toward agreement with + `{today}` / `{TODAY()}` and with the same query run at `TZ=UTC`. Deployments + already running at UTC are unaffected; the two spellings are indistinguishable + there, which is exactly why CI never reddened on this. + +### Patch Changes + +- a646120: fix(driver-memory): the reference matcher's `$notContains` arm answers the predicate, not a type test, for a stored non-string value + + `match()` used to answer `{ n: { $notContains: '5' } }` with NO for `{ n: 5 }` — the arm read `typeof value !== 'string' || value.includes(target)`, so a number failed `$contains` (correct) AND its negation (wrong: for the very reason a number cannot contain the substring, it does not contain it). This package's own live mingo path admitted the row, so one filter answered two ways depending on which face was asked; on this face the failure mode was silently dropped rows. + + The arm now answers what `FILTER_TEXT_CASES`' new `score` rows declare on every face (maintainer ruling 2026-09-05 on the contract card): a stored value that is not a string never satisfies a positive text operator and always satisfies `$notContains`. The no-value cells keep their #13166 answer; nothing else in the matcher moved. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 1b7754973f..15ebe23139 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.3.0", + "version": "17.4.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 e36b1910a5..7f1b33752e 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,199 @@ # @objectstack/driver-mongodb +## 17.4.0 + +### Patch Changes + +- a06faeb: fix(driver-mongodb): put the test layer in front of tsc, so the package's own typecheck reports a PASS and not a NUMBER (#14917) + + `packages/drivers/driver-mongodb`'s `tsconfig.json` excluded `**/*.test.ts`, and + its `typecheck` script is `tsc --noEmit` against that very config. Measured at + `6ed4b811af` with the dependency closure built: that program admits **0** of the + package's 30 `src/**/*.test.ts` files while all **10** of its non-test `src/**` + files ARE there, so `pnpm --filter @objectstack/driver-mongodb typecheck` + exiting 0 was a true sentence carrying no information about any test file. + + The filing's headline — that a compile-time `Equals` / `IsAny` pin here is + "checked by nothing" — is **false**, and the correction on the card is right: a + second program does compile these files. `check-type-check-coverage.mjs`'s + `remeasureProject` drops only the test glob and compares the result against its + `TEST_DEBT` ledger. Confirmed here by ablation rather than argued: a + deliberately false `Equals` pin added to `mongodb-driver.test.ts` takes that + program from 10 errors to 11, above the ledger's recorded 10, which reddens it. + The pins were never phantoms. What was true is narrower, and is what this change + closes: the only program reading this layer was a **debt ratchet** — an + instrument that reports a number and fails when the number moves, not a gate + that reports a pass. + + Gives the package the #5286 sibling shape (`packages/rest`, `runtime`, + `objectql`, `core`): a `tsconfig.test.json` with module semantics only — + `esnext` / `bundler` / `lib: ES2022`, matching how vitest actually executes + these files — strictness inherited and untouched, named by the `typecheck` + script via `check:test-typecheck`. + + Measured: **10** errors under the ratchet's shape (matching its recorded number, + and its recorded composition `TS1309 x7, TS2550 x3`, class for class), and **0** + under the split. All 10 were config-tier in full — 7 `TS1309` (`await` at module + scope in a program NodeNext compiles as CJS, because this package has no `"type": + "module"`) and 3 `TS2550` (`Array.prototype.at` against a `lib` older than + es2022). Neither class says anything about a test, and nothing was exposed + behind them: there was no unresolved-import cascade here to collapse, so there + is no `+n` term. `noUnusedLocals` / `noUnusedParameters` are live for this + package (unlike `driver-turso`, which switches both off) and neither fires. + + The `TEST_DEBT` entry (10 errors) is **deleted**, not lowered — the graduation + this ratchet's invariant requires. No `test-typecheck-debt.json` is added: + residue is 0, so none is owed (#5286, maintainer-only to open). That leaves all + 30 files unledgered, so any error any one of them gains is red on arrival. + + `check:type-source-resolution` went red from onboarding the new program (the + documented onboarding-limb case, #11490): a registry entry is added rather than + `paths`, with its numbers stated in place — 123 tsc programs / 309 pairs before, + 124 / 310 after. The single new pair is `@objectstack/objectql`, a devDependency + that no non-test file in `src/` imports. + + No runtime code changes: not one test file and not one source file is edited, so + no shipped behaviour moves — the suite reports the same 552 passed / 147 skipped + across 30 files as before. The `patch` level reflects the published + `package.json` gaining `typecheck` / `check:test-typecheck` scripts and a `tsx` + devDependency. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index ed800a419c..0cff5f31fc 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.3.0", + "version": "17.4.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 6a2866044b..092f1fde4b 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,385 @@ # @objectstack/driver-sql +## 17.4.0 + +### Minor Changes + +- 54bb2f1: The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684) + + `$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`. + + On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518. + + What changed: + + - **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess. + - **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only. + - **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL. + + `$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted. +- 6d4d5d3: `SqlDriver.aggregate` answers `0` — not `null` — for a `sum` over a group whose aggregand is NULL in every row, matching the engine's in-memory aggregate tier and the identity `emptyGroupValueFor` already declares (#15546; maintainer ruling 2026-09-07, option A: a non-empty group whose aggregand is absent and an empty group are the SAME case for `sum`, and the SQL face is the one that moves). + + SQL `SUM` skips NULLs and answers NULL once it has skipped everything, so on every dialect this driver targets (measured on better-sqlite3, live PostgreSQL 16.13 and live MySQL 8.0.46) a grouped list view with a `sum` summary on a nullable number or currency column rendered a BLANK total for a group whose column was empty in every row — while the same view on a deployment whose query took the engine's in-memory path rendered `0`. Which path answered was decided by a driver capability bit the caller never sees. The fold is part of the driver's aggregate presentation (`foldEmptyAggregateAnswers`): the compiled statement is unchanged (no `COALESCE`), the answer is the JS number `0` on every dialect, and `avg`/`min`/`max` — which have no identity over nothing — still answer `null`. The identity is read from `emptyGroupValueFor` rather than restated, so the two faces cannot drift apart on it again. + + `@objectstack/driver-turso`: the REMOTE transport's `aggregate` carries the same fold (`RemoteTransport.foldEmptyAggregateAnswers`). `TursoDriver` picks the remote compiler or the local `SqlDriver` one from `url`, so without it the same driver would have answered the all-NULL `sum` as `0` locally and `null` remotely — one query, two answers, decided by a connection string, the seam the shared conformance table exists to close. Measured `null` on the enrolled remote face before the fold. + + `@objectstack/spec`: the aggregate-vocabulary conformance fixture gains a NULLABLE numeric column. `AggregationRow.amount` (`number | null`) is NULL in every row of the `east` group and in two of the four `west` rows, and `AGGREGATION_CASES` gains the three cases that pin the ruled answer on every enrolled face — `sum(amount)` grouped by region (`east` 0 / `west` 40), its `count(amount)` reachability control (`east` 0 / `west` 2, which is what proves the nulls were stored as nulls), and the ungrouped partial-null control (40). A harness that runs the table MUST declare `amount` as a nullable numeric column and seed its nulls AS nulls, exactly as it already must for `stage`; a `0` written in place of a null turns the cell green for the wrong reason. +- 45cfa1b: The record read doors present the builtin audit stamps (`created_at`, `updated_at`) and every declared `Field.datetime` column as the canonical instant text `YYYY-MM-DDTHH:MM:SS.sssZ` on EVERY dialect — Postgres and MySQL now included, exactly as SQLite always has (ADR-0053 addendum D-F1..D-F3, #13973). + + **Consumer-visible change, Postgres and MySQL only.** An in-process consumer reading such a column off a `find()` / `findOne()` row, off the row `create()` / `update()` / `upsert()` / `bulkCreate()` / `bulkUpdate()` return, or out of `aggregate()` (`min` / `max`, a raw temporal group key) or `distinct()`, receives a `string` where it received a JS `Date`. The wire is unchanged: `JSON.stringify` already serialised that `Date` as the same ISO text, so REST, MCP and SDK callers see nothing move. A consumer that called a `Date` method directly on the field (`.getTime()`, `.toISOString()`, `.getFullYear()`) now fails loudly with a `TypeError` instead of silently working on one dialect; the sweep behind this change found none in the repository's non-test sources. A consumer that compared, sorted, keyed or formatted the value as text — the shape eight production-driver defects had (#13382, #13993–#13999) — is now correct by construction on every dialect. + + - **Where the fold happens.** At the driver's own read boundary (`formatOutput` for rows, `presentReadValue` for the aggregate/distinct doors). The `pg` and `mysql2` client parsers are untouched: a `Date` is still what the client materialises, and a raw knex read still hands it back. Only the driver's read doors changed. + - **The builtin audit columns gain an `aggregate()` / `distinct()` arm on every dialect.** `max(updated_at)` and `distinct('created_at')` had no read presentation at all before — on SQLite they even missed ADR-0074's legacy-row repair — and now present exactly what `find()` presents. + - **An Invalid `Date` is the one shape the fold hands through unchanged** (#14078: a MySQL zero `DATETIME`; a Postgres year past 275760). It has no canonical text; the fold never throws on it, and the consumer-side guards #14078 landed absorb it as before. + + The per-site canonicalisations landed for #13993–#13999 and #14078 stay correct and become no-ops on driver rows; nothing is removed here. +- 7862fb7: `SqlDriver`'s object-definition parameters now DECLARE every key they read. `initObjects` accepts `lifecycle`, and the whole rotation chain — `rotateShards`, `ensureRotation`, `ensureShardTable` — accepts `tenancy` and `indexes`, spelled as a **fresh object literal** rather than only as a value bound to a variable first. + + The driver read those keys off caller objects all along, through `(obj as any).`, while the parameter's own inline type listed none of them. That is refused or accepted depending only on where the object is spelled: TypeScript's excess-property check fires on a fresh literal and not on one hoisted to a variable, so the same call compiles in one shape and is `TS2353` in the other. The loud outcome is the harmless one. The bad one is an author — or an AI reading the signature — concluding the key is not accepted and DROPPING it, at which point a declared UNIQUE is never synced and an ADR-0057 rotation policy is never armed, with nothing anywhere saying so. + + This is the third instance of one class, not a third coincidence: `tenancy` (#4311) and `indexes` (#16570) were the first two, each fixed one key at a time. The class is now held by a gate — `scripts/check-object-def-param-keys.mjs` — that reads parameter lists as an AST and covers the shape no in-file check could see: a subclass in another published package overriding one of these methods with a narrower literal. + + - **What widened.** `rotateShards(objectDef)` gains `tenancy?: any` and `indexes?: any[]`; `ensureRotation(…, obj, …)` gains the same two; `ensureShardTable(…, obj)` gains `indexes?: any[]`; `initObjects(objects)` gains `lifecycle?: any`. All three rotation links carry the keys, not just the leaf that reads them — declaring them only on the leaf would leave the two links above still narrowing the same value in flight, so a fresh literal handed to the public entry point would still have been refused. + - **What did NOT widen, deliberately.** The accept set still has a boundary: a misspelling (`indexs`, `tenancyy`, `lifecycl`) on a fresh literal is still `TS2353`, pinned by `@ts-expect-error` in `src/sql-driver-16711-object-def-param-keys.test.ts`. A "fix" that relaxed these parameters to `any`, or gave them an index signature, would have turned every other assertion green while deleting the entire layer of protection. + - **Four `as any` casts deleted**, including the residual one in `detectManagedDrift`, whose parameter had declared `indexes` all along. Behaviour is unchanged in every case — the keys were already being read. +- a646120: A text operator over a column whose declared type stores no text (`Field.number` and its numeric siblings, `Field.boolean`) now compiles to the contract's declared answer on every dialect, instead of a dialect accident. + + Before: `{ score: { $contains: '5' } }` over a numeric column compiled `col GLOB '*5*'` on SQLite and coerced the REAL in its storage class's spelling (`5` as `'5.0'`, so `$endsWith: '0'` matched every row), `col LIKE $1 ESCAPE $2` on Postgres and was refused at query time with SQLSTATE 42883 (`operator does not exist: real ~~ text` — a 500 for a filter the spec accepts), and `CAST(col AS BINARY) LIKE ?` on MySQL. + + Now (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05): the positive operators (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) compile to `1 = 0` and `$notContains` to `1 = 1` — the same row set as every JS face, decided from the declared type at compile time because the stored value is not visible until run time. Postgres: a 500 becomes a result. The gate reads the `numericFields` / `booleanFields` registries `initObjects` and `registerExternalObject` already fill; a table this driver was never told about keeps the `LIKE` / `GLOB` it always compiled, every comparand refusal still runs first, and the constants compose with the NULL-safe rules (`$notContains` admits a NULL row already) and the `$not` rewrite. Temporal columns are untouched: their stored value IS text on SQLite, so the contract declares nothing for them. + + `driver-sqlite-wasm` and `driver-turso`'s local transport inherit this compiler. +- 2200f8e: feat(driver-sql): `update()` publishes its honest type — the contract's `Record | null`, not `any` (#14438) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 used for the same door on `@objectstack/driver-memory`). `SqlDriver.update()` was written out with an explicit `Promise` while it has always answered a missing id with `null` (`formatOutput(...) || null` on the un-rotated path, `null` once every rotation shard has been probed). `IDataDriver.update()` declares `Promise | null>`, and an explicit `any` satisfies that structurally — so the emitted `.d.ts` read `Promise` and no caller holding a `SqlDriver`, or a `SqliteWasmDriver` (which inherits the door unchanged), was ever asked to narrow. It is now declared as the contract declares it, and the protected rotation-path producer `rotatedUpdateById()` carries the same type. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members now types them. No runtime behaviour changes. + + `@objectstack/driver-sqlite-wasm` re-declares no `update` member of its own (measured on its emitted `.d.ts`), so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides the door and carries its own entry. + + +- 33e939f: Schema drift now reports a SINGLE-VALUE JSON-class column that a stale `varchar`/`text` column is holding — the population the detector could never see. + + The driver decides a field's column type with `JSON_COLUMN_TYPES.has(type) || !!field.multiple`: `createColumn` gives a json column to every JSON-class TYPE, and `isJsonField` — the read-side deserializer — asks the same question. The drift detector asked only `field.multiple === true`. So a single-value `file` / `image` / `location` / `address` / `record` / `vector` / `json` field (and the option families) sitting on a `varchar` or `text` column was written as JSON by the writer and did not exist to the differ. Because the additive sync never migrates a column's type, that column stayed wrong permanently and nothing reported it. Measured on the previous tree, one call per type: all fifteen JSON-class types the spec declares returned zero findings over a `character varying(2048)` column on `postgres` and `mysql`, while the same column under a `multiple: true` field returned one in the same run. + + The detector now reads the writer's own predicate, so the two halves can no longer disagree about which declarations get a json column. `SQLite is unchanged and still reports nothing`: its read path parses a textual column regardless of what the column calls itself, re-measured on an in-memory cell as a byte-identical round-trip between the stale column and the driver's own. + + **The remedy is offered to the array-valued half only.** `os migrate multi-value-columns` repairs a stale column by wrapping each stored value in a one-element JSON array, which is the right repair for a field whose value is a list and the wrong one for a field whose value is a scalar or an object. Findings for array-valued fields (`multiple: true`, and the inherently-multi option types) keep their message character for character, so that command keeps recovering the dialect from it and keeps working exactly as before. Findings for single-value JSON-class fields carry a message of their own that names neither the command nor its statement, explains why the automated route is withheld, and describes the by-hand conversion; the command refuses such an entry (`remedy_not_recognized`) instead of running array SQL over scalar rows. + + Also fixed by the same predicate: a single-value JSON-class field declaring a `maxLength` over a wider `varchar` column used to be reported as `narrow_varchar` at category `destructive` — inviting `os migrate apply --allow-destructive` to rewrite the column to a narrower varchar, the opposite of the repair it needs. It is now reported once, as the base-type divergence. +- b72226f: `SqlDriver.initObjects()` and `SqlDriver.registerObjectMetadata()` now declare the `indexes` key they have always read. + + Both entry points took `Array<{ name; fields?; tenancy? }>`, with no `indexes` in the type. The key was read out of those very objects one call deep anyway, through an `as any`, in `registerManagedObjectMetadata` — and the map it fills, `managedObjectIndexes`, is what `syncDeclaredIndexes` renders every declared UNIQUE from. So the driver's whole index-sync path was driven by a key its own signature said did not exist, while the sibling `detectManagedDrift` on the same class had always declared `indexes?: any[]`: the two halves of one class disagreed about the shape of the same input. + + That is the shape #4311 already addressed for `tenancy`, one key over, and the comment it left above `initObjects` described `indexes` word for word. + + **Why nothing tripped over it.** TypeScript's excess-property check fires on a fresh object literal and not on one bound to a variable first, so the same object was accepted or rejected by nothing but where it was spelled — `await driver.initObjects([{ ...bare, indexes: [] }])` was rejected with TS2353, `const o = { ...bare, indexes: [] }; await driver.initObjects([o])` was accepted, and the index was synced either way. Every caller happened to bind first, so the package typechecked green for a reason unrelated to correctness. + + **Why this matters beyond a compile error.** The loud symptom was a rejected correct call. The quiet one is the reachable branch: an author — or an AI — reading the signature concludes `indexes` is not accepted and drops the key, and a declared UNIQUE is then never synced, with no error at authoring time and no error at boot. The schema says those rows cannot collide; they can. + + What changed, all inside `SqlDriver`: + + - `registerObjectMetadata(objects)`, `initObjects(objects)` and the shared `registerManagedObjectMetadata(obj)` helper each gained `indexes?: any[]`, spelled exactly as `detectManagedDrift` already spells it. + - Every `(obj as any)` cast reading `indexes` off those parameters is gone — the one at the `managedObjectIndexes.set` site and the two inside `initObjects`' own create/alter path. The cast was the evidence that the declaration and the read disagreed; leaving any of them would have fixed the signature while keeping the "the type does not admit me but I read it anyway" path alive. That path is now closed on this parameter. + + **What the accept set does, precisely — it moves in both directions.** For a **fresh object literal**, which is what an author writes and what the excess-property check judges, this is purely a widening: `{ ...bare, indexes: [...] }` was rejected and is now accepted. For a **variable-bound** argument, which bypasses that check and is judged by ordinary assignability, it is a narrowing: `indexes` spelled as a record, as a `readonly` tuple (`as const`), or as `null` compiled under the previous signatures and is now rejected with TS2322. Measured in both directions, all three shapes, on this package's own `tsc`. + + That narrowing is deliberate, and the three shapes did **not** all behave the same way before it — the difference is worth stating exactly, because only one of them ever worked: + + - A **record** and **`null`** never survived the `Array.isArray(obj.indexes)` guard the driver has always applied. That author got no index and no diagnostic — silently, at run time. Rejecting those two at compile time is precisely the failure this change exists to make impossible. + - A **`readonly` tuple (`as const`)** is a different case, and the only one with anything to lose. `as const` is type-only: at run time the value is a plain array, `Array.isArray` returns `true`, and the index **was** synced. That caller compiled and worked, and is now rejected at compile time. Nothing about its run-time behaviour changed — the rejection is entirely on the type surface. + + No migration is owed even so. No caller in this repository is affected, and the shape could never have reached `detectManagedDrift` on the same class either, which publishes the very same `any[]` spelling for the very same key — so a `readonly` caller was already unable to use half of this driver's declared-index surface. A caller in that position spells the array without `as const`, or widens it at the call site. + + The disposition on that corrected ground, recorded here because the ground itself moved: **no `BREAKING` banner and no ADR-0087 disposition**, resting on grounds (i) and (iii) alone — zero affected callers, and the `any[]` spelling already published on `detectManagedDrift` for the same key on the same class. The ground that every newly rejected shape had already been discarded at run time is **not** among them: it is false for the `readonly` tuple, and nothing here leans on it. + + `@objectstack/driver-sqlite-wasm` is named because `SqliteWasmDriver extends SqlDriver` and overrides neither method, so both widened signatures land in its own published `.d.ts` and its consumers see the identical change. The two packages are in the same fixed version group, so this is a CHANGELOG effect rather than a version one. + + The `IDataDriver` contract itself did not move: `registerObjectMetadata?(schemas: unknown[])` in `@objectstack/spec` already accepted `unknown[]`, and `SqlDriver` narrowed it on its own. What grew is `SqlDriver`'s own published accept set. +- 78bc4ad: fix(driver-sql)!: `findWithWindowFunctions()` presents its rows like every other read door — a declared boolean answers `true`, not `1` (#16609) + + + + **BREAKING** on the rows returned by `SqlDriver#findWithWindowFunctions()`. + Shipped as `minor` under the repo's launch-window convention for breaking + changes, matching #3849 — the `aggregate()` / `distinct()` half of this same + gap, which graded `minor` for the same boolean-shape move. + + **What was wrong.** `findWithWindowFunctions()` was the one record read door + that returned `await builder` with no presentation at all: no `formatOutput` + (which every `find()` / `findOne()` row gets) and no `presentReadValue` (which + `aggregate()` / `distinct()` got under #3797 / #3849). So it handed back + STORAGE forms where every other door hands back the declared type's + presentation. Measured on SQLite against the built package, one row through the + two doors: + + ``` + find(): { ok: true, closed_at: '2026-01-10T09:00:00.123Z', meta: { k: 1 } } + findWithWindowFunctions(): { ok: 1, closed_at: '2026-01-10T09:00:00.123Z', meta: '{"k":1}', rn: 1 } + ``` + + A declared `Field.boolean` answered `1` where `find()` answered `true`; a + declared `Field.object` answered the stored JSON TEXT where `find()` answered + the parsed object. On Postgres and MySQL the same door handed out the client + library's `Date` for `Field.datetime` and the audit stamps — the one shape every + other read door no longer produces — so on the live dialects the divergence was + between this door and the driver's own declared read contract, not merely + between dialects. + + **What moves, per column class and per dialect (storage form → presented form).** Routing this door + through `formatOutput` moves SEVEN classes, not only the boolean and JSON ones + the defect was reported as. `unchanged` means the storage form on that dialect + already WAS the presented form, so the row is byte-identical there — it is + recorded rather than omitted, because the same code path now runs for it. + + | class | sqlite | postgres | mysql | + |---|---|---|---| + | `Field.boolean` | `1` / `0` → `true` / `false` | unchanged (native `boolean`) | `1` / `0` → `true` / `false` | + | `Field.object` (JSON) | `'{"k":1}'` TEXT → `{ k: 1 }` | unchanged (native `jsonb`) | unchanged (mysql2 parses JSON) | + | numeric fields | `'4'` → `4` (a numeric STRING off a legacy TEXT-affinity column) | unchanged | unchanged | + | `Field.datetime` + `created_at` / `updated_at` | unchanged — already the canonical text since #3912; a legacy zone-naive row is repaired to it | `Date` → `'2026-01-10T09:00:00.123Z'` | `Date` → `'2026-01-10T09:00:00.123Z'` | + | `Field.date` | unchanged (`toDateOnly` on text is identity) | unchanged (the driver pins the `date` OID parser to text) | `Date` → `'2026-01-10'` | + | `Field.time` | unchanged | `'09:30:00.5'` → `'09:30:00.500'` | → canonical `HH:MM:SS[.fff]` | + | `external.columnMap` | the row KEY renames: remote column key → local field key | same | same | + + The instant TO is the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` TEXT **on every + dialect**, never a JS `Date` — that is ADR-0053 D-F1 as #16619 landed it, and + this door now runs the same presenter, so it answers the same shape the other + read doors do. + + `external.columnMap` is the one class nobody named on the card, and it is a KEY + move rather than a value move: on an external object with a `columnMap`, the row + this door returns is now keyed by the LOCAL field names, as `find()` has always + keyed it, instead of by the remote physical column names. + + **What to do.** Code that compensated for the storage forms stops being + correct and should simply drop the compensation: + + - `if (row.ok === 1)` → `if (row.ok)`; the value is a real boolean now. + - `JSON.parse(row.meta)` → `row.meta`; it is already the parsed value, and + parsing an object throws. + - `Number(row.amount)` → `row.amount`; a numeric column is a `number`. + - A `Field.datetime` / `Field.date` / `Field.time` / `created_at` / `updated_at` + read through this door is now the same presented value `find()` gives, so a + branch that re-normalised it — or that called `Date` methods on it — can go. + - A reader of an external object with a `columnMap` indexes the row by the LOCAL + field key, not the remote column key. + + **The alias columns are carved out**, which is the design question this door + raised. A window alias is a computed value, not a declared field, so no declared + field's presentation rule touches it. When an alias is spelled the same as a + declared field, SQL had already decided which value wins the key — `select *` + plus ` as ok` projects two columns named `ok` and the row keeps the + LAST, so the computed value wins and the declared column's value is not in the + row at all. That is unchanged. What is now ruled is that the winning value stays + RAW: presenting a `row_number` of `1` and `2` as the declared boolean would fold + both to `true` and destroy the value the caller asked for. This is the same + ruling `aggregate()` already makes for a date-bucketed column aliased as its own + field name. + +### Patch Changes + +- f2b5e46: fix(spec): withdraw the `field-required-notnull-explicit` ADR-0087 conversion — `required: true` no longer stamps `storage.notNull: true` on anybody's fields (#16693) + + ADR-0113 split the pre-17 `required` tri-binding on purpose: `required` is the **write-time contract** and is NOT a column constraint, and `storage.notNull` alone binds the physical column (`sql-driver.ts#createColumn` has keyed off it alone since that ADR's P0). The `field-required-notnull-explicit` conversion asserted exactly the implication the ADR abolished — it added `storage: { notNull: true }` to every field it found `required: true` on — so it is removed from the conversion registry and from protocol 17's ADR-0087 ledger entry. + + **Who this was reaching, and why it was not confined to old artifacts.** The entry carried `retiredFromLoadPath: true` and a docblock stating that "only `os migrate meta --from <16 or lower>` may apply it". That was not true of this tree. The artifact-ingestion door replays the whole chain with `includeRetired: true` (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) and keys the replay off the artifact's declared `engines.protocol` **floor**, not its age — so any artifact declaring `^17.0.0`, which is the range `create-objectstack` stamps, was converted at boot. Measured on this tree at that seam: an artifact declaring `^17.0.0` on a 17.3.0 runtime came back from the door with `storage.notNull: true` written onto a field its author wrote as nullable-and-write-gated, and the boot logged `converted N site(s) forward` with a remedy sentence telling the author to write the same tightening into the source. On a populated database that instruction is a `tighten_not_null` / `severity: error` / `category: destructive` migration — prescribed as the remedy for a deprecation notice. + + **What moves for consumers.** + + - `applyConversions(stack, { includeRetired: true })` — the artifact-ingestion door and `os migrate meta` — no longer emits or applies this rewrite. The default load posture (`includeRetired: false`) is unchanged: the conversion was already skipped there. + - `os migrate meta --from 16` no longer lists it, and a `required: true` field crosses 16 → 17 carrying its write contract and nothing else. + - Boot no longer warns about it, so an artifact whose only conversion was this one now boots with that warning gone. + - Nothing is authored differently and nothing is refused that was accepted before. `required` and `storage.notNull` both remain authorable and both keep their ADR-0113 meanings. A column is NOT NULL because its author wrote `storage: { notNull: true }`, and for no other reason. + + **No migration is owed to anyone** (maintainer ruling, 2026-09-08, decision batch #85, option A). Genuinely pre-ADR-0113 artifacts are not measured to exist, existing columns are left exactly as they are, and an app that wants NOT NULL columns declares `storage.notNull` deliberately — which is what the app that reported this had already done. + + The protocol-17 ledger entry and the generated upgrade guide now say this in the other direction too, and the falsified sentence in `sql-driver.ts` — "sources authored before protocol 17 carry `storage.notNull` explicitly via the `field-required-notnull-explicit` conversion, so their columns come out exactly as they always did" — is corrected where it stood. + + Two sentences in `@objectstack/driver-sql` that this withdrawal falsifies are corrected with it, and no drift behaviour changes. The `relax_not_null` finding — raised when a column is NOT NULL and the metadata declares no `storage` constraint — used to prescribe "(pre-protocol-17 sources: `os migrate meta` stamps it for every previously-required field)"; it now says the constraint has to be declared by its author, because nothing supplies it any more. The comment beside it, which closed with "`os migrate meta` ratifies it whenever the source is next migrated", says so too. The deliberate SILENCE for a `required: true` field whose column is already NOT NULL is unchanged — this corrects the sentences, never the finding. +- d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. + + Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. + + No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. + + Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. +- 001a83b: `SqlDriver.execute()` — the raw-SQL path the analytics compilers run on — now declares a backend refusal the way the typed read exits (`find` / `count` / `aggregate`) have since #8931: `code: DATABASE_ERROR`, `status: 500`, a composed message that carries none of the dialect's words, and the dialect error whole under a non-enumerable `cause`. `TursoDriver` in remote mode — the one transport that hands the engine's text back with no statement in front of it — declares through the same terminal, so both transports leave the driver with one envelope. **Graded `patch`** on AGENTS.md's changeset rule ("A bug fix in a released package takes a `patch` changeset"; breaking is what removes or renames something an author can write — a spec key, an export, a config field — and nothing here does: `execute()` stays `Promise` of `any`, and `code` / `status` were untyped before) and on the precedent of the identical change on the typed read exits, #8931 via PR #9273, which shipped `@objectstack/driver-sql: patch`. + + **The defect this closes (#16019, folding in the envelope half of #16028).** `no such function: translate` — what SQLite answers when a compiler emits a function the dialect lacks — left `execute()` as knex's own error: `code: 'SQLITE_ERROR'`, no `status`, message ` - no such function: translate`. Undeclared, it fell to the HTTP doors' phrasing heuristic (`looksLikeInternalErrorLeak`), which recognises `no such column:` and not `no such function:`, so whether the caller saw the engine's text depended on which limb the message happened to match: through knex it was withheld by accident (the statement prefix starts with `select`), through the Turso remote transport it was withheld by a different accident (`SQLITE_ERROR:` in front), and a bare `Error('no such function: translate')` reached the body verbatim. Maintainer ruling 2026-09-06 (decision batch #57, option 3): the substring list is not grown; the driver declares its own fault and the doors classify on the declaration. The heuristic stays as the last-resort fallback for an error that arrives with no declaration. + + **What moves on the wire — three doors, each because a declared fault is relayed where an undeclared one was re-labelled.** + + - `POST /api/v1/analytics/dataset/query`: a driver fault on the raw path answers `500 {"code":"DATABASE_ERROR","error":"Internal server error"}` — the declared-fault relay, the same answer the `/data` door and `/analytics/query` already give a declared 5xx — where it was `500 {"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` when the phrasing heuristic happened to fire and the raw engine text when it did not. Status unchanged; the code is now the producer's, exactly as the typed read exits' faults have answered at this door since PR #9273. + - The same door, a dataset over a backing table that is NOT present, on the native-SQL strategy (the strategy every deployment whose data engine exposes `execute()` runs): `500 DATABASE_ERROR` where it was `200 {"rows":[],"fields":[],"totals":[]}` plus a `warn`. `queryDataset`'s missing-source degrade sits behind its declared-envelope re-throw (#5717 defence B: a declared envelope is re-thrown untouched, whatever it says), so a driver-raised missing table no longer reaches it — the answer the ObjectQL-aggregate strategy has given since #9273, now on both strategies. The degrade still applies to an undeclared producer (an embedder's own `executeRawSql`, the framework's not-registered signals). + - `POST /api/v1/packages/publish` and `DELETE /api/v1/packages/:id`: a raw-exec driver fault under `sys_packages` answers `500 {"code":"DATABASE_ERROR"}` with the composed sentence as its message — `PackageService.publish` / `delete` re-throw a throw that declares an HTTP answer (`declaresHttpAnswer`, whose docblock already says a declared 5xx is re-thrown too) and the door's `sendThrownError` relays it — where it was `500 PACKAGE_PUBLISH_FAILED` / `500 PACKAGE_DELETE_FAILED` from the swallowing branch. Same status band, no dialect text on the wire either way; the ledgered `code` on those two doors moves. + + **What a consumer of `execute()` sees.** `error.message` is the composed sentence; `error.code` is `DATABASE_ERROR` where it was the backend's errno; `error.status` is `500` where it was absent. The backend's error object — its errno, its diagnostic, and on the dialects that inline them the bound literals — is on `error.cause` (non-enumerable, so it does not serialise), and the driver writes it, with the statement, to its warn log before composing. Cause-following predicates are unaffected: `isMissingTableError(err, readObject)` still classifies a missing table raised on this path. An error that already declares a `status` is passed through untouched, never double-wrapped. A caller that read the dialect's text off `error.message` (a migration preflight recording it as its `detail`, say) now reads the composed sentence there and finds the dialect text on `cause` and in the log; the in-repo sites of that class are tracked as #16657 (read `cause` there). +- 1ca95df: MySQL: a plain unique index over existing duplicate rows no longer takes the boot down when the index has to be carried by a hash shadow. + + `syncDeclaredIndexes` handles a declared unique that the database refuses in one `catch`, and that `catch` has two arms: the DIRECT one, and the hash-shadow one MySQL takes when a key part is wider than the 768-char utf8mb4 ceiling. #14902 taught the direct arm that a uniqueness violation over existing rows is a durability degradation rather than a fatal — log it, name the conflicting rows and the remedy, let the boot continue. The shadow arm kept the older guard, which also required a NULL-safe organization key part, so a PLAIN unique (`tenancy: { enabled: false }`, or an explicit `unique: 'global'`) matched neither branch. + + Measured on live MySQL 8.0.46: the boot died carrying `ER_BLOB_KEY_WITHOUT_LENGTH` — a refusal about an unkeyable TEXT column, telling the operator to declare a `maxLength` the field already declared — while the real cause was two duplicate rows it never mentioned. It named no rows and no remedy. + + The two arms now agree, and they say different things because they mean different things. The NULL-safe arm keeps its wording (existing rows violate the NULL-safe key, duplicating what the previous void constraint admitted); the plain arm gets the direct arm's reviewed sentence, because neither of those clauses is true of a plain unique — nothing admitted the rows, and there is no NULL-safe key. Widening the guard alone would have shipped a factually false durability log, which is worse than the throw it replaces. + + `os migrate plan` already reported this operation as `destructive` with the row report and is unchanged. +- 8f2ecb3: The hash-shadow NULL-safe durability log now counts its overflow duplicate groups in the same words as the other three reports that render the same rows (#16289) + + `formatDuplicateGroups` is module-local in `sql-driver.ts` for one stated reason, quoted from its own docblock: the sites that report a blocked unique "must name the SAME rows in the SAME shape, and a second hand-rolled `.slice(0, 5).join('; ')` is exactly how the plain and the NULL-safe path drifted apart in the first place". Four sites render duplicate groups — the drift entry, the direct arm's plain-unique log, the hash-shadow arm's plain-unique log, and the hash-shadow arm's NULL-safe branch — and the fourth still hand-rolled that exact shape. + + So the drift the helper exists to prevent had already recurred, in the overflow tail: the helper writes `; …and N more group(s)`, the hand-rolled copy wrote `; …and N more`. Two durability logs about the same failure class, emitted from the same `catch`, disagreed on how they say "there are more". + + What an operator sees: when a hash-shadow NULL-safe unique index is blocked by more than five conflicting groups in one table, the boot-time durability line now ends `; …and N more group(s).` instead of `; …and N more.`. The surrounding ` Conflicting group(s): ….` framing, the five groups rendered in full, their `(key) × N rows` spelling and their order are unchanged, and so is every other line. No behaviour, no data effect, no API movement — the five-then-count rendering is now owned in one place for all four sites. +- 61821e5: A plain unique index over existing duplicate rows no longer kills the boot with the database's raw error, and `os migrate plan` no longer calls that op `safe`. + + Declaring a column unique over a table that already holds duplicates had two very different outcomes depending on one branch in the SQL driver, and only one of them was survivable. + + - **An organization-scoped unique** (the `unique: 'organization'` default, materialised as the NULL-safe `COALESCE(organization_id, '__global__')` composite) kept the boot up: the driver logged at `error` naming the index, the constraint that is not enforced and the remedy, and the ADR-0120 D4 duplicate pre-flight reported the blocked `create_index` as `category: 'destructive'` / `severity: 'error'` with the conflicting key groups and their row counts. + - **A plain unique** — no organization key part at all, reached by an object with `tenancy: { enabled: false }` or by any explicit `unique: 'global'` — took the process down: `initObjects` threw the database's own error, which names the index and the column and no rows and no remedy, nothing reached the durability channel, and `detectManagedDrift` (what `os migrate plan` reports) classified the very same op `category: 'safe'`, `severity: 'warning'`, so `os migrate apply` and dev `autoMigrate: 'safe'` walked straight into the raw failure. + + The plain path now reaches the same posture as the scoped one: + + - **The boot survives and says what is not enforced.** `syncDeclaredIndexes` absorbs a uniqueness violation on a plain unique index the way it already absorbed one on the NULL-safe composite: the failure is logged on the durability channel (`error`) naming the index, the conflicting key groups with their row counts, the constraint that is NOT enforced, and `os migrate plan` as the way out. A non-unique index and any failure that is not a uniqueness violation still surface as before. + - **The duplicate pre-flight covers it.** The ADR-0120 D4 probe no longer skips ops whose NULL-safe column set is empty, so a plain unique `create_index` over dirty data is reported `destructive` / `error` with the same row report instead of `safe`. Nothing new probes it: the existing probe already groups by the bare columns when there is no NULL-safe key part, so both key shapes share one pre-flight rather than a second copy that can drift from the first. + + Consumers of the classification see the op move from the "Safe" group to "Destructive (requires --allow-destructive)" in `os migrate plan` and `os diff`; `os migrate apply` defers it instead of attempting it; the artifact boot gate refuses with a named destructive-drift refusal instead of crashing; and dev `autoMigrate: 'safe'` leaves it alone. Clean data is unaffected — the probe finds nothing and the index is created exactly as before. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index b6c89c50ff..55cd30d82b 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.3.0", + "version": "17.4.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 81cbce514d..3159f7688c 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,193 @@ # @objectstack/driver-sqlite-wasm +## 17.4.0 + +### Minor Changes + +- 7862fb7: `SqliteWasmDriver.initObjects` accepts `tenancy`, `indexes` and `lifecycle` in a **fresh object literal**, inherited from the widened `SqlDriver` — and that inheritance is now asserted rather than assumed. + + This package overrides neither `initObjects` nor `registerObjectMetadata`, so its published `.d.ts` re-declares none of them and the door it exposes is `SqlDriver`'s, imported from `@objectstack/driver-sql`. Measured on the built declarations: zero re-declarations of `initObjects`, `registerObjectMetadata`, `rotateShards`, `ensureShardTable` or `registerManagedObjectMetadata`. That is the opposite direction of the defect the sibling packages carried — `TursoDriver` overrode `initObjects` with a narrower literal and shadowed a base-class fix for five weeks — and it is recorded here because a consumer reading only this package's changelog would otherwise never learn its accept set moved. + + `src/sqlite-wasm-16711-inherited-object-def-keys.test.ts` pins the inheritance inside this package's own tsc program: the inherited parameter is not `any`, each key is present on the element type, a fresh literal carrying them compiles and is read at run time, and a misspelling is still `TS2353`. It goes red both ways — if the base narrows again, and if a future override here re-declares the door more narrowly. +- b72226f: `SqlDriver.initObjects()` and `SqlDriver.registerObjectMetadata()` now declare the `indexes` key they have always read. + + Both entry points took `Array<{ name; fields?; tenancy? }>`, with no `indexes` in the type. The key was read out of those very objects one call deep anyway, through an `as any`, in `registerManagedObjectMetadata` — and the map it fills, `managedObjectIndexes`, is what `syncDeclaredIndexes` renders every declared UNIQUE from. So the driver's whole index-sync path was driven by a key its own signature said did not exist, while the sibling `detectManagedDrift` on the same class had always declared `indexes?: any[]`: the two halves of one class disagreed about the shape of the same input. + + That is the shape #4311 already addressed for `tenancy`, one key over, and the comment it left above `initObjects` described `indexes` word for word. + + **Why nothing tripped over it.** TypeScript's excess-property check fires on a fresh object literal and not on one bound to a variable first, so the same object was accepted or rejected by nothing but where it was spelled — `await driver.initObjects([{ ...bare, indexes: [] }])` was rejected with TS2353, `const o = { ...bare, indexes: [] }; await driver.initObjects([o])` was accepted, and the index was synced either way. Every caller happened to bind first, so the package typechecked green for a reason unrelated to correctness. + + **Why this matters beyond a compile error.** The loud symptom was a rejected correct call. The quiet one is the reachable branch: an author — or an AI — reading the signature concludes `indexes` is not accepted and drops the key, and a declared UNIQUE is then never synced, with no error at authoring time and no error at boot. The schema says those rows cannot collide; they can. + + What changed, all inside `SqlDriver`: + + - `registerObjectMetadata(objects)`, `initObjects(objects)` and the shared `registerManagedObjectMetadata(obj)` helper each gained `indexes?: any[]`, spelled exactly as `detectManagedDrift` already spells it. + - Every `(obj as any)` cast reading `indexes` off those parameters is gone — the one at the `managedObjectIndexes.set` site and the two inside `initObjects`' own create/alter path. The cast was the evidence that the declaration and the read disagreed; leaving any of them would have fixed the signature while keeping the "the type does not admit me but I read it anyway" path alive. That path is now closed on this parameter. + + **What the accept set does, precisely — it moves in both directions.** For a **fresh object literal**, which is what an author writes and what the excess-property check judges, this is purely a widening: `{ ...bare, indexes: [...] }` was rejected and is now accepted. For a **variable-bound** argument, which bypasses that check and is judged by ordinary assignability, it is a narrowing: `indexes` spelled as a record, as a `readonly` tuple (`as const`), or as `null` compiled under the previous signatures and is now rejected with TS2322. Measured in both directions, all three shapes, on this package's own `tsc`. + + That narrowing is deliberate, and the three shapes did **not** all behave the same way before it — the difference is worth stating exactly, because only one of them ever worked: + + - A **record** and **`null`** never survived the `Array.isArray(obj.indexes)` guard the driver has always applied. That author got no index and no diagnostic — silently, at run time. Rejecting those two at compile time is precisely the failure this change exists to make impossible. + - A **`readonly` tuple (`as const`)** is a different case, and the only one with anything to lose. `as const` is type-only: at run time the value is a plain array, `Array.isArray` returns `true`, and the index **was** synced. That caller compiled and worked, and is now rejected at compile time. Nothing about its run-time behaviour changed — the rejection is entirely on the type surface. + + No migration is owed even so. No caller in this repository is affected, and the shape could never have reached `detectManagedDrift` on the same class either, which publishes the very same `any[]` spelling for the very same key — so a `readonly` caller was already unable to use half of this driver's declared-index surface. A caller in that position spells the array without `as const`, or widens it at the call site. + + The disposition on that corrected ground, recorded here because the ground itself moved: **no `BREAKING` banner and no ADR-0087 disposition**, resting on grounds (i) and (iii) alone — zero affected callers, and the `any[]` spelling already published on `detectManagedDrift` for the same key on the same class. The ground that every newly rejected shape had already been discarded at run time is **not** among them: it is false for the `readonly` tuple, and nothing here leans on it. + + `@objectstack/driver-sqlite-wasm` is named because `SqliteWasmDriver extends SqlDriver` and overrides neither method, so both widened signatures land in its own published `.d.ts` and its consumers see the identical change. The two packages are in the same fixed version group, so this is a CHANGELOG effect rather than a version one. + + The `IDataDriver` contract itself did not move: `registerObjectMetadata?(schemas: unknown[])` in `@objectstack/spec` already accepted `unknown[]`, and `SqlDriver` narrowed it on its own. What grew is `SqlDriver`'s own published accept set. + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [001a83b] +- Updated dependencies [6d4d5d3] +- Updated dependencies [45cfa1b] +- Updated dependencies [7862fb7] +- Updated dependencies [1ca95df] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [8f2ecb3] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [61821e5] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [33e939f] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [b72226f] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [78bc4ad] + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index a2890926f1..e97049414b 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.3.0", + "version": "17.4.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 33c184df82..1f52ccd345 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,383 @@ # @objectstack/driver-turso +## 17.4.0 + +### Minor Changes + +- 6d4d5d3: `SqlDriver.aggregate` answers `0` — not `null` — for a `sum` over a group whose aggregand is NULL in every row, matching the engine's in-memory aggregate tier and the identity `emptyGroupValueFor` already declares (#15546; maintainer ruling 2026-09-07, option A: a non-empty group whose aggregand is absent and an empty group are the SAME case for `sum`, and the SQL face is the one that moves). + + SQL `SUM` skips NULLs and answers NULL once it has skipped everything, so on every dialect this driver targets (measured on better-sqlite3, live PostgreSQL 16.13 and live MySQL 8.0.46) a grouped list view with a `sum` summary on a nullable number or currency column rendered a BLANK total for a group whose column was empty in every row — while the same view on a deployment whose query took the engine's in-memory path rendered `0`. Which path answered was decided by a driver capability bit the caller never sees. The fold is part of the driver's aggregate presentation (`foldEmptyAggregateAnswers`): the compiled statement is unchanged (no `COALESCE`), the answer is the JS number `0` on every dialect, and `avg`/`min`/`max` — which have no identity over nothing — still answer `null`. The identity is read from `emptyGroupValueFor` rather than restated, so the two faces cannot drift apart on it again. + + `@objectstack/driver-turso`: the REMOTE transport's `aggregate` carries the same fold (`RemoteTransport.foldEmptyAggregateAnswers`). `TursoDriver` picks the remote compiler or the local `SqlDriver` one from `url`, so without it the same driver would have answered the all-NULL `sum` as `0` locally and `null` remotely — one query, two answers, decided by a connection string, the seam the shared conformance table exists to close. Measured `null` on the enrolled remote face before the fold. + + `@objectstack/spec`: the aggregate-vocabulary conformance fixture gains a NULLABLE numeric column. `AggregationRow.amount` (`number | null`) is NULL in every row of the `east` group and in two of the four `west` rows, and `AGGREGATION_CASES` gains the three cases that pin the ruled answer on every enrolled face — `sum(amount)` grouped by region (`east` 0 / `west` 40), its `count(amount)` reachability control (`east` 0 / `west` 2, which is what proves the nulls were stored as nulls), and the ungrouped partial-null control (40). A harness that runs the table MUST declare `amount` as a nullable numeric column and seed its nulls AS nulls, exactly as it already must for `stage`; a `0` written in place of a null turns the cell green for the wrong reason. +- e9fcd6b: feat(driver-turso)!: the published connection config names its timeout's unit (#15682, ruling B on #14478) + + + + + **BREAKING** — `TursoConfigSchema`'s `timeout` is renamed to **`timeoutMs`**. The + value is unchanged: the same milliseconds, the same `min(0)` bound, the same + optionality. + + `@objectstack/spec`'s own turso contract renamed the same authored key in + #15680. This package publishes a parallel schema for the same connection config + — the Spec / Studio metadata a host reads to expose Turso configuration UI — so + until now the two declarations of one setting disagreed on its spelling. They + agree again. + + The unit was never in the key name, only in the describe prose, while + `sync.intervalSeconds` — the same shape, three keys above — already spelled its + own. One published config carrying both conventions is what made the bare name + dangerous rather than untidy: an author who has just written + `intervalSeconds: 30` has no reason to read `timeout: 30` as milliseconds, and + nothing in the schema, the type or the parse would have told them otherwise. + + The old spelling is not dropped in silence. `TursoConfigSchema` is a plain + `z.object`, so a bare deletion would have STRIPPED `timeout` and parsed + successfully. The key stays declared as a tombstone instead: `tsc` refuses it on + anything typed `TursoConfig`, and a value that reaches the parse raises a + message naming `timeoutMs` rather than a generic unrecognised-key error. + + ```diff + - TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeout: 30000 }) + + TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeoutMs: 30000 }) + ``` + + `TursoDriverConfig` — this package's TypeScript constructor option, a separate + declaration — keeps its `timeout` spelling and is untouched here. +- ed5d557: feat(driver-turso)!: `timeout` bounds remote operations; `localPath` and `wasm` leave the published config schema (#16024, ADR-0049 enforce-or-remove) + + + + Three keys on this package's published Turso configuration were declared with a + describe promising behaviour that no code delivered — ADR-0049's + declared-but-unenforced shape, sitting beside `concurrency`, which was declared + the same way and IS forwarded. The maintainer ruled per key: forward `timeout`; + remove `localPath` and `wasm`. Not a rename for any of the three — an inert key + with a better name is what ADR-0049 exists to prevent. + + **`TursoDriverConfig.timeout` now does what its docblock has always said.** It + never reached `@libsql/client`. It still does not reach that client's own + `Config.timeout`, and deliberately: measured against `@libsql/client@0.17.4`, + that option is the busy timeout for lock contention on local `file:` databases + ("remote clients ignore it"), so forwarding to it would have left remote mode + exactly as inert as before. Instead: + + - **Remote mode over HTTP** (`libsql://`, `https://`, `http://`): the driver + hands the client a `fetch` that aborts every request once the window elapses, + and the operation fails as `TIMEOUT` / 504 (the ADR-0112 envelope) instead of + hanging on a stalled endpoint. `wss://` / `ws://` URLs ride the WebSocket + transport, which exposes no such seam in this client version — they are not + bounded, and the docblock says so. + - **Replica mode**: `sync()` — the one remote operation on that arm — rejects + with the same envelope when it has not completed within the window. The native + binding's sync is not cancelled, only no longer awaited. + - `0` or unset means no bound, as the published schema already documented. + + A datasource authors this as `config.timeoutMs`; the datasource seam maps it + onto the driver's `timeout`, so a `timeoutMs` that used to be silently dropped + now bounds the connection it describes. + + **BREAKING** — `TursoConfigSchema` refuses `localPath` and `wasm`. Neither was + read by any code: the replica arm names its local file via `url` (forwarding + `localPath` would have created a second way to say the same thing), and nothing + selects a WASM build of libSQL (forwarding `wasm` would have meant building + one). The shape is a plain `z.object`, so a bare deletion would have stripped + both keys in silence; they stay declared as `z.never()` tombstones instead — + `tsc` refuses them on anything typed `TursoConfig`, and a value reaching the + parse raises the prescription below rather than a generic unrecognised-key + error. The same treatment this package's `timeout` → `timeoutMs` rename took. + + ## Migration + + | Wrote | Write instead | + | --- | --- | + | `localPath: './replica.db'` beside `url: 'file:./replica.db'` | delete `localPath` — `url` names the replica's local file, `syncUrl` the remote primary; a path that differed from `url` belongs in `url` | + | `wasm: true` | delete `wasm` — no WASM build was ever selected; a runtime that cannot load native bindings uses the remote arm (`libsql://` / `https://`), which needs none | + + `@objectstack/spec`'s own turso contract never declared either key, so no stack + source or stored datasource row that passed the spec door can carry them; the + ADR-0087 ledger records the removal as the D3 entry + `driver-turso-config-local-path-wasm-retired` (no D2 conversion — there is no + lossless rewrite for a value that never did anything), which is the + `@objectstack/spec` `minor` here — the entry is a new member of the published migration + registry (`packages/spec/src/migrations/registry.ts`), an additive widening of that package's + surface, and the act sets the floor. +- 7862fb7: `TursoDriver.initObjects` now declares every key `SqlDriver.initObjects` declares — `tenancy`, `indexes` and `lifecycle` — so a caller of this package can spell them in a **fresh object literal** instead of hoisting the object to a variable to get past the type. + + `TursoDriver` OVERRIDES `initObjects`, and an override does not inherit the base's parameter type. Its own literal read `Array<{ name: string; fields?: Record }>`, which is what every consumer of `@objectstack/driver-turso` saw — so when #4311 declared `tenancy` on the base in August, that fix did not exist from outside this package, and stayed invisible for five weeks with nothing red anywhere. #16570's `indexes` fix would have escaped by the identical route. + + The type face was the only thing refusing the keys. The remote arm forwards the whole object through as `schema`, and `registerRemoteFieldMetadata` reads `tenancy` straight back off it, so the runtime carried both keys the entire time. `tenancy.enabled: false` is the key that decides whether a UNIQUE partitions globally or per organization — an author who hit the refusal and dropped it silently got the other answer. + + - `registerRemoteFieldMetadata(obj)` declares `tenancy?: any` and reads it directly; its `(obj as any).tenancy` cast is gone. + - The boundary is intact: a misspelling on a fresh literal is still `TS2353`, pinned in `src/turso-driver-16711-init-objects-param.test.ts`. + - `scripts/check-object-def-param-keys.mjs` now fails the build if this override — or any other subclass override in the workspace — declares fewer keys than the method it shadows, or erases the base's shape with an opaque type or an index signature. +- a646120: The remote transport compiles a text operator over a declared numeric or boolean column to the contract's declared answer, in step with the local transport. + + `RemoteTransport.buildWhereSQL` compiles filters independently of `SqlDriver` and keeps no schema, so a text operator over a `Field.number` used to compile `"col" GLOB ?` and coerce the REAL in the storage class's spelling (`5` as `'5.0'`). `TursoDriver` now hands the transport its declared-type rule (`setNonTextColumnResolver`, the same shape as the temporal `setFilterColumnSql` rule), answered from the registries `registerRemoteFieldMetadata` already fills at schema sync — so a positive text operator over such a column compiles to `1 = 0` and `$notContains` to `1 = 1` on BOTH transports (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05), instead of a dialect accident. A transport nobody handed the rule to compiles exactly as before, and every comparand refusal still runs ahead of the constant. +- 5071310: fix(driver-turso)!: `timeout` beside a pre-configured `client` in remote mode is refused at construction instead of being accepted and never delivered (ADR-0049 enforce-or-remove) + + + + `TursoDriverConfig.timeout` bounds remote operations over HTTP by installing a `fetch` that aborts at the window — and it installs it in exactly one place, while the driver is CREATING its `@libsql/client`. A pre-configured `TursoDriverConfig.client` arrives with its transport already built, and both remote sites that consume it (`connect()` and the lazy connect factory the transport self-heals through) skip the builder entirely. So on that one composition the window reached nothing: the driver constructed, connected, and ran every request unbounded, while `timeout`'s contract promised "every request the client's HTTP transport makes" and `client`'s said nothing about the key ceasing to apply. + + **BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside a supplied `client` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and a message that names both keys, the window, the mode and both ways out: + + ``` + `TursoDriverConfig.timeout` (30000 ms) is set beside `TursoDriverConfig.client` in + remote mode, and on that pair it bounds nothing: the window is the `fetch` this + driver hands @libsql/client while CREATING the remote client, and a pre-configured + client is already built — its transport is not the driver's to replace … Either drop + `client` and let the driver create the remote client, where every request IS bounded + and a stalled endpoint fails as TIMEOUT / 504, or keep `client` and omit `timeout`, + building the bound into that client yourself when you call `createClient({ fetch })`. + Replica mode is unaffected: there `sync()` is bounded whatever client is in use. + ``` + + **Who can reach this, measured on this tree.** The datasource seam cannot: `buildTursoDriverConfig` emits nine keys (`url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, `sync`, `timeout`, `mode`, `schemaMode`) and `client` is not among them — it is a live object, not authorable metadata, and the published `turso` schema documents its absence deliberately. So no datasource, environment variable or `sys_metadata` row can produce this pair; only code calling `new TursoDriver(...)` / `createTursoDriver(...)` directly. Across the 138 construction sites in this repository, the only one pairing the two keys outside the new pin file is a replica-arm test fixture, which stays accepted. Whether any out-of-repo host composes them is NOT measured and is not claimed to be zero. + + **What stays accepted — the refusal is no wider than the gap**, pinned by controls: + + - a supplied `client` with no `timeout`, and an explicit `client: undefined`, which the `??` at both sites treats as absent; + - `timeout` with no `client` — the client the driver builds IS bounded; + - `timeout: 0` beside a client, the documented "no bound", which asks for nothing; + - the whole REPLICA arm, where `sync()` is bounded by the driver around the awaited promise whatever client is in use, so the key is not inert there and the pair is still accepted. + + **What is deliberately NOT done**: wrapping or re-creating the caller's client so the window rides after all. A client handed in for custom caching, connection pooling or testing is the caller's object, and replacing its transport because `timeout` is set would discard the configuration it was built to carry, behind the author's back — the same reason a `wss://` url is not silently re-routed over HTTP. + + **What an affected author does.** The refusal text says which two: drop `client` and let the driver create the remote client, which bounds every request; or keep `client` and drop `timeout`, building the bound into that client where it is created, since `@libsql/client` reads its `fetch` at creation. Which of the two is wanted is authoring intent, and the choice is made in place at the driver config. +- 2200f8e: feat(driver-turso): the `update()` override publishes its honest type — `Record | null`, not `any` (#14438) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` overrides `update()` rather than inheriting it, 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. Both of its branches already answered the contract's type: the local branch forwards to `SqlDriver.update()` (narrowed alongside, #14438) and the remote branch passes `RemoteTransport.update()`'s `Record | null` (#14428) through the generic `formatRemoteRow`. The override now declares what it answers. A caller that read fields off the result through the `any` now narrows the `null` arm first. No runtime behaviour changes. + + +- 0145680: fix(driver-turso)!: `timeout` beside an UPPERCASE `WSS://` / `WS://` url in forced remote mode is refused at construction, closing the last corner of the same gap (ADR-0049 enforce-or-remove) + + + + The refusal that closed `timeout` beside a `wss://` / `ws://` url matched the two schemes **literally**, so one composition still constructed with a window that reaches nothing: + + ```ts + new TursoDriver({ url: 'WSS://db.example.turso.io', mode: 'remote', timeout: 30000 }) + ``` + + Reading `@libsql/client`'s routing switch alone says that cannot happen — the switch really does match the literal lowercase (`lib-esm/node.js`: `config.scheme === "wss" || config.scheme === "ws"`). But the switch never sees the url as the author spelled it. The node entry is `_createClient(expandConfig(config, true))`, and `expandConfig` has already lowercased the scheme by then — `@libsql/core@0.17.4`, `lib-esm/config.js`: `const originalUriScheme = uri.scheme.toLowerCase();`. Executed against that version: `expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'`, and `'Ws://127.0.0.1:8080'` → `'ws'`. So an uppercase `WSS://` url does reach the WebSocket client, which takes no `fetch` and no timeout option of its own — the driver constructed, connected, and ran unbounded. + + **BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside an uppercase-or-mixed-case `wss://` / `ws://` `url` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and **the same message the lowercase spelling already produced**, echoing the scheme in the caller's own casing so an operator can grep their config for what they actually typed. + + **The explicit `mode: 'remote'` is load-bearing.** Without it an uppercase url falls through `TursoDriver.detectMode` to `'local'` — behaviour that predates the refusal entirely and is **unchanged here**. Only the window predicate folds case; the mode detector is deliberately left case-sensitive, and the code says so at the predicate, because folding it there too would delete that fall-through: a mode-detection change on a published driver, which must be argued on its own rather than slipped in as a tidy-up. + + **What stays accepted — the refusal is no wider than the gap**, pinned by controls: + + - an uppercase url with **no** explicit `mode` still detects as `'local'`, with or without a `timeout`; + - the uppercase WebSocket url with no `timeout`, or with `timeout: 0` (the documented "no bound"); + - `https://` / `HTTPS://` / `LIBSQL://` / `HTTP://` remote urls **with** a window — the HTTP arm is bounded, so every casing of every HTTP-side scheme keeps the key; + - the existing lowercase refusals, unchanged in code, message and envelope. + + **What an affected author does.** Unchanged from the lowercase case, and the refusal text says it: keep the window and spell the url `libsql://` or `https://` (bounded — the client resolves `libsql://` to HTTPS), or drop the window and run the WebSocket remote unbounded, as it always did. + + Blast radius, measured on this tree: no in-repo deployment, example, test or doc pairs an uppercase remote scheme with a window; the host boot path (`OS_DATABASE_URL`) forwards only `url` and `authToken`, and the datasource seam's `buildTursoDriverConfig` normalises no casing either — so the pair is reachable in principle from both and is not observed in this repository. Whether any out-of-repo deployment spells a Turso url with an uppercase scheme is NOT measured and is not claimed to be zero. +- bc0ac1d: fix(driver-turso)!: `timeout` beside a `wss://` / `ws://` url is refused at construction instead of being accepted and never delivered (ADR-0049 enforce-or-remove) + + + + `TursoDriverConfig.timeout` bounds remote operations over HTTP (`libsql://`, `https://`, `http://` — the driver hands `@libsql/client` a `fetch` that aborts at the window) and bounds `sync()` on the replica arm. A remote url spelled `wss://` / `ws://` rides the client's WebSocket transport, which — measured against `@libsql/client@0.17.4` / `@libsql/hrana-client@0.10.0` — takes no `fetch` and no timeout option of its own, so on that one scheme the window reached nothing: the configuration constructed, connected, and ran unbounded, with the gap stated only in a docblock. + + **BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside a `wss://` or `ws://` `url` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and a message that names the key, the scheme it met, and both ways out: + + ``` + `TursoDriverConfig.timeout` (30000 ms) is set beside a `wss://` url, and on that + scheme it bounds nothing: a `wss://` url rides @libsql/client's WebSocket + transport, which takes no fetch and no timeout option … Either omit `timeout` + and run this remote unbounded, or keep it and spell the url `libsql://` or + `https://` — the client resolves `libsql://` to HTTPS — where every request IS + bounded and a stalled endpoint fails as TIMEOUT / 504. + ``` + + A datasource authors the window as `config.timeoutMs`; the datasource seam maps it onto the driver's `timeout`, so a `timeoutMs` beside a WebSocket url now fails the datasource's connect by name instead of quietly running unbounded. Both loaders (`@objectstack/runtime`'s host factory and the open-core datasource factory) reach this refusal through the same constructor. + + **What stays accepted — the refusal is no wider than the gap**, pinned by controls: + + - a `wss://` / `ws://` url with no `timeout`, or with `timeout: 0` (the documented "no bound"); + - `libsql://`, `https://` and `http://` urls WITH a window — the HTTP arm is bounded; + - the replica arm with any url scheme — `sync()` is bounded there, so the key is not inert. + + **What is deliberately NOT done**: routing a `wss://` url over HTTP because `timeout` is set. That would change the wire transport behind the author's back and is a contract decision, not a driver's; the refusal changes no wire behaviour. + + **What an affected author does.** The refusal text itself says which two: keep the window and spell the url as `libsql://` or `https://` (bounded — `libsql://` resolves to HTTPS), or drop the window and run the WebSocket remote unbounded, as it always did. Which of the two is wanted is authoring intent, and the choice is made at the datasource or driver config, in place. + + Blast radius, measured on this tree: no in-repo deployment, example or doc pairs a WebSocket url with a window, and the host boot path (`OS_DATABASE_URL`) forwards only `url` and `authToken`, so an env-configured deployment cannot carry `timeout` at all. + +### Patch Changes + +- d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. + + Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. + + No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. + + Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. +- 001a83b: `SqlDriver.execute()` — the raw-SQL path the analytics compilers run on — now declares a backend refusal the way the typed read exits (`find` / `count` / `aggregate`) have since #8931: `code: DATABASE_ERROR`, `status: 500`, a composed message that carries none of the dialect's words, and the dialect error whole under a non-enumerable `cause`. `TursoDriver` in remote mode — the one transport that hands the engine's text back with no statement in front of it — declares through the same terminal, so both transports leave the driver with one envelope. **Graded `patch`** on AGENTS.md's changeset rule ("A bug fix in a released package takes a `patch` changeset"; breaking is what removes or renames something an author can write — a spec key, an export, a config field — and nothing here does: `execute()` stays `Promise` of `any`, and `code` / `status` were untyped before) and on the precedent of the identical change on the typed read exits, #8931 via PR #9273, which shipped `@objectstack/driver-sql: patch`. + + **The defect this closes (#16019, folding in the envelope half of #16028).** `no such function: translate` — what SQLite answers when a compiler emits a function the dialect lacks — left `execute()` as knex's own error: `code: 'SQLITE_ERROR'`, no `status`, message ` - no such function: translate`. Undeclared, it fell to the HTTP doors' phrasing heuristic (`looksLikeInternalErrorLeak`), which recognises `no such column:` and not `no such function:`, so whether the caller saw the engine's text depended on which limb the message happened to match: through knex it was withheld by accident (the statement prefix starts with `select`), through the Turso remote transport it was withheld by a different accident (`SQLITE_ERROR:` in front), and a bare `Error('no such function: translate')` reached the body verbatim. Maintainer ruling 2026-09-06 (decision batch #57, option 3): the substring list is not grown; the driver declares its own fault and the doors classify on the declaration. The heuristic stays as the last-resort fallback for an error that arrives with no declaration. + + **What moves on the wire — three doors, each because a declared fault is relayed where an undeclared one was re-labelled.** + + - `POST /api/v1/analytics/dataset/query`: a driver fault on the raw path answers `500 {"code":"DATABASE_ERROR","error":"Internal server error"}` — the declared-fault relay, the same answer the `/data` door and `/analytics/query` already give a declared 5xx — where it was `500 {"code":"ANALYTICS_QUERY_FAILED","error":"Internal server error"}` when the phrasing heuristic happened to fire and the raw engine text when it did not. Status unchanged; the code is now the producer's, exactly as the typed read exits' faults have answered at this door since PR #9273. + - The same door, a dataset over a backing table that is NOT present, on the native-SQL strategy (the strategy every deployment whose data engine exposes `execute()` runs): `500 DATABASE_ERROR` where it was `200 {"rows":[],"fields":[],"totals":[]}` plus a `warn`. `queryDataset`'s missing-source degrade sits behind its declared-envelope re-throw (#5717 defence B: a declared envelope is re-thrown untouched, whatever it says), so a driver-raised missing table no longer reaches it — the answer the ObjectQL-aggregate strategy has given since #9273, now on both strategies. The degrade still applies to an undeclared producer (an embedder's own `executeRawSql`, the framework's not-registered signals). + - `POST /api/v1/packages/publish` and `DELETE /api/v1/packages/:id`: a raw-exec driver fault under `sys_packages` answers `500 {"code":"DATABASE_ERROR"}` with the composed sentence as its message — `PackageService.publish` / `delete` re-throw a throw that declares an HTTP answer (`declaresHttpAnswer`, whose docblock already says a declared 5xx is re-thrown too) and the door's `sendThrownError` relays it — where it was `500 PACKAGE_PUBLISH_FAILED` / `500 PACKAGE_DELETE_FAILED` from the swallowing branch. Same status band, no dialect text on the wire either way; the ledgered `code` on those two doors moves. + + **What a consumer of `execute()` sees.** `error.message` is the composed sentence; `error.code` is `DATABASE_ERROR` where it was the backend's errno; `error.status` is `500` where it was absent. The backend's error object — its errno, its diagnostic, and on the dialects that inline them the bound literals — is on `error.cause` (non-enumerable, so it does not serialise), and the driver writes it, with the statement, to its warn log before composing. Cause-following predicates are unaffected: `isMissingTableError(err, readObject)` still classifies a missing table raised on this path. An error that already declares a `status` is passed through untouched, never double-wrapped. A caller that read the dialect's text off `error.message` (a migration preflight recording it as its `detail`, say) now reads the composed sentence there and finds the dialect text on `cause` and in the log; the in-repo sites of that class are tracked as #16657 (read `cause` there). +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [001a83b] +- Updated dependencies [6d4d5d3] +- Updated dependencies [45cfa1b] +- Updated dependencies [7862fb7] +- Updated dependencies [1ca95df] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [8f2ecb3] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [61821e5] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [33e939f] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [b72226f] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [78bc4ad] + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index c8ae28b210..0d751e049e 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.3.0", + "version": "17.4.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 400ceab0f1..d8f52eeb40 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,160 @@ # @objectstack/formula +## 17.4.0 + +### Minor Changes + +- 098cbb7: `validateExpression` now refuses a non-string expression `source` through `errors[]`, instead of throwing a raw `TypeError` that wiped out the caller's located reporting. + + `validateExpression(role, input)` accepts `string | { dialect?, source? }`, and read the envelope's `source` unguarded — `if (!source.trim())`. `ExprInput` declares `source?: string`, but every production call site casts, because the value comes out of **metadata**, where a declaration is a claim about stored data and not a guarantee about it. An envelope whose `source` was present and not a string therefore threw `TypeError: source.trim is not a function` out of a validator whose own docblock promises it never throws. + + **The defect was not "it throws" — it was that it threw the wrong kind and bypassed a whole located-reporting contract.** `AutomationEngine.validateFlowExpressions` collects located findings and throws one assembled error naming the flow, the node, the slot and the source (ADR-0032 §1d); `@objectstack/lint`'s stack walk attributes every finding to the hook, sharing rule, action or field it came from. An exception raised *inside* the shared validator skipped both, so the author was handed an internal message naming none of them. Measured before the fix, on a stack whose `hooks[].condition` was `{ source: { nested: 1 } }`: the whole `objectstack validate` run died on `source.trim is not a function`. After: one located `error` reading ``hook 'gate_hook' (lead) condition``. + + The guard sits at `toSource`, the entry `validateExpression` and `inferExpressionType` share — **once**, not in each caller's own `try`/`catch`, which is the tolerant-consumer shape Prime Directive #12 forbids. `validateExpression` returns `ok: false` with one `ExprValidationError` naming what was found and both authorable forms; `inferExpressionType` answers `'unknown'`, its existing "cannot prove a type". + + **No exported symbol or signature moves** — measured by diffing the built `dist/index.d.ts` before and after: 39 exported declarations on both sides, and `validateExpression`'s declaration byte-identical. What changes is behaviour at a published entry, which is why this is `minor` rather than `patch`: an input that previously produced **no verdict at all** now produces a rejection. + + **What does not change.** Absent, `null`, empty and whitespace-only sources still read as "not authored" (`ok: true`), an `{ ast }` envelope carrying no `source` is still admitted (its admission is `ExpressionSchema`'s rule, not this entry's), and a malformed *string* still gets its own diagnostic — the brace trap, the dialect mismatch, the unknown function — never the shape refusal. No input that previously returned `ok: true` now returns `ok: false`, and none that returned `ok: false` now returns `ok: true`. + + A caller that relied on catching the `TypeError` would need to read `result.ok` instead. None does: all nine production call sites (`@objectstack/lint` ×4, its docs gate ×2, `@objectstack/service-automation` ×3) read `.errors`/`.warnings` directly, and the one call site inside a `try` (`@objectstack/mcp`'s `validate_expression` tool) has a handler-level catch that degrades to an error result and declares its `expression` parameter `z.string()`. + +### Patch Changes + +- 86c75f4: `firstUndeclaredReference` now documents the side of its contract it was silent about: it can false-NEGATIVE, and a `null` is "nothing was reported", not "every reference is rooted". + + The existing sentence — "Acts ONLY on cel-js's `Unknown variable: X` fault, so it cannot false-positive on arithmetic/comparison overloads" — is true, and stays. What it never said is what that narrowing costs. cel-js's checker returns exactly ONE error, so when the first one is of another class every undeclared reference behind it in the same source goes unjudged and the helper answers `null` — the same value that means the source is clean. A contract that declares only which error it cannot make reads as making neither. + + No behaviour changes. This is the contract text, and it ships: the amended block is JSDoc on a published export, so it is emitted into `@objectstack/formula`'s `dist/index.d.ts` and `dist/index.d.mts` (measured — the declaration file grew 53.45 KB to 55.99 KB) and is what a consumer reads on hover. + + What the amendment adds, all of it measured rather than reasoned: + + - **The masking is positional, not name-keyed.** The masked name is not the one that triggered the first error, so excluding the trigger's own name does not reach it. `data == 'x' && status == 'q'` answers `null`; the same two names in the other order answer `"status"`. + - **`celEngine.compile()` is not a gate against it.** `compile` type-checks in the permissive environment, where every unlisted name is `dyn`. The strict environment here declares `SCOPE_ROOTS` as `map`, so a root — or an object field sharing one of those names (`data`, `config`, `result`, `item`, `event`, `input`, `user`, …) — used as the operand of an operator with no `map` overload faults HERE and nowhere else. A caller that only reaches the helper on a clean compile is therefore not protected by its own gate. + - **The CEL type-name class is the same shape.** `type == 'grid'` is already pinned as a blind spot in `@objectstack/lint`'s `visibility-bare-identifier` suite, but pinned per NAME; the masking it causes is source-wide. + - **What closing it would take, and why that is not this change.** Widening the regex onto the overload message is the false positive the narrowing buys off (`type(record.x) == string` is legitimate CEL). Reporting past the first error needs a re-check loop over a neutralised source, or a checker entry returning more than one error — cel-js 8.0.0 has neither; its `TypeCheckResult` carries a single `error`. Both change what every consuming rule reports, so the oracle's shape is a design decision. + + `@objectstack/lint` carries a second comment-only correction, to `flow-variable-scope`'s account of the same oracle. Its "known, deliberate blind spot" note bounded the under-report to a flow variable named after a `SCOPE_ROOTS` member; measured, the bound does not hold — such a name in an operand position terminates the discovery loop on iteration 0 and every shadow in that source is lost, whatever it is named. That block sits on an internal function, so unlike the `formula` half it reaches no published declaration file; the entry is here because the package is touched and published. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 20d659f2b0..4a0dd393e0 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.3.0", + "version": "17.4.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 c1214fd85d..f54b737d1d 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,748 @@ # @objectstack/lint +## 17.4.0 + +### Minor Changes + +- 954cb0b: feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention; the level also follows the 2026-09-04 bump ruling — + this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an + additive widening is at least `minor`). No ADR-0087 conversion: no authorable key + is renamed or retired, and the shape this refuses was never a shape any surface + offered. + + The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be + a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size` + …) is finally reachable from metadata — until now CEL was only ever asked for a + boolean. The spec half landed the contract (PR #15113); this is the half that + makes it do something. + + ```yaml + # before: written into the variable verbatim, and rendered by `notify` as + # {"dialect":"cel","source":"joinNonEmpty(...)"} + # now: evaluated — digest is "Renewal due\nInvoice overdue" + assignments: + digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' } + ``` + + - **Evaluated at run time.** The built-in `assignment` executor evaluates a + `value`-role envelope with the expression engine and assigns the result, in the + same CEL scope a flow predicate is evaluated in (one shared scope builder, so a + predicate and a value expression cannot disagree about what `rows` means). A + plain string keeps today's `{token}` interpolation, and every other literal is + still assigned as data. + - **Refused at three doors.** A malformed envelope now stops the flow registering + (`registerFlow` throws, the severity a malformed predicate gets) and surfaces as + a located `error` finding naming the node and the author's own variable — + `config.assignments.digest` — both at `objectstack validate` and at the runtime + publish gate a Studio / REST / MCP flow write goes through + (`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes: + ['flow']`). Malformed is a composition, not a fixed list: whatever + `AssignmentValueSchema` refuses in the envelope's shape — among them a missing, + empty or non-string `source`, a dialect other than `cel`, a non-object `meta` — + and then CEL that does not parse. All three doors derive that set from the same + two published validators, so none refuses a shape the executor would have run, + and a registered flow never faults for a shape those validators judge malformed. + Two shapes sit outside what either validator can judge — an `ast`-only envelope + and a whitespace-only `source` (it passes `min(1)` and reads as "not authored" + to the validator, while the CEL engine parses it untrimmed) — and those fault + loudly at run time rather than assigning a value. Both are pinned and tracked in + #15430. + - **Only the canonical map.** The ledger declares `assignment.assignments.*` and + nothing else, so the two legacy shapes the executor still normalizes — the + `assignments: [{ variable, value }]` array and the bare `{ : }` + config — keep every meaning they had, envelope-shaped values included. + `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the + array form: refusing it would break flows that register today, and that refusal + is a maintainer ruling rather than a lane's call (#15137 ask 3). + + **What changes silently, and how far it reaches.** A flow that today authors an + envelope-shaped object *as data* in the canonical `assignments` map now evaluates + it — no error on either side, a different value. The discriminator is the spec's + own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`, + in the declared map only. Data that names no `dialect`, names a non-string one, + nests the envelope one level down, or sits in either legacy shape is untouched + and byte-identical. The remaining overlap — a well-formed + `{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly + the spelling the ruling reinterprets; every near-miss the two validators can + judge now refuses loudly at registration instead of changing value in silence. +- 36a16d0: Two new widget-binding rule ids for a chart widget with an empty selection + + `validateWidgetBindings` reported nothing about two dataset-bound chart shapes that the + `@object-ui` revision this repo pins (`.objectui-sha`) visibly degrades. Both are now + warnings, suppressible per widget with `suppressWarnings: ['']`: + + - `chart-measures-missing` — a chart-family widget selects no measures (`values` empty or + absent). `DatasetWidget.tsx:683` returns the authoring placeholder "Pick measures + (values) for this dataset widget." before any query runs, above every family branch, so + no chart is drawn at all. + - `chart-dimensions-missing` — a chart-family widget selects at least one measure but no + dimensions. `DatasetWidget.tsx:423` reads + `const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;`, so the + widget renders as a single KPI number and the declared chart family is silently ignored. + The hint steers the author to a dimension, or to the `metric`/`kpi` family that matches + what actually renders. + + Warning tier rather than error for both: an empty selection is a work-in-progress state a + build must tolerate, and erroring would gate the `sys_metadata` publish path on a + half-authored widget. Neither shape is folded into `chart-config-missing` — neither is + caused by, nor repairable with, `chartConfig`, which carries presentation only. + + "Chart family" is derived, not hand-listed: every declared `ChartTypeSchema` option that + the pinned renderer routes to its chart branch — the taxonomy minus the renderer's own + `METRIC_TYPES` (`metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`) and its `table`/`pivot` + tabular test. A `metric` tile with no dimensions, such as the shipped `system_overview` + board's own KPI tiles, is therefore not a finding. +- c01b3a6: `chart-field-unknown` drops to `warning` on the three `chartConfig` binding keys the pinned renderer refuses, and says what actually happens + + The rule id covers exactly three positions, and the `@object-ui` revision this repo pins (`.objectui-sha`) refuses all three as bindings, so none of them can produce the data failure the messages described: + + - `chartConfig.xAxis.field` — `axisPresentation` (`@object-ui/core` `src/utils/chart-presentation.ts`) builds the axis presentation **minus** its `field`. The x-axis key is `buildChartSeries`' `xAxisKey`, i.e. the widget's `dimensions[0]`; an authored `field` re-points nothing. + - `chartConfig.yAxis[].field` — the same call, per entry. The entry keeps its slot (the count is what turns on a secondary axis) and its scale and chrome; only the binding is dropped. + - `chartConfig.series[].name` — `mergeAuthoredSeries` pairs an authored entry with the derived series whose `dataKey` it equals, one per entry of `values`. An entry naming no derived series is ignored whole, so the presentation hung on it — the mark, the colour, the stack, the axis side — lands on nothing. + + The renderer pins this by name in `DatasetWidget.chartConfig.test.tsx` ("ignores an authored axis `field` and keeps the derived axis binding", "ignores an authored series and keeps one derived series per measure"). + + So the old message — "the query result will not contain it" — named a query failure that never happens, and `error` blocked a build and a Studio publish for a key that changes nothing at runtime. That is the class `widget-legacy-analytics-shape` reports at `warning` in the same file ("the dashboard renderer ignores them … a silent no-op"), and this id now carries the same tier, the same suppressibility (`suppressWarnings: ['chart-field-unknown']` per widget) and the same kind of sentence. Each message states its own consequence, because the axis positions and the series position are refused for different reasons. + + The finding is **kept**, not deleted: unlike the `chart-config-missing` over-reach this measurement came from, the metadata really is wrong — the author wrote a binding and believes it is in force. + + ## Migration + + **A publish that used to be refused now succeeds.** Ruled 2026-08-15, `validateWidgetBindings` put its whole error set on the `sys_metadata` publish door (Studio / REST `/meta` / MCP) as one "this board cannot render" reference-integrity class. That class was six ids and is now five — `chart-field-unknown` has left it. A dashboard write whose only reference-integrity problem is a refused `chartConfig` binding key is no longer a 422 `INVALID_METADATA`; it publishes, and the finding rides the non-blocking `advisories` channel on the 2xx response instead. The other five (`widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`) are unchanged. + + Same direction on the CLI: `os validate` / `os build` / `os lint` report the finding at `warning`, so a stack that used to fail the build over one of these keys now exits 0 with an advisory. If you were relying on the build to stop on it, add the key to your own gate, or fix the binding — the fix has not changed: + + - point `xAxis.field` at a dimension the widget selects (or drop the key — `xAxis` carries presentation only); + - point `yAxis[].field` at a selected measure (or drop it — `yAxis[]` carries presentation only); + - name a selected measure in `series[].name`, remembering that post-cutover (ADR-0021) result rows are keyed by the dataset's measure **name** (`sum_amount`), not the base column (`amount`). + + A deliberately inert key can be silenced per widget with `suppressWarnings: ['chart-field-unknown']`. +- 56fe8c2: A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. + + A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate. + + - `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge. + - `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`. + + **String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved. + + An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map. +- d61bad0: New gating rule `flow-filter-token-unknown`: a `{…}` filter token in a flow node's `config.filter` that NEITHER `{…}` dialect can resolve is now an authoring-time `error`. + + `filter-token-unknown` walks seven presentation collections and not `flows`, so `{TOMORROW()}` in a list view's filter failed the build while the identical string in a flow node's `config.filter` was silent — even though this package's other filter rules (`empty-combinator`, the preset-comparand rules) have reached flows all along. + + The gap was not an oversight to close by adding a root. A flow node's filter is interpolated by the automation template evaluator **before** ObjectQL sees it, and only what that evaluator cannot resolve is handed on. Judging a flow filter against the ObjectQL vocabulary — the obvious one-line fix — reports every legitimate `{record.id}` and `{recordId}`: measured at **7 findings, all 7 false positives**, on this repo's own example apps. So the new rule is a second rule id with the flow dialect as its reference set, and `filter-token-unknown`'s surface list is untouched. + + Reported (`error`): a call to a name in neither table — `{TOMORROW()}`, `{ROUND(x)}`, `{Math.round(x)}`, `{DATEADD(day, -45)}`. The flow template dialect's function vocabulary is closed (`round` / `floor` / `ceil` / `abs` / `min` / `max`, plus the whole-token `NOW()` / `TODAY()` with an optional `± N` day offset), and the evaluator already raises a guard refusal on anything else — so the node cannot run at all, and the build was shipping a flow whose runtime was already decided. This is the same severity axis `flow-template-unknown-field` applies at this exact position. + + Silent, deliberately: `{TODAY() - 45}` and every other whole-token date form; `{$User.Id}`; `{current_user_id}` / `{today}` / `{30_days_ago}` and the rest of the filter placeholders; and every bare or dotted identifier (`{recordId}`, `{record.id}`, `{currentTask.id}`), which addresses the run's variable map — declared flow variables, node outputs, and the trigger record's own fields — and is not decidable from authored metadata. + + Finding delta on this repo's example apps: **0**. Expect a new `error` only where a flow filter calls a function the evaluator would refuse at run time. +- a87163c: New advisory rule `field-no-consumers` (`validateFieldConsumers`): a field declared on an object that nothing in the stack reads or displays is reported as a `warning` by `os validate`, `os build` and `os lint`. + + Until now such a field was schema-valid and passed every platform check — the declaration was inert and nothing in the toolchain said so. The rule is object-aware (the same field name on two objects gets two verdicts, resolved against the object whose declaration encloses each reference), and it distinguishes consumers from carriers: a view column, form section, page binding, flow node, dataset dimension, widget filter, formula, validation, hook or action is a consumer; a translation label, a seed value, an import-mapping column, a field-level permission grant or a flow that only writes the field is a carrier and never counts. The finding carries the verdict (`carrier-only` with the carrier paths a removal must clean, or `inert`), the roots scanned, and — when the name is also declared elsewhere — the other objects, so a per-object verdict is never mistaken for a name-level one. + + Exempt, each derived from the spec rather than listed by hand: the registry-injected system columns an author re-declared, the record's title field (ADR-0079 `nameField` ladder), and `master_detail` fields (ADR-0035 — cascade delete, `controlled_by_parent` sharing and roll-ups read the relationship by declaration). A stack that declares no consumer root at all (objects only, or objects plus carriers) is not judged: its consumers live in another package. Test fixtures are never scanned. + + Public surface: `validateFieldConsumers`, `FIELD_NO_CONSUMERS`, `FIELD_CONSUMER_ROOTS`, `FIELD_CARRIER_ROOTS`, and the `FieldConsumerFinding` / `FieldConsumerVerdict` / `FieldConsumerSeverity` types. +- 0cde37d: `objectstack lint` now judges hooks authored as inline `handler` functions with the same write-set rules it already applied to explicit `body` hooks. + + The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, so on the stack `objectstack lint` handed the rule registry the whole family returned before reading anything — while the reference app authors every one of its hooks that way. `objectstack build` never had the gap: it lowers each inline handler to a metadata body before it parses and judges the lowered stack. + + `objectstack lint` now hands the registry's parsed-tier rules that same lowered view (the `lowerCallables` pass the build runs), so a handler-authored hook writing a `readonly` field through `ctx.api` is refused by the pre-flight exactly as the build would refuse it. What this does and does not change: + + - A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack lint` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` with the same finding, so nothing that built green fails lint red. + - The warning-severity members of the family (`hook-body-write-unknown-field`, `hook-api-update-readonly-when-field`, …) now report on inline handlers too; they never fail a run without `--strict`. + - Nothing about what `objectstack build` accepts changes, and `objectstack validate` — which parses without lowering — is unchanged and still does not see handler-authored hooks; both are recorded in the rules' headers. + - The lint input is never mutated: rules that read the live function value (`hook-body/not-lowerable` and its siblings) keep seeing it, and a handler the extractor refuses has no body on any command, so no rule guesses about a body that was not produced. + + Measured on this repository's own four example apps (`examples/app-crm`, `app-showcase`, `app-todo`, `app-multi-package`), before and after: **121 findings before, 121 after — row for row identical, and zero at `error` on both sides.** No config that passes today starts failing. Two of the six hooks in that corpus are `handler`-authored and were invisible to this family before; their bodies write nothing the family objects to, which is why the delta is zero rather than the family being unreached. The reach itself is pinned separately, with a body-authored control beside every leg. + + `@objectstack/lint` carries only the header ledger recording which *intakes* reach each hook rule — the call sites of `runAuthoringRules`, which are more numerous than the three commands (the scaffold validator is a fourth, and it has always reached this family). Its behaviour is unchanged. +- 9f890d3: `filter-preset-comparand` gains a FIELD-TYPED arm (#16106, maintainer-ruled 1′): on a declared `date` / `datetime` field, a dashboard date-range preset name (`last_30_days`, `this_quarter`, …) is now refused in EVERY comparand position — bare (implicit equality), `$eq` / `$ne`, `$in` / `$nin`, and their view-rule (`equals` / `not_equals` / `in` / `not_in`) and triple (`=` / `!=` / `in` / `nin`) spellings — with the same located message and prescription the ordering positions already carry (`{ $gte: '{30_days_ago}' }` for `last_30_days`, and so on). The field type is read from the stack's own object graph: a dashboard widget or report through its `dataset` to that dataset's `object`, a view through `data.object`, a flow CRUD node through `config.objectName`, a page component through `dataSource` / `properties`, an object's own list views and `relatedListFilter`, a summary field's child object. A position the graph cannot bind, a registry-injected column, a `time` field, or a select / text column stays unjudged — equality against a picklist value that collides with a preset name is a working filter. The field-agnostic schema door in `@objectstack/spec` keeps its ordering-only boundary unchanged; this closes the authoring-time gap where `objectstack lint` and the runtime publish gate accepted a filter the engine then refused with `INVALID_FILTER` / 400 on first render. +- 720bf47: `flow-update-readonly-field` and `hook-api-update-readonly-field` now report a non-system **create** of a static-`readonly` field — a new **error**-severity finding that fails `os lint` / `os validate` / `os build` on a shape they used to accept. + + Both rules scanned only the update verb (`update_record`; `ctx.api…update()` / `.updateById()`) and justified the omission with the same sentence: INSERT is engine-exempt from the author-declared `readonly` strip, so a create that seeds a `readonly` column is not a no-op. The maintainer ruling of 2026-09-03 (option C, #14147) made that false — `engine.insert` now runs the same `isSystem`-gated `stripReadonlyFields` the update path runs — so a flow `create_record` without `runAs: 'system'`, or a hook body's `ctx.api.object('…').insert()` under a non-system trigger, that writes a `readonly` field became a **silent no-op**: the row lands without the column (which falls back to its `defaultValue`), the step reports `success`, and only a run-time warning names the dropped field (measured end to end in `@objectstack/service-automation`'s `create-record-readonly-drop.test.ts`). Nothing reported it at build time. This closes that scan gap (#15394). + + **What now fails that passed before.** Exactly one new shape per rule, at `error`: + + - a flow `create_record` node whose literal `fields` map writes a field the target object declares `readonly: true`, on a flow that does not declare `runAs: 'system'`; + - an L2 hook body's literal `ctx.api.object('').insert({ … })` writing such a field, on a hook that does not declare `runAs: 'system'`. + + The rule ids and severities are the update ones — one id per shape, not per verb — and each finding's message names the verb it was judged on and what actually happens to a create. Everything the rules already skipped is still skipped: a templated object name, a non-literal payload, an object outside the stack or declaring no fields, an unknown field (the unknown-field rules' question), and any `runAs: 'system'` flow or hook, because seeding a `readonly` column at create time is a system act and that write lands. + + **Deliberately not reported.** + + - No `readonlyWhen` (conditional) finding on a create, on either surface: a conditional lock is evaluated against the record being written over, which a create does not have, and the engine runs no conditional strip on INSERT ("INSERT stays exempt"). A warning there would state something false about a write that lands. + - The hook rule judges `.insert()` only, not `.create()`. The host `ObjectRepository` aliases `create()` to `insert()`, but L2 bodies run in QuickJS and the VM-side `ctx.api.object()` installs no `create` leaf — a body calling `.create()` throws `TypeError: not a function` on its first run, a loud failure rather than the silent drop this rule reports. The silence is recorded as a reasoned method exclusion (`READONLY_HOOK_METHOD_EXCLUSIONS`) and pinned. + - No create finding on a **platform object** — one declaring `managedBy`, or in the reserved `sys_` namespace. The engine's create-side strip does not judge those at all (`staticReadonlyInsertSubject`: their own ADR-0086 write guard governs them), so a finding there would describe a strip that never runs. The update verb keeps judging them, exactly as the engine's update path does. + - `validate-readonly-action-writes` is unchanged: an action body runs system-elevated by design, so its create genuinely lands. + + **Migration.** If your build reds on the new finding, the fix is one of: declare `runAs: 'system'` on the flow or hook when seeding the `readonly` column is the intent (the intended channel — `readonly` governs the end-user/API surface, not trusted system writers); remove the key from the `create_record` `fields` / `insert()` payload when it is not; or stamp it in a `beforeInsert` hook on the target object (`ctx.input. = …`), which is a server value the strip does not touch. Measured over this repository's shipped examples (`app-crm`, `app-showcase`, `app-todo`): zero in-repo flows or hooks go red — the two `create_record` nodes that target an object carrying a `readonly` field write none of its `readonly` fields, and the one flow that creates unauthenticated already declares `runAs: 'system'`; no shipped hook body inserts through `ctx.api`. +- b4b37e5: The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have. + + `#15254` closed this one key over: it crossed the reference-integrity suite onto the object write door for the object's own field-name **lists** (`highlightFields`, `publicSharing.redactFields`). The two members that read the *other* field surfaces an object carries — its ADR-0061 search set and its built-in `listViews` — still declared `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived. An object could publish clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail the same silent way downstream: the engine filters a stale search entry out without a word (`resolveSearchFields`), so `$search` scans a narrower set than declared — or, once every entry is stale, the auto-default set the author never chose — and a dangling column renders one field short. + + - **`validateSearchableFields` and `validateListViewFieldRefs` gain `object`** in their suite-member `runtimeTypes`. No new rule and no new finding class: the rule ids (`searchable-field-unknown`, `searchable-field-unsearchable`, `list-view-field-unknown`, `list-view-field-dotted`) and their severities are unchanged — they now reach the door where the author actually is. + - **The crossing carries the #9313 precondition.** Both members resolve only against `stack.objects`, the one collection every per-write snapshot carries, so neither opens a missing-collection false-positive channel; their `views[]` rungs simply find no `stack.views` on an object snapshot. + - **Measured before crossing**, at the door's own snapshot shape and differential, over every shipped object definition in the monorepo: 116 objects (platform-objects 48, showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1, qa 1), 105 built-in list views on 40 objects, 666 list-view field-naming positions and 5 `searchableFields` entries judged — **0 findings for both members, precision 1.0**, against synthetic probes that are refused. + - **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication. + + ## Migration + + **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `objects.proj_task.searchableFields[1]` or `objects.proj_task.listViews.all.columns[1]` — plus the string that was written and the fields the object actually has. + + To fix a refusal, do one of: + + - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or + - drop the entry from the declaration; or, for `searchable-field-unsearchable`, target a text-like stored column instead of a virtual or non-scannable one. + + `os validate` / `os build` / `os lint` already reported these findings at the same severity, so a code-authored stack can be repaired before it reaches a publish. Objects that name a platform-injected system column are unaffected — both members resolve those per object and stay silent where the platform really provisions them. +- e0af1a8: feat(spec)!: `` / `` are retired from the react-tier component contract — `data={{ provider: 'object', object }}` / `type` are the only spellings (#14791) + + + + **BREAKING** — an accept-set narrowing on a published contract. The `REACT_BLOCKS` + ListView entry no longer publishes the `objectName` and `viewType` overlay props that + #11284 had deprecated in favour of ListViewSchema's own `data` / `type`: the generated + contract (`skills/objectstack-ui/references/react-blocks.md`) drops both rows, and + `@objectstack/lint`'s `validate-react-page-props` now REFUSES either spelling on a + `kind:'react'` page with a new `react-prop-retired` error that carries the fix, where it + used to warn and accept. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescription is registered under protocol major 18 + (`ui-react-list-view-binding-aliases-retired`). Maintainer ruling on #14791 (2026-09-07, + director seat summon #17, decision batch #1, option B — retire now, no deprecation window, + 「同意」). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `` | `` | + | `` | `` | + | `` with no binding at all | add `data={{ provider: 'object', object: '…' }}` — it is the required binding on a react page | + + One-line fix: on every `` in react page source replace `objectName="X"` with + `data={{ provider: 'object', object: 'X' }}` and `viewType="K"` with `type="K"`, then re-run + `objectstack validate` — a leftover alias is reported as `react-prop-retired` with this + same prescription, and a list with no data source as `react-prop-missing-required`. + + ## Why now, and why no window + + The contract deprecated both aliases (#11284) while objectui's ListView still read only + `objectName`, so a page written the canonical way validated green and rendered an empty + list. That consumer half has landed and ships in the console this repo pins + (`normalizeListViewSchema` at `a472b071` folds `data.provider === 'object'` onto the key + the renderer reads and takes the author's `type` for the view kind), so both spellings + render today — and the maintainer's standing rule for a spelling with zero external + authors is to retire it at once rather than keep two vocabularies alive. + + ## What else moved + + - `REACT_RETIRED_OVERLAY_PROPS` is a new export of `@objectstack/spec/ui`: the tombstone + ledger (prop → replacement + one-line fix) the lint quotes, the react-tier twin of a + metadata schema's `retiredKey()`. + - `data` is restated on the ListView overlay as its **required** binding (ledgered in + `REACT_OVERLAY_SHADOWS`), so the generated contract marks it ✓ and a `` with no + data source is refused — the check the required `objectName` used to carry. + - `REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']` prescribes the canonical spelling. + - The showcase pages (`crm-workbench`, `renewals-pipeline`, `task-desk`), the published + `objectstack-ui` skill and the react-pages / validating-metadata guides write the + canonical spelling; `@objectstack/lint` exports `REACT_PROP_RETIRED`. +- 9408b7f: A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. + + `evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author. + + - The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`. + + **This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too. + + **Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved. + + An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. +- 615fac3: A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack. + + `list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all. + + The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this. + + - **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`). + - **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default. + - **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier. + - **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing. + + ## Migration + + **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id `object-field-ref-unknown` and the offending path, name-keyed on the wire — for example `objects.proj_task.highlightFields[1]` — plus the string that was written and the fields the object actually has. + + To fix a dangling reference, do one of: + + - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or + - drop the entry from the list. + + `os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them. +- 6b7d709: `objectstack validate` now lowers hooks authored as inline `handler` functions to a metadata body before it parses, so the hook write-set rules judge them there exactly as `objectstack build` and `objectstack lint` already do. + + The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, and `objectstack validate` parsed the normalized stack without lowering — so on that command the whole family returned before reading anything, and a stack `objectstack build` refuses with `hook-api-update-readonly-field` (exit 1) passed `objectstack validate` with exit 0 and no finding. The same statement authored as an explicit `body: { language: 'js', source }` was refused by `objectstack validate` all along, so the silence was the command's intake, not the rule. + + `objectstack validate` now runs the same `lowerCallables` pass `objectstack build` runs before its parse — after its two pre-parse undeclared-key lints, which keep reading the un-lowered stack, and before the schema parse, which reads the lowered view — and hands the rule registry the parsed result as before. This moves what `objectstack validate` accepts in **both** directions, and both are parity with `objectstack build`: + + - **Narrowing (hooks).** A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack validate` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` and (since #16095) `objectstack lint` with the same finding, so nothing that builds green starts failing `objectstack validate`. + - **Widening (actions, and a nameless `functions` array entry).** A plain-object config carrying an inline action `target` callable — `actions: [{ name, label, target: async (ctx) => { … } }]`, or the same on `objects[*].actions[*]` — was **refused** by `objectstack validate` before this change: `ActionSchema.target` is a string, and nothing lowered the function before the parse, so the run exited 1 with `invalid_type` at `actions.0.target` (measured through the real CLI: `valid=false errors=2 invalid_type@objects.0.actions.0.target | invalid_type@actions.0.target`). The same pass now lowers it to a ref string plus `body` on this command too, so `objectstack validate` **accepts** it (exit 0, `valid: true`) — exactly as `objectstack build` accepted it all along (exit 0 on both sides). Likewise a nameless `functions` **array** entry, `functions: [{ handler: async (ctx) => { … } }]`, which the same pass names `anon_fn`: the array form requires `name`, so `objectstack validate` refused it at the parse (measured: `valid=false errors=1 invalid_union@functions`, exit 1) and now accepts it (exit 0, `valid: true`), as `objectstack build` did (exit 0 on both sides). The `functions` map forms and `hooks[*].handler` parse either way and are not affected. These are accepted-set relaxations on a published command; they are declared here rather than inferred from the build's behaviour, and pinned beside the hook legs. + - The warning-severity members of the family now report on inline handlers under `objectstack validate` too; they fail a run only with `--strict`, as every other advisory does. + - The `--json` payload gains no key and the text face prints no new step: the lowering is a view for the parse and the rule registry. A handler the extractor cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by `objectstack lint`'s `hook-body/*` rules and `objectstack build`'s warn-and-bundle line, never guessed at here. + - Nothing about what `objectstack build` accepts changes; on both axes above `objectstack validate` now agrees with it. + + Measured on this repository's ten `objectstack.config.ts` corpus files at `6ba0db4e0` with `objectstack validate --json`, before and after: **exit code, error text and rule-id list identical on 10 of 10 — zero findings change, zero verdicts change.** Six reach the rule registry (the four example apps and the `plugin-auth` / `plugin-security` / `service-i18n` configs); two (`driver-memory`, `plugin-hono-server`) are plugin manifests, not stacks, and are refused at the schema parse — after the lowering point — with the same top-level `unrecognized_keys` on both sides; two (`app-showcase`, the `blank` template) fail at load in the measuring environment, before the lowering point, on both sides. None of the repository's handler-authored hooks writes through `ctx.api`, and none of the ten carries an inline action `target` callable, which is why the delta is zero on both axes rather than either being unreached — a corpus with neither shape cannot see either limb, so both are pinned on their own fixtures; the reach itself is pinned by the card's own fixture, with the body-authored control beside it and a handler-authored hook the family has nothing to say about still passing. + + `@objectstack/lint` carries only the header ledger recording which intakes reach each hook rule; `objectstack validate` moves from "not reached" to "reached". Its behaviour is unchanged. +- cd55558: `widget-measures-missing` — the empty-measure selection is reported on every widget family, not just charts + + `chart-measures-missing` (#15462) reported the authoring placeholder only for the chart + family, but the return that produces it is type-independent. At the `@object-ui` revision + this repo pins (`.objectui-sha` = `a472b0716`), `packages/plugin-dashboard/src/DatasetWidget.tsx:683` + reads `if (values.length === 0)` and returns *"Pick measures (values) for this dataset + widget."* ABOVE `isMetric` (`:423`, over `METRIC_TYPES` at `:343`), `isTable` (`:424`) and + the chart branch alike. So a `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`, `table` or + `pivot` widget that selects no measures renders the same placeholder — the KPI number or + the table the author declared is not drawn at all — and nothing reported it: + `table-count-only` requires `values.length > 0` before it looks, and the rules that iterate + `dimensions[]`/`values[]` are silent on an empty array by construction. + + - **New id `widget-measures-missing`** — a NON-chart declared widget type selects no + measures. Warning tier, suppressible per widget with + `suppressWarnings: ['widget-measures-missing']`, exactly as the chart-family id is. The + message states the consequence its family actually has (the single KPI number is not + drawn / no table is rendered) and the hint names the dataset's declared measures. + - **`chart-measures-missing` is unchanged** — same id, same chart-family population, same + message and same suppression. The condition split rather than widened because "chart" + stops naming it once the population is every family, while the old id is reachable from + the package barrel (a public-surface contract) and may already be written into a board's + `suppressWarnings`. + - `chart-dimensions-missing` stays chart-family only: a dimensionless `metric` or `table` + is what those families are for. + + The two never double-report one widget, in the pin's own order: the measures check runs + before the dimensions one, and `table-count-only` already skips an empty selection. + +### Patch Changes + +- 347b777: `chart-config-missing` no longer fires on a widget whose binding the renderer derives + + The rule warned on every chart-family widget that declared no `chartConfig`, on the + stated grounds that "the renderer cannot determine which measure to plot, so the series + renders empty". Measured against the `@object-ui` revision this repo pins + (`.objectui-sha`), that consequence is false: `DatasetWidget` derives the x-axis key and + one series per measure from the widget's own `dimensions` / `values` via + `buildChartSeries`, and refuses an authored `ChartAxis.field` / `ChartSeries.name` + outright — `chartConfig` carries presentation only. The renderer pins this by name: + "ignores an authored axis `field` and keeps the derived axis binding", "ignores an + authored series and keeps one derived series per measure", "emits none of the + presentation keys when no chartConfig is declared". + + The false finding was landing on this platform's own shipped metadata — the + `system_overview` dashboard's pie and bar tiles, on the Setup board every customer opens + first — which is the ADR-0072 D1 cost the rule family exists to avoid. + + The rule id is unchanged and keeps one true arm: a `combo` widget with no `chartConfig`, + whose per-series mark is authored as `chartConfig.series[].type` and has no other + channel, so every measure draws with the same default mark and the chart is not a + combination at all. Its message now names that consequence instead of the binding. + An existing `suppressWarnings: ['chart-config-missing']` entry stays valid. +- a51eb86: `chart-measure-unknown` no longer blocks a build over a chart `series[].name` (or a page chart's `yAxis[].field`) that names nothing — those positions are presentation, and the message now says so. + + The rule fired at `error` on every measure position of the three chart surfaces it covers, with one consequence sentence: *"result rows are keyed by MEASURE NAME … so this series comes back empty"*. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), that is true only where the position feeds the dataset query, and the three surfaces do not agree: + + - **Report charts** run the chart's own query out of the two axis strings (`useDatasetRows(dataset, [xAxis], [yAxis], …)` — *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*), so `chart.xAxis`/`chart.yAxis` are the binding. `chart.series[]` is *"the author's per-chart override for ONE measure's display name"*, lowered through `mergeAuthoredSeries`, where *"an authored entry naming a measure that is NOT in the dataset selection is **ignored** — membership belongs to the dataset"*. + - **List-view charts** have no presentation position at all: `ListChartConfigSchema` is a strict object of `chartType`/`dataset`/`dimensions`/`values`, and `values[]` is handed to the chart as the dataset measures. + - **Dataset-bound page chart components** query `{ dimensions, measures: values }` and then replace the authored series wholesale with one derived entry per selected measure, so `properties.series[].name` reaches the renderer not at all and `properties.yAxis[].field` re-points nothing. + + **Behaviour change users see:** the three presentation positions — report `chart.series[].name`, page-component `properties.series[].name` and `properties.yAxis[].field` — drop from `error` to `warning`. A build or a metadata publish that used to be refused because of one of them now succeeds, with the finding on the advisory channel. The finding is KEPT, not deleted: the metadata really is wrong — the author wrote a key and believes it is in force. Every query position (report `chart.yAxis`, and `values[]` on all three surfaces) keeps `error` and its existing message verbatim. + + Two smaller corrections ride along, both from the same read: + + - The page surface's `yAxis[].field` refs are no longer concatenated into the `series[]` limb before the measure walk, so an axis position no longer takes the series message. Reading both shapes on that surface stays deliberate; giving them one sentence was not. + - `chart-axis-not-selected` (a declared measure outside the selection) took the same one-size consequence, *"the query does not return it, so the series plots nothing"*. It keeps that wording at a query position and states the real one at a presentation position, where no series is derived for the name in the first place. + + Note that none of these three surfaces declares `suppressWarnings` — it is a dashboard-widget key — so the new advisories cannot be individually silenced; the hint says so instead of pointing at a key that does not exist. +- 693fbcb: `dashboard-action-route-unresolved` now resolves the `apps/NAME` head of a dashboard header action's `url` target against `stack.apps`, and reports every unresolved `/` segment in the path rather than stopping at the first one it recognizes. + + Before this, `URL_COLLECTION_TO_STACK_KEY` had no `apps` entry, so an `actionUrl` like `/apps/no_such_app_nope/crm_lead` was never checked at all — a dashboard button pointing at an app that does not exist passed lint clean. Worse, once a bad app name was combined with a second bad segment later in the same path (e.g. `/apps/no_such_app_nope/dashboard/no_such_dashboard_nope`), the old loop returned at the FIRST recognized segment and reported only that one — so a bad app name plus a bad dashboard name reported only the dashboard, never the app. + + **Behavior change on paths that used to pass clean:** the loop no longer stops scanning a path the moment it recognizes one collection segment, resolved or not. A path like `/dashboards/exec/views/bad_view` — where `exec` is a real dashboard but `bad_view` names no view — used to report nothing (the loop returned as soon as `dashboards/exec` resolved, never reaching `views/bad_view`); it now reports one warning on the `views/bad_view` segment. Any stack with a dashboard `url` action whose path recognizes a valid collection segment followed later by an unresolved one will see a NEW warning here that did not fire before. This is intentional — it is the same false-affordance category the rule already exists to catch — but it is a real, visible change to what a clean `lint` run reports on such stacks, not a pure addition. +- 86c75f4: `firstUndeclaredReference` now documents the side of its contract it was silent about: it can false-NEGATIVE, and a `null` is "nothing was reported", not "every reference is rooted". + + The existing sentence — "Acts ONLY on cel-js's `Unknown variable: X` fault, so it cannot false-positive on arithmetic/comparison overloads" — is true, and stays. What it never said is what that narrowing costs. cel-js's checker returns exactly ONE error, so when the first one is of another class every undeclared reference behind it in the same source goes unjudged and the helper answers `null` — the same value that means the source is clean. A contract that declares only which error it cannot make reads as making neither. + + No behaviour changes. This is the contract text, and it ships: the amended block is JSDoc on a published export, so it is emitted into `@objectstack/formula`'s `dist/index.d.ts` and `dist/index.d.mts` (measured — the declaration file grew 53.45 KB to 55.99 KB) and is what a consumer reads on hover. + + What the amendment adds, all of it measured rather than reasoned: + + - **The masking is positional, not name-keyed.** The masked name is not the one that triggered the first error, so excluding the trigger's own name does not reach it. `data == 'x' && status == 'q'` answers `null`; the same two names in the other order answer `"status"`. + - **`celEngine.compile()` is not a gate against it.** `compile` type-checks in the permissive environment, where every unlisted name is `dyn`. The strict environment here declares `SCOPE_ROOTS` as `map`, so a root — or an object field sharing one of those names (`data`, `config`, `result`, `item`, `event`, `input`, `user`, …) — used as the operand of an operator with no `map` overload faults HERE and nowhere else. A caller that only reaches the helper on a clean compile is therefore not protected by its own gate. + - **The CEL type-name class is the same shape.** `type == 'grid'` is already pinned as a blind spot in `@objectstack/lint`'s `visibility-bare-identifier` suite, but pinned per NAME; the masking it causes is source-wide. + - **What closing it would take, and why that is not this change.** Widening the regex onto the overload message is the false positive the narrowing buys off (`type(record.x) == string` is legitimate CEL). Reporting past the first error needs a re-check loop over a neutralised source, or a checker entry returning more than one error — cel-js 8.0.0 has neither; its `TypeCheckResult` carries a single `error`. Both change what every consuming rule reports, so the oracle's shape is a design decision. + + `@objectstack/lint` carries a second comment-only correction, to `flow-variable-scope`'s account of the same oracle. Its "known, deliberate blind spot" note bounded the under-report to a flow variable named after a `SCOPE_ROOTS` member; measured, the bound does not hold — such a name in an operand position terminates the discovery loop on iteration 0 and every shadow in that source is lost, whatever it is named. That block sits on an internal function, so unlike the `formula` half it reaches no published declaration file; the entry is here because the package is touched and published. +- b371960: `flow-decision-unconditional-branch` now reports the decision that gates on nothing — the shape the rule used to skip. + + A `decision` whose out-edges carry no `condition` and no `isDefault`, and whose node declares no `config.conditions[]`, selects no branch at all: the automation engine's own decision executor reports no branch when `conditions[]` is empty, so traversal considers every out-edge and each successor runs on every pass. The gateway is decoration. The rule could not see that shape, because it was framed as "an unconditional edge undercuts a guarded one" and read zero guarded edges as nothing to undercut — so the strictly worse gateway was the one case that stayed silent, and it is the harder one to notice in review, because the node still says `type: 'decision'`. + + Same rule id, same `warning` tier, with its own message: it names the out-edges that run unconditionally and offers the three fixes (a `condition` per branch plus `isDefault: true` on the fallback, a `config.conditions[]` whose `label` matches an out-edge, or dropping `type: 'decision'` for the node the gateway already behaves as). The mixed shape — one guarded out-edge beside an unconditional one — keeps its existing wording and its single finding. + + Decisions that do declare their routing stay silent, including the two that are easiest to catch by mistake: an ordinary gateway with guarded edges, and a decision that routes by `config.conditions[]` labels alone with bare out-edges. A decision declaring a label no out-edge claims remains the gating `flow-branch-label-unmatched` on its own, with no second finding piled on the same node. +- d91dff4: `validateStackExpressions` no longer throws on a non-record entry of a flow's `nodes` list. + + An empty item in a YAML `nodes:` list deserialises to `null`, so this is an authorable shape — the same one #15552, #15636 and #15742 closed for stack collections and for `objects[].fields`. Here it crashed the linter instead of producing a finding: `flow.nodes: [null, ...]` threw `Cannot read properties of null (reading 'type')`, which presents to an author as a broken tool rather than as a problem with their metadata. + + Both of the file's inline casts now read through `recordsOf`, the one home of this coercion, instead of asserting that `Array.isArray` proves anything about a list's MEMBERS: + + - The flow walk reads `flow.nodes` through `recordsOf`, and — the half that actually removes the crash — hands that coerced array to `collectFlowGraphs` rather than the raw flow. `collectFlowGraphs` declares its input as already-parsed `FlowNodeParsed[]` and is transparent about members, so passing raw authored metadata was calling it out of contract; coercing only the local variable relocated the throw into `@objectstack/spec` instead of ending it. The producer's contract is unchanged, deliberately: widening it to tolerate malformed members is the wrong direction. + - The per-graph walk reads `graph.nodes` through `recordsOf` in place of an `as unknown as` double cast. A nested region's node list is only `Array.isArray`-checked before it becomes a graph, so that list carries the producer's word about its members and not a check. + + A non-record member is dropped whole and in silence, exactly as the file's sibling field readers already did; a flow standing beside the junk entry is still judged, and a `nodes` list holding a plain string still reports exactly what it reported before. +- dff0bdd: `flow-template-unknown-field` and `flow-template-lookup-traversal` now reach a `{record.}` template that sits outside a node filter — the `warning` half both rules already declared, and never emitted. + + A `{record.}` token in a filter has always been reported as an `error`: an unresolved token there erases the condition and the CRUD node refuses to run. A token anywhere else — a message body, an http request payload, a created row's field values — is the quiet failure the rules were written for: it renders as an empty string on every run, and nothing reports it at either end — no build-time finding, no run-time error — so a hand-off payload naming a renamed field ships an empty value and the run is recorded as a success. That half was silent. + + The cause was one key, in the shared flow walk rather than in either rule. A rule that scans a node's config recursively has to read a view of it with the nested regions removed, or it reports every finding inside a `loop` / `try_catch` / `parallel` a second time against the container. That view was built by removing every key that holds a region on *any* node type — and `body` is `loop`'s region slot **and** the canonical request-payload key on an `http` node. So `config.body` was deleted from every node's view before any rule read it, and the whole of an http payload was invisible. The view now removes only the slots the node's own type declares, which is exactly the set the walk descended into: nothing is double-reported, and nothing that was never a region is dropped. + + Expect new `warning` findings on flows that publish clean today. Each one names a token that renders empty at run time; `warning` does not change `os validate`'s exit code, so a build that passed still passes. +- 7a01847: Fix: a name-keyed `pages:` map no longer passes every source-page lint vacuously. + + `pages` has two authoring carriers — a list, or a map keyed by page name that + `normalizeStackInput` folds into a list before the schema sees it. Four rules + (`validate-jsx-pages`, `validate-page-source-styling`, + `validate-react-page-props`, `validate-react-pages`) read the collection through + a private coercion that answered a map with an empty list, and they run on the + raw `os lint` path where nothing has normalized it yet. On a map-shaped stack + all four therefore returned no findings by never walking a single page: an + empty source, a syntax error, an unparseable component and a Tailwind + `className` were all reported as clean. They now read `collectionEntries`, + which handles both carriers, and a finding on the map carrier is located by the + author's own key (`pages.home.source`) rather than a synthetic index. + + The same change removes the last sixteen private copies of the collection + coercion in this package. Twelve rules — the `function` form, which had already + grown the non-record filter locally in two different spellings — now read + `recordsOf` from `object-graph.ts`. Two behaviour changes fall out, both on + input that was already malformed: an array-typed member of `agents:` / + `skills:` / `tools:` used to survive the looser local filter and draw one + reference-integrity finding at a position nobody authored, and is now dropped; + a member of a name-keyed `validations:` map whose value is not a record is now + carried as `{ name }` rather than discarded, which reaches no check that reads + it. No rule id, message or severity changes, and every finding path on the list + carrier is unchanged. +- 36a6082: `flow-double-brace-interpolation` and `flow-bare-dollar-reference` now read an `http` node's request payload. Both rules were blind to the whole of `config.body` on every node type — the one key where an uninterpolated token has an outbound consequence. + + The recursive template scan in `lint-flow-patterns.ts` read a region-stripped view of each node's config, and it built that view from the FLAT UNION of every config key that holds a region on *any* node type (`body`, `try`, `catch`, `branches`) rather than from the slots the node in hand actually owns. `body` is `loop`'s region slot **and** the canonical request-payload key on an `http` node, so `config.body` was deleted from every node's view before the scan ever read it. + + That made the two rules silent exactly where they matter most: `http-nodes.ts` interpolates the raw config wholesale, so a double-brace `{{ record.title }}` or a bare `$source.id` written in a payload is never interpolated and ships to the endpoint as literal text. Measured before this change, an `http` node whose `body` carried either token shape — at the top level or nested inside a `try_catch` region — produced zero findings from either rule. + + - **The call site passes its own slots.** `stripRegions(node.config, ownRegionKeys(node.type))`. The remedy was already written in `stripRegions`' own docblock ("Pass the OWNING node's slots, not the flat union") and the sibling call site in `flow-walk.ts` already followed it; this one did not. + - **The trapping default is gone.** `stripRegions`' `regionKeys` parameter is now REQUIRED. The flat union survived as a default only to bound an earlier change, and the cost of leaving it was this defect: the shorter call compiled and quietly asked a different question. A caller that has not decided which set it means now fails to compile instead. + - **The double-count direction is unchanged and pinned.** A token inside a `loop` body is still reported exactly ONCE, against the node that carries it and not also against the container — the reason the strip exists, and the direction that breaks if a repair over-corrects to stripping nothing. + + Both rules keep their existing severity. New findings appear only where a `{{ }}` or bare `$ref.field` sits in a previously-hidden key; measured across `examples/app-showcase`, `app-crm` and `app-todo` (34 flows, `http` payloads inside a `parallel` branch and a `try_catch` try among them), the count is unchanged at zero — those payloads use correct single-brace tokens. +- 7ad2ca0: `validateFormLayout` now resolves the bound object for a view container's default `form` (and its `formViews.*` entries that declare no binding of their own) when that container names its object only on the `list` block (`list.data.object`, `list.object` or `list.objectName`) and nowhere on the container itself. + + Before this fix, `containerObject` had no way to see a list-only binding, so `objName` stayed `undefined` for every site under such a container — and `form-field-unknown` / `form-section-group-unknown` never fired there, however wrong the section content was. This is the same fallback rung `validate-translatable-sections.ts` already carries for its own sites; it is now shared by both. `absolute-colspan-discouraged` is unaffected by this change — it was never gated on the object binding (it needs only a field's `colSpan`), so it already fired on a list-bound container's form sections before this fix. + + Consequence: a view whose object binding lives only on `list` and whose default `form` (or an unbound `formViews.*` entry) references a nonexistent field or an undeclared `section.group` now gets a `warning` finding it did not get before. A stack with no such dangling reference sees no new output. +- 7dafaae: No authoring rule throws on a non-record entry of any stack collection. + + A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape. + + Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before. + + The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked. +- 52b59d6: fix(lint): every `stack.objects` reader skips a non-record entry, so no authoring rule throws on the publish door + + A `null` member of `stack.objects` — what an empty YAML list item + deserialises to, and what a partial editor write leaves behind — crashed + 13 of the 42 `AUTHORING_RULES` with + `TypeError: Cannot read properties of null (reading 'name')`. The + authoring rules are pure `(stack) => Finding[]` (ADR-0019) and run on the + RAW `lint` path as well as the parsed one, so nothing upstream had judged + the entry's shape. At the runtime publish gate they are called inside the + gate rather than behind a try/catch of their own, so the throw was an + exception on a WRITE path, not a skipped finding; on the CLI, `os lint` / + `os validate` / `os compile` died on the first one instead of reporting + the stack. + + The repair before this one guarded ONE seam — the object-graph index every + field-path rule opens with. The crash stood at fourteen more readers of + the same collection, each a hand-copied `asArray` whose array branch was + an unchecked `v as AnyRec[]`. Copies are why: the defensive spelling was + already present in about a dozen siblings and absent in the rest, so + fixing one left the others answering the old way. + + So the copies are gone. `recordsOf` — the guarded reader, exported from + `object-graph.ts` and package-private — is now the one coercion from a + collection authored as an array OR as a name-keyed map into the records it + holds, and fifteen files call it: + + - `validate-expressions.ts`, `validate-list-view-mode.ts`, + `validate-widget-bindings.ts`, `filter-walk.ts`, + `validate-object-references.ts`, `validate-record-title.ts`, + `validate-form-layout.ts`, `lint-autonumber-formats.ts`, + `lint-view-refs.ts`, `validate-org-axis-red-lines.ts`, + `validate-sharing-rule-enforceability.ts` — the eleven sites that threw. + - `validate-searchable-fields.ts`'s `indexObjectSearchTargets` and + `validate-page-field-bindings.ts`'s `indexObjectFields` — two shared + indexers inside the reference-integrity suite, each in front of two + rules and both hidden behind whichever suite member threw first. + - `object-field-groups.ts`'s `indexObjectFieldGroups`, which the + re-measure surfaced only once the eleven above stopped throwing. + - `validate-security-posture.ts`, the one that never threw: an `[]` + member passed its `typeof v === 'object'` read and drew a second + `security-owd-unset` at `object "(object 0)"` — an `error` about an + entry no author wrote. + + The verdict is a SKIP, not a finding, matching the seam it extends: a junk + `objects` member is a SHAPE defect and belongs to the schema, every rule + already re-answers the question in its own per-object guard, and reporting + it at the reader would emit one finding per member for one bad entry. On + the name-keyed map shape a member whose VALUE is unreadable keeps its key + (`{ name }`) — the author named it, only its body is illegible. + + No rule tier, id, message or accept-set changes. A valid object standing + beside a junk one is judged exactly as it is judged alone; only a path + index moves, and only for the rules that index `objects` raw, where + `objects[1]` is the honest position. +- 434ca2d: `validateStackExpressions` no longer throws on a non-record entry in an object's `fields:` list. + + An empty item in a YAML `fields:` list deserialises to `null`, and `buildFieldIndex` cast each member of the list inline (`fields.map(f => (f as AnyRec).name)`) before the `.filter` two calls later could drop it. `Array.isArray` proves the LIST, never its MEMBERS, so linting such a stack failed with `TypeError: Cannot read properties of null (reading 'name')` out of the whole rule instead of reporting anything about the file. + + The list is now read through `recordsOf` — the one place that coercion is decided — which drops a non-record member of the array shape whole and in **silence**: it carries no author-written name, so there is nothing to report about it. That matches what the two sibling field readers in the same module (`buildFieldTypeIndex`, `fieldEntries`) already did with the same member, so the three readers now agree. The readable siblings of the junk member are still indexed, so unknown-field findings on that object continue to be reported. + + The map shape (`fields: { amount: { … } }`) is unchanged: there the author's key is the field name, which is what this index needs. +- 25a3d91: Stop reporting a declarative `operation: 'update'` action as "a button wired to nothing" + + The boot action-governance inventory (ADR-0110 D5) built its `unboundDeclarations` + finding from a `type`-only test. The declarative single-record field write + (`operation: 'update'` + `patch`, #14092) is exactly the shape that test mistakes + for a dead button: `ActionSchema` refuses `target` and `body` beside it and keeps + `type` at its default `script`, because the platform action route is where the + write is performed. Every such action was named at every boot and every + `metadata:reloaded` — with a prescription ("add a `body`, or register a handler + under the declared `target`") that parse itself refuses. + + Both readers now read `operation` before `type`, the precedence the runtime doors + already use: the engine inventory, and the authoring-time AI tool-reference rule, + which had diverged from the runtime's listing door and reported a resolvable + `action_` reference as fictional. +- ce21963: `nav-object-ungranted`'s hint no longer tells you to gate the nav entry with `requiredPermissions`/`visible` — that never cleared the finding, because the rule never reads either key. Gating restricts who can see the entry; it doesn't grant the object read, so a holder who clears the gate could still hit permission-denied, and the warning kept firing anyway. The hint (and the module doc-block) now name the two remedies that actually clear it: grant read on the object in a permission set (`allowRead: true` or `viewAllRecords`), or drop the nav entry. No behavior change — the rule fires and stays silent on exactly the same inputs as before; only the wording of the hint moved. +- ba426b0: A junk entry in `stack.objects` no longer crashes the reference-integrity rules, and a probe rule that throws is reported instead of read as "nothing wrong". + + `indexObjectGraph` is the first statement of every rule that resolves a field path, and it read each `stack.objects` member without checking it was a record — so a `null` entry (an empty YAML list item, a partial editor write) threw `TypeError: Cannot read properties of null (reading 'name')` before any rule's own per-object guard could run. Because these rules also run inside the runtime publish gate, that was an exception on a write path rather than a missed finding. The seam now drops non-record entries — silently, matching every sibling collection reader in the package — and the valid objects beside them are judged exactly as before. + + On the publish receipt, `runBuildProbes`' object plane wrapped its rule call in a catch that produced an empty finding list, so a crashed rule was indistinguishable from a clean object while `checked.objects` had already counted it. A rule that throws now surfaces as a `runtime`-layer `object_field_ref_rule_failed` error carrying the thrown message, so an unverified object never reads as a verified one. Probes still never fail the publish they verify. +- ed6579b: `objectstack build` now refuses to lower a hook/action body that calls `.create(`, and the shared write-pattern ledger stops advertising the verb. Three layers used to disagree about `ctx.api.object('x').create({ … })`, and the loudest one was wrong. + + - The spec contract `IScopedObjectRepository` (`packages/spec/src/contracts/scoped-context.ts`) declares `insert` and names `create` as measured-and-deliberately-excluded. + - The QuickJS sandbox installs exactly `insert / update / delete / updateMany / deleteMany / upsert` as the `ctx.api.object()` write leaves — no `create`. An L2 body calling `.create()` therefore threw `TypeError: not a function` on its **first run**, and under a hook's default `onError: 'abort'` that throw aborted the triggering write, with a message naming no member. + - The extractor ledger nonetheless advertised `.create({…})` as legal `api-crud-literal` syntax and mapped it in `API_WRITE_METHODS`, so `hook-body-write-unknown-field` graded the payload as a live write and stayed silent when the field existed — a clean bill of health for a call that cannot run. Build time said nothing at all. + + What changes: + + - **`@objectstack/cli`** — `.create(` joins `FORBIDDEN_PATTERNS` in the hook/action body extractor, beside `.sudo(` and for the same reason (a member real on the in-process `ScopedContext` / `ObjectRepository` and absent from the VM). The refusal names `.insert({ ... })` as the spelling the sandbox actually has. Behaviour is the `forbidden-token` fallback every other entry has: the callable is still registered and still shipped through the back-compat `.mjs` bundle, so a handler keeps running in-process where the host `create()` alias exists — `objectstack build` merely declines to *also* emit it as a body that cannot run. Under `--strict-body` it is a hard failure, correctly. The rule is receiver-loose like `.sudo(` (`const repo = ctx.api.object('x'); repo.create(…)` is refused too) with one carve-out: `Object.create()` is a real sandbox global and is **not** affected. + - **`@objectstack/lint`** — `create` is withdrawn from `HOOK_BODY_WRITE_PATTERNS`' advertised `api-crud-literal` syntax and from `API_WRITE_METHODS`, on the hook and action surfaces alike. `hook-body-write-unknown-field` / `action-body-write-unknown-field` no longer grade a `.create()` payload; `hook-api-update-readonly-field` keeps its existing `create` exclusion, whose *reason* is updated — it is no longer "the call throws, so a silently-dropped finding would be false" but "the shape can no longer reach this rule at all". + + **Migration.** If a hook or action body calls `ctx.api.object('x').create({ … })`, spell it `ctx.api.object('x').insert({ … })` — the same host method, the one the sandbox installs and the only insert verb the contract declares. The host-side `ObjectRepository.create()` alias is untouched and stays reachable from in-process handlers and actions. +- 89758ac: `chart-axis-not-selected` resolves a report chart against its own `chart.yAxis`, not `report.values` (#15734) + + **Behaviour change — one false finding removed on the report surface.** A report chart whose `chart.yAxis` names a declared measure that `report.values` does not select no longer raises a `chart-axis-not-selected` warning. Nothing else about the rule moves, and no other surface moves at all. + + The warning stated a query consequence the renderer refutes. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), `plugin-report/src/DatasetReportRenderer.tsx` does not query `report.values` for the chart at all — it runs the chart's own, narrower query out of the two axis strings: + + ``` + const state = useDatasetRows( + dataset, + plan.kind === 'series' && xAxis ? [xAxis] : [], + wantsQuery && yAxis ? [yAxis] : [], + ``` + + and says so in that file's own words at the `scopeOrder` docblock: *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*. So the measure the warning said "the query does not return" is exactly the one the query asks for, and the chart plots it. `report.values` is the selection of the TABLE beneath the chart. + + Both limbs follow from that one measurement: + + - **No not-selected check at the report `chart.yAxis`.** That position IS the chart's query, so it cannot fail to select itself. `chart-measure-unknown` there is untouched: an UNDECLARED measure is still no column at all, and still an `error`. + - **`chart.series[].name` resolves against the singleton `{ chart.yAxis }`.** The entry is a display-name override paired with a DERIVED series, and the chart derives exactly one (`buildChartSeries(…, [xAxis], [yAxis], …)`). An entry naming `chart.yAxis` now lands however the table is selected, and one naming any other declared measure is still reported — including a measure `report.values` does select, which it could not reach before. + + The list-view and page-component surfaces are unchanged, and carry firing controls that say so: on both, `values` IS the measure set the query asks for (`ObjectView` hands it to the chart; `ObjectChart` queries `{ dimensions: schema.dimensions, measures: schema.values }`), so the existing resolution is the right one there. + + The per-position tier and consequence wording is untouched — only the SET the report surface resolves against moves. +- d83d079: A report's `dataset`, `rows`, `columns` and `values` are checked whether or not the report draws a chart (#16105) + + **Behaviour change — new findings on reports that used to publish clean.** `validateChartBindings` reached a report through one closure that opened `if (!isRec(chart)) return`, and that closure was the only place a report's `dataset` was ever passed to the resolver. Two gaps followed, and both are closed: + + - **A report authored without a `chart` was not checked at all.** Bind it to a dataset that does not exist and `os lint` exited 0 and named nothing. It now reports `chart-dataset-unknown` at `error`, the same as a charted report always did. + - **`rows` and `columns` were resolved on no report, charted or not.** On one and the same report object the measure selection (`values`) was resolved against the dataset and the dimension selection beside it was not. Both now report `chart-dimension-unknown` at `error` for a name the bound dataset does not declare as a dimension, at `reports[i].rows[j]` / `reports[i].columns[j]`. A chartless report's `values` is resolved for the first time too, under the existing `chart-measure-unknown`. + + `ReportSchema` is what makes these bindings rather than free text: it requires `dataset` + `values` on every non-`joined` report, and declares `rows` (the down axis) and `columns` (the across axis a `matrix` pivots on, ADR-0021 D2) as dimension names taken from that dataset. The chart is optional decoration on top of a binding the report already has. So a report bound to a missing dataset, or grouping on a dimension its dataset does not declare, now fails authoring instead of rendering blank or mis-grouped in production. + + No new rule id, no severity moved, and the charted path is unchanged — `chart-axis-not-selected` stays a `warning` and still resolves against the chart's own `chart.yAxis`. Two smaller corrections come with the restructure, both on messages an author reads: + + - The dataset finding on a report now points at `reports[i].dataset`, the key the author wrote. It used to say `reports[i].chart.dataset`, a position a report does not have. + - Its sentence ends "there is no data to render" rather than "the chart has no data to render", which is not true of a report that draws no chart. + + Blocks of a `joined` report carry the same keys and take the same checks. An unresolvable dataset is still exactly one finding per report or block. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- 60ff091: `visibility-bare-identifier` now reports an identifier written bare beside a `has()` guard in the same visibility predicate. + + `has(status) && status == "qualified"` published clean while `status == "qualified"` — the same defect, without the guard — gated at `error`. The guarded spelling is the one the totality discipline pushes authors toward, so an author who correctly adds `has()` and forgets the `record.` prefix on both halves landed in the silent row. That predicate never evaluates for any record, and an unevaluable `visibleWhen` on a form surface fails OPEN: the field renders and carries its `required: true` into the console's submit check. + + The cause was not the exclusion a `has()` argument earns — that is correct and stays. `firstUndeclaredReference` reads the first error the CEL checker reports and acts only on `Unknown variable: X`; a bare `has(x)` fails that check with `has() invalid argument` instead, and a first error of a different class masked every undeclared reference behind it in the same predicate, whatever it was called. Each `has(…)` call is now masked out of the source before the checker sees it, using the canonical AST's own spans, so the argument occurrence is excluded and every other occurrence is judged exactly as it would be with no guard written beside it. + + Expect new `error` findings on predicates that used to publish clean: a guarded-but-unprefixed `visibleWhen` on a view, page component or form section is now refused at build, validate and lint alike. That is the fail-open shape the rule exists to catch. A `has()` argument that is the only bare occurrence — `has(status)` on its own — stays silent, as it did before. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/sdui-parser@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index 8d3e50e83a..5ebd2c873b 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.3.0", + "version": "17.4.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 52b443d228..a6f6bfa4a9 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,160 @@ # @objectstack/plugin-mcp-server +## 17.4.0 + +### Patch Changes + +- 17f8604: The MCP stdio transport now vets an API key's organization against the deployment's tenancy posture, instead of trusting the key's own stored claim. + + `resolveStdioExecutionContext` — the whole of this transport's authorization, since every caller on it is an API key by construction and there is no session path — built its own header map and called `resolveAuthzContext` with no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one (`organization_required` at admission, `organization_membership_ended` after grants), so a door that supplied none ran neither: the key's `sys_api_key.active_organization_id`, never re-checked against current membership, became the request's tenant. Under a wall-enforcing posture a key stamped with an organization its owner had left read and wrote that organization's rows through this door. + + The posture is now derived in the plugin's `start()`, where the kernel is reachable, and threaded into the resolver. What changes for a deployment: + + - Under `isolated` or `group`, a stdio transport configured with a key whose owner is no longer a member of the organization the key names refuses to start, and a key already live is refused on its next call. Under `isolated`, an organization-less key is refused the same way. Both refusals are logged server-side naming the key, principal, organization and reason; nothing about them reaches the caller. + - A kernel that registers no `tenancy` service is unaffected: no organization wall exists there, so no posture-conditional refusal is made. That is the supported composition, not a degraded one. + - A `tenancy` service that is registered and **fails to build** now raises `SERVICE_UNAVAILABLE` (503) rather than reading as "no posture". A posture that could not be read is not a posture that is absent, and admitting on one is the permissive-on-failure shape this repair exists to avoid. + + The posture is re-read per call, on the same schedule as the identity beside it (ADR-0101 D1), so a wall that comes up or a membership that ends mid-session takes effect on the next call rather than at the next restart. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 467ba21acf..16ef085ca9 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.3.0", + "version": "17.4.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 62d731785b..14f13c077b 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,137 @@ # @objectstack/metadata-core +## 17.4.0 + +### Patch Changes + +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index bb81c19da6..c669e6d0c1 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.3.0", + "version": "17.4.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 5e51f281c0..e19b8ec38c 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,19 @@ # @objectstack/metadata-fs +## 17.4.0 + +### Patch Changes + +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [c5d6803] + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 3c28f016cb..bda6ba4bb5 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.3.0", + "version": "17.4.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 41f636f8db..a0614c054d 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,482 @@ # @objectstack/metadata-protocol +## 17.4.0 + +### Minor Changes + +- 2ed6be6: Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. + + A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule. + + Two changes, and neither moves what a rule evaluates to: + + - **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`. + - **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects. + + `error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list. +- 65846bc: fix(metadata-protocol)!: a batch ROW reports a unique-constraint refusal as `UNIQUE_VIOLATION` — the same wire spelling as the whole-request failure on the same route (#14723) + + + + **BREAKING** on the per-row report of `POST /api/v1/data/:object/batch` (and + the multi-object `POST /api/v1/batch`, which rides the same protocol): a row + refused by the engine's `DuplicateRecordError` envelope now reports + `errors[].code: 'UNIQUE_VIOLATION'` where it reported `'DUPLICATE_RECORD'`. + Shipped as `minor` under the repo's launch-window convention for breaking + changes. Maintainer ruling 2026-09-03 on #14723 (verbatim 「同意,然后执行契约 + 复审」), adopting option A: one wire spelling for a unique-constraint refusal on + every route. + + **Why.** `toRowApiError` put a thrown REGISTERED code on the row verbatim, and + `DUPLICATE_RECORD` is registered, so a `DuplicateRecordError` row said + `DUPLICATE_RECORD` while the whole-request failure on the very same route (the + bulk door's classification in `@objectstack/rest`) answered `UNIQUE_VIOLATION` + — the standard-catalog member `content/docs/protocol/kernel/http-protocol.mdx` + documents for the 409 constraint-violation body. Since the bulk doors were + restored to `UNIQUE_VIOLATION`, the two spellings of one condition sat side by + side in one route's responses, which ADR-0112's one-name-per-concept and the + error-code ledger's own header both forbid. The duplication is removed, not + declared: no ledger waiver is added. + + **What changes.** The row derivation recognises the engine's envelope by the + same two-part gate the whole-request arm uses — the registered code AND the + class name `DuplicateRecordError`, never message text — and reports + `UNIQUE_VIOLATION`. Everything else on the row is unchanged: `httpStatus: 409`, + the platform sentence (no driver text, no bound value — the driver's error + stays on `cause` and never reaches the row), and the sibling `NOT_ATTEMPTED` / + `ROLLED_BACK` rows. + + **What does NOT change.** The engine's thrown identity: `DuplicateRecordError.code` + is still `DUPLICATE_RECORD` for an in-process caller of `engine.insert` / + `engine.update` (a hook, a flow node), and the objectql pins on `insert` / + `insertMany` hold. The single-record `/data` door, which has answered + `UNIQUE_VIOLATION` throughout, does not move. A producer that merely THROWS the + registered `DUPLICATE_RECORD` from its own body without being the engine's + class keeps its own code on the row, exactly as it does at the door. + + **Consumer note.** A batch client that branched on a row's `code` reading + `DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it + already handles for the whole-request 409 on that route and on the + single-record door. Measured in-repo and in the sibling repos (hotcrm, objectui, + non-test sources): zero consumers branch on either spelling of a row code. +- e08892d: `cloneData` reports `droppedFields` like every other create face: `CloneDataResponseSchema` (`@objectstack/spec/api`) gains an optional `droppedFields` member of the same shape as `CreateDataResponseSchema`'s, and the `POST /data/:object/:id/clone` 201 body carries it whenever the engine stripped a static `readonly` column from the clone. + + A clone IS a create, and it is the one create shape that can carry a read-only column without the caller typing it: the source row is copied whole (`approval_status: 'approved'` included), `overrides` are applied on top, and the copy is inserted. Since the create-side strip moved into `engine.insert` that column has been stripped and logged at `warn` — but the 201 body said nothing, so a caller that cloned an approved record and read `record.approval_status: 'draft'` back had no field in the response telling it why, while `createData`, `createManyData`, `insertManyData` and every `batchData` row that created already answered on the wire. Maintainer ruling 2026-09-08 (option 1 on #15703): report it, the same way. + + - **`@objectstack/spec`** — `CloneDataResponseSchema.droppedFields`: `DroppedFieldsEvent[]`, optional, omit-when-empty — present ONLY when ≥1 field was dropped, and the clone still succeeded without them (status unchanged). The schema is declared AS PRODUCED, so the member and the producer land in one change. Additive: a client that reads only `object` / `id` / `sourceId` / `record` sees no difference. + - **`@objectstack/metadata-protocol`** — `cloneData` passes the engine the same `onFieldsDropped` listener `createData` wires and spreads the collected events onto its return as `droppedFields`. The strip itself is unchanged and still the engine's (`isSystem`-gated, `defaultValue` re-derived); what is new is that a copied-in or overridden readonly key is now named in the body instead of only in the server log. + - **`@objectstack/client`** — `CloneDataResult` (the declared mirror of `CloneDataResponseSchema`, the return type of `client.data.clone`) gains the same optional `droppedFields?: DroppedFieldsEvent[]`, so a TypeScript caller reads the member without a cast; its docblock no longer states that the clone producer emits no write-observability event. + + Body only, deliberately: the clone route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header (the single-record `POST /data/:object` and `PATCH /data/:object/:id` mounts do); the schema's `.describe()` says so rather than promising a header the route does not send. +- 6491463: `/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. + + **A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. + + **The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. + + **Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". + + What to read instead, per case: + + - deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; + - asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. + + Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. + + New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. +- b4b37e5: The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have. + + `#15254` closed this one key over: it crossed the reference-integrity suite onto the object write door for the object's own field-name **lists** (`highlightFields`, `publicSharing.redactFields`). The two members that read the *other* field surfaces an object carries — its ADR-0061 search set and its built-in `listViews` — still declared `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived. An object could publish clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail the same silent way downstream: the engine filters a stale search entry out without a word (`resolveSearchFields`), so `$search` scans a narrower set than declared — or, once every entry is stale, the auto-default set the author never chose — and a dangling column renders one field short. + + - **`validateSearchableFields` and `validateListViewFieldRefs` gain `object`** in their suite-member `runtimeTypes`. No new rule and no new finding class: the rule ids (`searchable-field-unknown`, `searchable-field-unsearchable`, `list-view-field-unknown`, `list-view-field-dotted`) and their severities are unchanged — they now reach the door where the author actually is. + - **The crossing carries the #9313 precondition.** Both members resolve only against `stack.objects`, the one collection every per-write snapshot carries, so neither opens a missing-collection false-positive channel; their `views[]` rungs simply find no `stack.views` on an object snapshot. + - **Measured before crossing**, at the door's own snapshot shape and differential, over every shipped object definition in the monorepo: 116 objects (platform-objects 48, showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1, qa 1), 105 built-in list views on 40 objects, 666 list-view field-naming positions and 5 `searchableFields` entries judged — **0 findings for both members, precision 1.0**, against synthetic probes that are refused. + - **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication. + + ## Migration + + **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `objects.proj_task.searchableFields[1]` or `objects.proj_task.listViews.all.columns[1]` — plus the string that was written and the fields the object actually has. + + To fix a refusal, do one of: + + - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or + - drop the entry from the declaration; or, for `searchable-field-unsearchable`, target a text-like stored column instead of a virtual or non-scannable one. + + `os validate` / `os build` / `os lint` already reported these findings at the same severity, so a code-authored stack can be repaired before it reaches a publish. Objects that name a platform-injected system column are unaffected — both members resolve those per object and stay silent where the platform really provisions them. +- c1d8f98: Seed datasets gain a `locale` filter axis, composed with `env` by the loader. + + An app shipping demo data for two language markets — the same records, different display strings — had no declarative way to say which dataset applies. `SeedSchema` is a `strictObject`, so the app could not add the key itself; the selection had to happen in application code while the config was assembled. That is the wrong layer twice over: the choice is cached in the build output (switching markets means deleting `dist`), and because every profile is an `upsert` and the loader only writes, the other market's rows stay resident in the database. + + - **`Seed.locale?: string[]`** — BCP-47 tags scoping the dataset to one or more language markets. **Omitted means every locale.** Unlike `env`, whose three environments are a closed set that can be spelled out as a default, locales are open-ended tags with no enumerable universe — so absence, not a default array, is what carries "unrestricted". An empty array is rejected: a dataset that applies nowhere is an authoring mistake, the same reasoning that already governs a composite `externalId`. `locales`, `language` and `languages` are aliased onto it, matching the existing `environment` / `environments` → `env` pair. + - **`SeedLoaderConfig.locale?: string`** — the tag the load filters on. + - **The loader composes both axes by conjunction.** A dataset is loaded when it passes `env` **and** `locale`; neither axis can rescue a dataset the other excluded. `filterByLocale` mirrors `filterByEnv` down to the reporting posture — skipping is the declared, intended outcome, so it logs at `info`, but it always names what it dropped. Tags compare case-insensitively (BCP-47 casing is a convention, not part of a tag's identity) and otherwise exactly: `['zh']` does not match `zh-CN`, and widening that would be the lenient consumer-side fallback the contract-first rule forbids. + + The platform still translates nothing and merges nothing. The app authors both record sets; this adds only the axis that selects between them. + + **What is not wired yet, stated plainly.** The locale axis is evaluated against `config.locale`, and no first-party call site supplies one — the runtime wiring that would resolve it from the stack's configured locale is a separate change in `packages/runtime`. An embedding host that passes `config.locale` itself gets the full behaviour today; on the default boot path the axis is inert. That is the shape `Seed.env` was in before framework#4704, so it is not left silent: a load carrying locale-scoped datasets and no `config.locale` warns naming each dataset it let through and the config key that would make the scope take effect. The liveness ledger records `seed.locale` as `experimental` for exactly this reason, with the consumer side cited and the producer gap spelled out, rather than claiming `live` on a correct-but-insufficient consumer pointer. +- 615fac3: A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack. + + `list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all. + + The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this. + + - **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`). + - **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default. + - **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier. + - **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing. + + ## Migration + + **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id `object-field-ref-unknown` and the offending path, name-keyed on the wire — for example `objects.proj_task.highlightFields[1]` — plus the string that was written and the fields the object actually has. + + To fix a dangling reference, do one of: + + - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or + - drop the entry from the list. + + `os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + + +### Patch Changes + +- 3e7ef9c: Serve an Invalid `Date` from a driver instead of raising `RangeError` at two metadata read seams. + + `canonicalIsoInstant` (`sys-metadata-repository.ts`) and the `occurredAt` arm inside `auditMetaItem` (`protocol.ts`) both reached `value.toISOString()` for any `Date`. That call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN`, so a single bad row answered **500** on a read path — where the spelling these repairs replaced, `String(value)`, had served a visibly-wrong field the caller could see and report. + + The shape is measured, not hypothetical: mysql2 3.23.1 returns a module constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276 — a range Postgres itself stores. Legacy imports, hand migrations and a MySQL database shared with another application are all ordinary ways such a row arrives. + + Both arms now guard on `Number.isNaN(value.getTime())`, and the terminal value is chosen per call site rather than uniformly: + + - `canonicalIsoInstant` answers `undefined`, so each caller's existing `?? ` chain — the branch an absent column already takes — keeps its meaning. Its consumers are machines, and one forwards into a `z.string().datetime()` field that visible text would fail. + - `auditMetaItem`'s `occurredAt` falls into the `String(...)` arm already beside it, which renders exactly `"Invalid Date"`. `AuditMetaItemResponseSchema.events[].occurredAt` is a required plain `z.string()` read by an operator in Studio's audit tab, so the text satisfies the contract and one bad row no longer blanks the page. + + Neither answer is a blank: a silent empty value is the shape that hides the producer's bug. +- e1d4f9e: `getMetaItemLayered` no longer reports a phantom org-scoped row as a tenant customization. + + `getMetaItemLayered` is the three-layer diagnostic behind Studio's "Code default vs Overlay vs Effective" view, and the third `/meta` read verb in the series `getMetaItems` (plural) and `getMetaItem` (singular) were repaired in. Unlike those two it applied no registry read gate of its own: whatever organization a caller passed was spent on whatever type it passed. On a type the registry declares `allowOrgOverride: false` — everything outside the ADR-0005 tier-A five (`view`, `dashboard`, `report`, `translation`, `email_template`) — a deployment with history can hold pre-#6190 phantom org-scoped rows, which boot hydration deliberately walks past. Read back through this verb they surfaced as `overlay` with `overlayScope: 'org'`: an operator was shown a customization that does not exist, in the one surface built to be authoritative about customizations. + + It was not only displayed. Two doors return that layer **as the response** when it is non-null — the runtime metadata dispatcher and REST `GET /meta/:type/:name/published` — so on those paths the phantom was served as the item. + + The read now resolves its organization through `organizationIdForMetaRead`, the same registry-derived predicate the REST `/meta` doors have applied since #9454 and the twin of the write side's `organizationIdForMetaWrite`. A type with a per-org read channel still resolves the caller's organization and still reports `overlayScope: 'org'`; every other type reads env-wide, which is the partition that actually runs. + + **The gate is bound after the canonical type fold, and that ordering is load-bearing.** In the two sibling verbs the binding already sat below `canonicalizeMetaRequestType`, so the fix there was a substitution. Here it sat above it, and dropping the same expression in place would have gated on the raw `/meta/:type` segment: `declaresOrgOverride` tolerates the manifest plurals but not the URL-only spellings (`translations` and `email_templates` have no manifest key), so a raw segment splits one item across two partitions, addressed by spelling. The repair is therefore a reorder, and it is pinned by a test that fails if the binding moves back above the fold. + + Callers that name no organization — four of the five `plugin-security` invocations, and every import/analytics/auth reader — are unaffected, and a door that already computed the same predicate receives the scope it did before. +- c383352: `SysMetadataRepository.listDrafts` emits the ISO-8601 string its own signature declares for `updatedAt` + + `listDrafts` declares `updatedAt: string | null` on an inline TypeScript return type and reached the field through `row.updated_at ?? row.created_at ?? null`. `??` fires only on nullish, so the JS `Date` that Postgres and MySQL materialise for the builtin audit columns walked straight past it into a field the declaration calls a string. Driven through the published door, the pre-fix build answered `typeof "object"` and the visible text `Wed Mar 04 2026 05:06:07 GMT+0000 (Coordinated Universal Time)` where the same build's `dist/index.d.ts` promised `string | null`; it now answers `2026-03-04T05:06:07.089Z`. + + `updated_at` / `created_at` are builtin audit columns: `SqlDriver#formatOutput` repairs them (and folds declared `datetime` columns) only inside its `if (this.isSqlite)` arm, and `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp` deliberately untouched because those are instants. Nothing reported the mismatch — the declaration is an inline return type rather than a Zod schema, so a schema search finds nothing, and `rows` is cast `as any[]` one line above the map, so tsc saw a `string` assignment that never happened. + + Canonicalised at the producer through the same adapter boundary `rowToItem` already uses, with the terminal chosen per call site: `null` here, because the chain being replaced already ended in `?? null` and that is what "absent" already means to this projection's consumers. An Invalid `Date` — reachable on both live dialects — takes that same branch instead of raising. Already-canonical SQLite text passes through byte-identically, and `updatedBy` is unchanged: `updated_by` / `created_by` are `Field.lookup('sys_user')` string columns, which the dialect asymmetry never reaches. + + No published declaration moves: `dist/index.d.ts` and `dist/index.d.cts` are byte-identical across the fix, which already declared `updatedAt: string | null` before it. A JavaScript consumer that read the raw value and called a `Date` method on it, or stringified it, sees the corrected shape. +- ba426b0: A junk entry in `stack.objects` no longer crashes the reference-integrity rules, and a probe rule that throws is reported instead of read as "nothing wrong". + + `indexObjectGraph` is the first statement of every rule that resolves a field path, and it read each `stack.objects` member without checking it was a record — so a `null` entry (an empty YAML list item, a partial editor write) threw `TypeError: Cannot read properties of null (reading 'name')` before any rule's own per-object guard could run. Because these rules also run inside the runtime publish gate, that was an exception on a write path rather than a missed finding. The seam now drops non-record entries — silently, matching every sibling collection reader in the package — and the valid objects beside them are judged exactly as before. + + On the publish receipt, `runBuildProbes`' object plane wrapped its rule call in a catch that produced an empty finding list, so a crashed rule was indistinguishable from a clean object while `checked.objects` had already counted it. A rule that throws now surfaces as a `runtime`-layer `object_field_ref_rule_failed` error carrying the thrown message, so an unverified object never reads as a verified one. Probes still never fail the publish they verify. +- 618f70d: A dashboard bound to a dataset you just saved now publishes, without restarting the runtime. + + The author-time gate that runs on every `active` metadata publish resolves a widget's `dataset` (and a `type: 'page'` view's `pageName`, and the sibling collections the cross-collection security rules compare against) against a resolution universe the host gathers per write. That gather read the SchemaRegistry alone. The registry is filled at boot by code packages, and for every metadata type except `object` a runtime write does not reach it — so a dataset saved through `PUT /api/v1/meta/dataset` was invisible to the gate until the process restarted, while `GET /api/v1/meta/dataset` returned it in the same instant with `_diagnostics.valid: true`. + + Measured on the reported shape, in one process with no restart between the steps: the row is in `sys_metadata`, the read API lists six datasets, the registry lists the five code-package ones, and a three-widget board bound to the new dataset was refused `422` with three `widget-dataset-unknown` issues whose hint enumerated every dataset except the one just authored. The same request answered `200` after a restart, nothing else changed. + + The gather now folds the stored half onto the registry half for every collection it carries. What that does and does not do: + + - **Additive.** A stored row contributes a name the registry does not already carry and never displaces a registry entry — an object's registry copy is its resolved schema (base plus `extend` contributors) and a raw `sys_metadata` row is the base layer alone, so replacing it would trade this phantom for a subtler one. Where an org overlay redefines a code-package item, the gate still judges that item's content from the registry's version. + - **Active rows only.** A draft does not resolve. The refuse-at-publish ruling exists so an author can write the widget first and the dataset second; a draft dataset that satisfied a published board would invert it. + - **Scoped to the write's own partition** — environment-wide rows plus, when the write has one, its own organization. No other organization's overlays are visible to the gate, on any kernel. + - **A failed store read is reported, not swallowed.** Context gathering still never fails a write, but a read that fails for any reason other than an unprovisioned `sys_metadata` now says so once, naming the consequence — a gather that silently shrinks is how a phantom refusal is manufactured in the first place. + + The rules themselves are unchanged: a reference that resolves in neither home is still refused, with the same code, status and key path. +- 4b0508e: docs(runtime,metadata-protocol): correct the `writable` verdict's illustration — the scope-less booted row is a marketplace / offline import, never a multi-package artifact's module (#14803) + + Comment and prose only. No predicate, no assertion and no served shape changes; + every pin behind the `writable` verdict stays green as written. + + The `writable` verdict shipped in 17.3.0 with a **false attribution** in its own + explanation, and this corrects it at every site that repeated it. The claim was + that the scope-less booted row `isWritablePackage` answers `false` for is *the + `type: module` sub-package a multi-package artifact carries*. It is not, and it + never was: + + - `defineStack` parses every `packages[]` entry through `ManifestSchema` + (`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), whose `scope` is + `.default('project')` (`spec/src/kernel/manifest.zod.ts`), so **no** package of + a compiled artifact is ever scope-less — `dist/objectstack.json` and both + served rows carry `scope: "project"`. + - A genuinely scope-less row arises only where a manifest reaches the registry + **without** that parse, because `installPackage` stores a key-by-key copy that + applies no defaults: a marketplace install / offline file import + (`manifestService.register(rawBody)` to `ql.registerApp`) for the **booted, + read-only** half, and `POST /api/v1/packages` (`body.manifest || body` to + `installPackage`) for the **database base, writable** half. + + Measured: `ManifestSchema.parse` of the `app-multi-package` orders body turns an + unauthored `scope` into `scope: "project"`, while `SchemaRegistry.installPackage` + of the same unparsed body yields a record with no `scope` key at all. + + What stays, because it is true and load-bearing: a scope-less **booted** package + is read-only while a scope-less **database base** is writable, and only + `engine.manifests` tells them apart — which is why the server owns the verdict. +- 460d4b8: Seed loader: a composite `externalId` no longer puts a raw NUL byte in a + diagnostic line. + + `SeedLoaderService` joins a composite natural key's parts with U+0000 on + purpose — that byte cannot occur in a natural-key value, so `('a','b')` and + `('a\0b','')` never collide. The map key is unchanged. What changes is that + the key string is no longer interpolated into human-readable messages: the + `Failed to write record #N (=)` parenthetical and pass + 2's `on record ''` lines now render a composite value as a JSON array of + its parts (`(employer+user=["emp-1","usr-2"])`). + + A single-field `externalId` renders byte-identically, so non-composite + diagnostics do not move, and the structured `errors[].attemptedValue` still + carries the real key. + + Why it mattered: one raw NUL makes `grep` classify the whole server log as + binary, so every later `grep -n` / `grep -c` over it silently returns nothing + until the reader adds `-a` — the reader's main instrument disabled by one byte, + at the moment someone is diagnosing a failed boot. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- ce478db: Correct the out-of-package comments that still described `SqlDriver#formatOutput`'s + two timestamp passes as gated on `if (this.isSqlite)`. + + Since ADR-0053 D-F1 (#13973) both passes — the `AUDIT_TIMESTAMP_COLUMNS` pass and the + `normalizeSqliteDatetimeOutput` pass over `datetimeFields` — run on every dialect, so a + declared `Field.datetime` and the builtin audit columns are presented as canonical + ISO-8601-`Z` text on Postgres and MySQL as well as SQLite. The `rest-server.ts` comment + went further than staleness: it warned future authors that "a declared `Field.datetime` + is therefore NOT protected on Postgres/MySQL", inviting exactly the tolerant consumer-side + coercion ADR-0053 forbids. + + Comments only — no runtime behaviour, no exported symbol and no public type changes; the + published `.d.ts` of both packages is byte-identical. These two packages are named because + their bundled `dist/index.js` / `dist/index.cjs` carry the amended comment text verbatim, + so the published output does change. `@objectstack/metadata` carries the same correction + in `database-loader.ts` but is deliberately NOT named: its edits are all JSDoc blocks, + which its bundle strips, so its published output is unchanged. + + Two carve-outs are preserved rather than flattened: `withPostgresCalendarDayAsText` is + untouched by that ruling (D-F2 — the client library still materialises `timestamptz` / + `DATETIME(3)` as a `Date`; the driver now folds it at its own read boundary), and the + Invalid `Date` residue still stands (D-F3 — the one `Date` shape with no canonical text + leaves the read door unchanged). +- 7d711c9: `findReferencesToMeta`'s unanswerable-target refusal now opens with prose instead of a machine-shaped `[unanswerable_target]` tag that nothing read. + + ``` + before 501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} + after 501 {"error":{"code":"NOT_IMPLEMENTED","message":"References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} + ``` + + Nothing else moves: same `501`, same `NOT_IMPLEMENTED`, same envelope position, and the prescriptive sentence ADR-0110 D3 requires is untouched. Callers branch on `code`, which is unchanged; only the human-facing sentence is shorter. + + Why the tag was wrong here specifically. This producer writes a bracketed tag on many refusals, and every other one is the lowercase restatement of that throw's own declared `code` — `[item_locked]` with `ITEM_LOCKED`, `[no_draft]` with `NO_DRAFT`, `[invalid_request]` with `INVALID_REQUEST`. Measured across the two producer files, 30 of the 31 tagged throw sites that declare a code restate it that way. This refusal declares `NOT_IMPLEMENTED`, so its tag was the sole exception: it named a token the envelope carries on no axis, and a repo-wide search finds no parser, no switch, no assertion and no doc that reads it. Per the ruling behind the `/data` door's `FORBIDDEN:` prefix removal, `error` is human language and `code` is the machine token. + + It became worth fixing when the `/meta/:type/:name/references` door started relaying the producer's prose verbatim: before that the whole sentence was replaced by `Internal server error` and the tag reached nobody, and after it the tag was the first thing an operator read on the screen where they decide whether to delete something. The `@objectstack/rest` entry in this release quotes the pre-removal sentence in its example; this entry is the later word on that wire text. + + The absence is now pinned in `protocol.reference-target-unanswerable.test.ts` — nothing pinned the tag, so without a pin nothing would have pinned its removal either. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [954cb0b] +- Updated dependencies [a56baa2] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [347b777] +- Updated dependencies [36a16d0] +- Updated dependencies [c01b3a6] +- Updated dependencies [a51eb86] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [693fbcb] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [b371960] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [d61bad0] +- Updated dependencies [53cf263] +- Updated dependencies [d91dff4] +- Updated dependencies [21aabbc] +- Updated dependencies [dff0bdd] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [7a01847] +- Updated dependencies [a87163c] +- Updated dependencies [36a6082] +- Updated dependencies [7ad2ca0] +- Updated dependencies [0cde37d] +- Updated dependencies [7dafaae] +- Updated dependencies [52b59d6] +- Updated dependencies [9f890d3] +- Updated dependencies [720bf47] +- Updated dependencies [434ca2d] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [ce21963] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [6f23f0e] +- Updated dependencies [b4b37e5] +- Updated dependencies [ba426b0] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [ed6579b] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [efc5447] +- Updated dependencies [89758ac] +- Updated dependencies [d83d079] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [615fac3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [6b7d709] +- Updated dependencies [7a7fb03] +- Updated dependencies [60ff091] +- Updated dependencies [cd55558] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/lint@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index cd18ee5fa9..1839dc65c7 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.3.0", + "version": "17.4.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 61eb06aedd..998b977efe 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,514 @@ # @objectstack/metadata +## 17.4.0 + +### Minor Changes + +- a56baa2: feat(metadata,objectql): a keyed plural read on `MetadataManager`, `listNames` fault parity, and an action audit that answers from the same identity and sources as the router + + Two plural reads of one metadata plane could disagree with a by-name read of + that same plane, and the ADR-0110 D5 action-governance audit stood on the + disagreement — reporting `registered handler with NO declaration … REFUSED at + dispatch` about a route the router was resolving and dispatching in the same + boot. + + **`MetadataManager.listNames` gains the per-loader `try`/`catch` that + `loadMany` and `list()` have carried since #5108.** One loader fault used to + produce two different facts depending only on which plural read a caller + reached for: `loadMany` swallowed it and answered short, `listNames` threw. It + now degrades the same way, through the same `reportLoaderReadFailure` / + `reportLoaderReadRecovered` helpers — one outage, one line, one vocabulary. + Callers that relied on `listNames` throwing to detect an outage should read + `listDiagnosed()`, which reports `degraded` explicitly. + + **New: `MetadataManager.loadManyKeyed(type, options?)`** — `loadMany` read under + the identity the STORE holds each item by, returning `{ name, data }` pairs. It + delegates to a loader's own `loadManyKeyed` where one is offered (on + `DatabaseLoader` that shares `loadMany`'s single query, so it costs nothing + extra) and otherwise falls back to that loader's `list()` + per-name `load()`. + ⛔ **`loadMany`'s published return shape does not change**, and no existing + consumer is touched: the key travels *beside* the body, never inside it, so a + body that deliberately carries no `name` stays byte-identical to what was + stored (#14205). + + **The action-governance audit now mirrors the router on both halves of the D5 + bijection.** The declaration half enumerates the plane keyed + (`loadStandaloneActionsKeyed`), so a row whose body does not name itself — a + `sys_metadata` row keyed by its `name` column, or a `FilesystemLoader` file + whose identity is its path — is a declaration to the audit exactly as it is to + the router; the handler half also probes the plane BY NAME + (`lookupMetadataAction`, `loadDiagnosed`/`load`, injected like the existing + registry rung), so a loader fault a plural read swallows can no longer turn a + dispatchable handler into an accusation. Both probes stay conservative in one + direction only: a source that throws leaves the handler on the list. + + Additive on every published signature. `runActionGovernanceInventory` and + `collectEngineActionDeclarations` gain optional parameters and keep their old + ones working unchanged; declaration rows gain an optional `storeKey` (the new + exported `ActionDeclarationRow`). + + **Population change, reported:** `unboundDeclarations` now sees declarations + whose identity is the store key. Its BEFORE was **0, structurally rather than + by sampling** — a nameless row was dropped before reconciliation ran, so it + could never be reported however many a plane held. Its one deliberate + subtraction: a row with neither an own `name` nor a store key is no longer + reported as `actionName: undefined`, which read as a parse failure in the + warning rather than as a finding. + + Known boundary, stated in the audit's docblock rather than left to be + rediscovered: a boot-time audit runs outside any request scope, so if a + composition ever registered `metadata` as `SCOPED` the audit could not reach + that instance at all — before any read method runs. No shipped composition does + (`packages/metadata/src/plugin.ts` registers a static instance), and reaching a + request-scoped service from a boot-time audit is a separate change. +- c1d274d: fix(metadata): two files sharing one stem are refused with both paths named, instead of one being listed twice and served by extension precedence (#14921) + + **BREAKING** accept-set narrowing on `FilesystemLoader`, shipped as `minor` + under the repo's launch-window convention for breaking changes. Ruled on + #14921 (2026-09-05, option 1 of three). + + **Remedy: delete or rename the duplicate file.** The refusal names every + colliding path and the metadata type, so the fix is visible at the point of + failure. + + `FilesystemLoader` derives a metadata name by stripping a flat file's + extension, and resolves a name back to a file under a FIXED extension + precedence (`.json` → `.yaml` → `.yml` → `.ts` → `.js`). Two files sharing a + stem therefore produced one name **twice** in `list()` while only the + first-precedence file was reachable through any name at all. With + `object/twin.json` and `object/twin.yaml` both present, `list()` answered + `['twin', 'twin']`, `twin.yaml` was addressable through nothing, and + `loadMany()` returned both bodies. `MetadataManager.listNames()` unions loader + output into a `Set`, which collapsed the duplicate and took the count + discrepancy with it — the file stayed unreachable either way, so a clean + `listNames()` was never evidence the collision had been absorbed. + + The invariant that broke: **what is listed is what is loadable.** The listed + set and the addressable set stopped being the same set. The failure was silent + in the direction that matters for authoring — convert `twin.json` to + `twin.yaml` and leave the old file behind, or land one from each of two + packages, and the JSON one is served forever with no diagnostic anywhere, + while `admitLoaderItems()`'s documented "keep the first and say nothing" + absorbs the collision a second time. + + `FilesystemLoader.list()` now throws `AmbiguousMetadataStemError` + (`AMBIGUOUS_METADATA_STEM`, HTTP 500) naming both paths and the type, and the + same refusal fronts the shared `loadMany()` / `loadManyKeyed()` walk, so the + two-body answer is gone rather than de-duplicated. `MetadataManager.listNames()` + and `list()` **propagate** it rather than absorbing it into their per-loader + degradation: an ambiguous stem is an authoring error no retry fixes, and + degrading it would drop every item the loader holds into a short-but-served + list while the server keeps reporting healthy. A real storage outage still + degrades exactly as before — the seams discriminate on a branded predicate, + `isAmbiguousMetadataStemError`, not on a blanket rethrow. + + **Refused shape**, precisely: two or more files **directly under + `ROOT/TYPE/`** whose basenames differ only by an extension belonging to one of + **this instance's registered serializers**. Register `javascript` and + `dual.json` + `dual.js` becomes ambiguous; under the manager's default format + set (`typescript` / `json` / `yaml`) it is not, because `.js` derives no name. + Nested files are untouched — they are neither listed nor resolvable (#14486), + so `crm/solo.json` beside a flat `solo.json` is not a collision. The refusal is + scoped to the type directory that holds it: a clean `view/` still lists while + `object/` refuses. + + New exports from the package root entry: `AmbiguousMetadataStemError`, + `isAmbiguousMetadataStemError`, `AMBIGUOUS_METADATA_STEM_CODE`, + `AMBIGUOUS_METADATA_STEM_STATUS`. + + Measured migration cost, which is what makes this narrowing cheap: **no tree in + this repository carries the shape.** A walk of all 7,770 tracked files across + 526 directories found zero stem collisions among `.json` / `.yaml` / `.yml` / + `.ts` / `.js`, confirmed independently by a `git ls-files` pass, and the repo + holds no `.yaml`/`.yml` metadata file at all outside CI and workspace config. + No existing tree goes red. + + +- e9fcd6b: feat(metadata)!: `DatabaseLoaderOptions.cache.ttl` → `cache.ttlMs` — the read-through cache TTL carries its unit in the key name (#14478) + + + + **BREAKING** rename on the exported `DatabaseLoaderOptions.cache` shape + (`DatabaseLoaderCacheOptions.ttl` → `ttlMs`), shipped as `minor` under the + launch-window convention. `MetadataManager` hands `config.cache.databaseLoader` + straight to `new DatabaseLoader({ cache })`, so this option is the spec key + `cache.databaseLoader.ttlMs` one layer down and renames with it: a loader + configured with `ttlMs: 60_000` expires entries after 60 seconds exactly as + `ttl: 60_000` did. The README example and the kernel metadata-service docs page + spell the new key. + + ```ts + // before + new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttl: 60_000 } }); + // after + new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttlMs: 60_000 } }); + ``` +- 3bd9b34: feat(metadata): `deriveViewContainerObject` gets a leaf `/view-container` subpath, so objectql's lean ADR-0076 entry stops loading the manager, chokidar, glob and js-yaml for a six-line pure function + + `packages/objectql/src/engine.ts` reached `deriveViewContainerObject` through + `@objectstack/metadata`'s ROOT entry. `core.ts` — the ADR-0076 lean entry — + re-exports `engine.ts`, so `@objectstack/objectql/core`'s module-init closure + inherited the whole root entry: `MetadataPlugin` -> `NodeMetadataManager` -> + `chokidar`, plus `glob`, `js-yaml` and `readdirp`. + + The same file already carried the answer 79 lines above, at its + `@objectstack/metadata/errors` import: that leaf subpath exists "precisely so a + cross-package consumer gets the predicate without the manager, the loaders or + the YAML/filesystem machinery behind the root entry". This is that pattern, + taken a second time. + + **Measured on the built artifacts, not asserted** — every module Node actually + evaluates when `@objectstack/objectql/core` is loaded in a fresh process, + recorded through a `module.registerHooks` load hook (ESM and CJS) plus + `require.cache`, byte sizes from `statSync`: + + | `@objectstack/objectql/core` | modules | bytes | + |:---|---:|---:| + | before (ESM `dist/core.mjs`) | 190 | 12,348,424 | + | after (ESM `dist/core.mjs`) | 185 | 11,849,808 | + | **delta** | **-5** | **-498,616 (-486.9 KiB)** | + | before (CJS `dist/core.js`) | 188 | 12,654,238 | + | after (CJS `dist/core.js`) | 183 | 12,141,034 | + | **delta** | **-5** | **-513,204 (-501.2 KiB)** | + + Six modules stop loading — `packages/metadata/dist/index.js` (237,747 B), + `js-yaml` (114,610 B), `glob` (82,749 B), `chokidar` (2 files, 54,220 B) and + `readdirp` (9,836 B) — and one 469-byte module takes their place. Marginal + module-init time for that root entry, measured on a warm lean closure, was + ~22 ms (median of 7; 20.4-27.5 ms) out of ~630 ms. + + ⚠️ The figure the finding was argued on — "~3.6 KB to ~450 KB" — is right about + the delta and wrong about the baseline: the lean entry's closure was already + ~11.5 MiB before this import existed, dominated by `@objectstack/spec` + (9,587,914 B) and `zod` (567,918 B), neither of which the metadata root entry + contributes. What the root import cost was ~487 KiB *on top of* that, not a + closure of 450 KB. + + The derivation itself moves to `packages/metadata/src/view-container.ts`, a + module with **no imports at all**, and `view-container-expansion.ts` imports + and re-exports it, so `index.ts`'s root export and `plugin.ts` keep their + spelling and the symbol stays on the root entry — this subpath is an additional + door, not a relocation. A re-export shim onto `view-container-expansion.ts` was + tried first and rejected on measurement: esbuild tree-shakes the unused + `expandRuntimeViewContainer` but keeps its two `@objectstack/spec` import + statements, so that shim's own closure was 84 modules / 3,035 KiB. The real + leaf's is 1 module / 469 B. + + `expandRuntimeViewContainer` is deliberately not exported from the new subpath: + `metadata-manager.ts` is its only caller, the root entry does not export it + either, and it is the half that carries the spec machinery. +- 8647c87: A completed run of the ADR-0030 notification cut-over now records itself in the `sys_migration` deployment ledger, per the ruled claim matrix. + + `migrateSysNotificationToEvent` reports `migrated` / `already_done` / `not_applicable` / `error` to its caller and — until now — recorded nothing anywhere. Once that line had scrolled, "did this cut-over run here, and when" had no answer in the deployment even in principle. The ledger row is what answers it, and what a run of this migration may claim under `NOTIFICATION_EVENT_MIGRATION_ID` is stated on that constant in `@objectstack/spec/system`: + + - `last_run_at` — stamped on every completed non-`error` run (`migrated`, `already_done`, `not_applicable` alike). + - `applied_at` — stamped only on `migrated`. Never cleared: a later `already_done` leaves an earlier backfill's stamp alone, because the backfill really did happen. + - `verified_at` — never written, in either direction. This migration has no self-check, and `verified_at` means a self-check passed. On a store created after the cut-over the row already exists and `attestFreshDatastore` set `verified_at` at birth; that certificate survives a run untouched, because the column is omitted from the update rather than sent as `null`. + - `blocking: 0`, and `details` carrying `{ outcome }` verbatim. + - An `error` run writes no claim at all — it does not know what it did, so it does not say. + + **Receipt, not gate.** Nothing reads a row under this id as a precondition and nothing may: a gate would need the self-check that does not exist. The row is what an operator reads, in the shape `sys_migration` already documents for the seed-tenancy repair. + + Two additions to the published surface of `@objectstack/metadata/migrations`, both driven by that: a new `SysNotificationMigrationReceipt` type, and a new `receipt` member on `SysNotificationMigrationResult` reporting what became of the claim (`inserted` / `updated` / `not-claimed` / `no-ledger` / `failed`, with a reason on the last two). This directory takes no logger and reports to its caller, so the claim's own fate is reported the same way the migration's is — a receipt that could not be written is never swallowed. Reading a result is unaffected; code that CONSTRUCTS a `SysNotificationMigrationResult` by hand (a test double) now supplies `receipt`. + +### Patch Changes + +- 0c5d035: fix(metadata): a `HistoryCleanupManager` run that loses deletes now says so, at both of `start()`'s triggers + + A failing history cleanup was completely silent. Three things composed: every inner `catch` on the delete path is a bare `catch {`, so the error object is discarded; the only `console.error` in `runCleanup()` sits in its OUTER catch, which those inner catches prevent execution from reaching; and `start()` invoked the run as `void this.runCleanup()`, throwing away the `{ deleted, errors }` the run returns — at BOTH call sites, the immediate run and every interval tick. A driver whose deletes failed on every scheduled run therefore produced zero output and no reachable error count, while the history table grew past its retention policy with nothing to find. + + The repair reads the envelope instead of replacing it. `runCleanup()`'s contract, its inner catches and its counting are unchanged: reporting a failure to the CALLER is the third answer AGENTS.md → "Degradation log levels" allows a durability seam, and that same section names a log per failed write as the mirror-image failure. What was missing was a reader — `start()` is where the chain ends, since it returns `void` and an interval tick has no caller at all. Both call sites now go through one shared pass that reads the returned counts and, when a run lost deletes, prints one `error` line naming the consequence (rows past the retention policy are still in the table, nothing retries them, and the system keeps reporting healthy) and where to look. A run that loses nothing stays quiet, and a direct caller of `runCleanup()` sees exactly the same `{ deleted, errors }` as before. +- 281bf0d: `HistoryCleanupManager` computes its retention cutoff on one calendar, not two. + + Both call sites — the age-based delete in `runCleanup()` and the preview count in `getCleanupStats()` — built the cutoff with `cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays)` and then rendered it with `toISOString()`. `setDate`/`getDate` read and write the **local** calendar; `toISOString()` renders **UTC**. They now use `setUTCDate`/`getUTCDate`, so the arithmetic and the rendering agree. + + `setDate` preserves wall-clock time, so shifting the local calendar back `n` days moves the *instant* by exactly `n × 24h` only while every local day in the window is 24 hours long. When the window straddles a DST transition it is 23 hours (spring-forward) or 25 (fall-back), and the cutoff instant that goes into the `recorded_at: { $lt: … }` **delete** filter is off by the size of that transition — one hour in most zones, thirty minutes on Lord Howe Island. History rows within that slip of the retention boundary were deleted early, or retained too long. + + The exposure is not limited to the two transition days: the window only has to *straddle* a transition, so it grows with `maxAgeDays`. Measured over a 12-zone × 366-day × 48-half-hour sweep of 2026, in `America/New_York` the old spelling produced a wrong cutoff for 0.6% of instants at `maxAgeDays: 1`, 16.4% at 30, 49.7% at 90 and 69.4% at 180. In zones that do not observe DST (`UTC`, `Asia/Shanghai`, `Asia/Kolkata`, `Australia/Perth`) the rate is 0.0% at every `maxAgeDays` — which is why no test had ever gone red on this. + + This does **not** make retention timezone-aware, and does not change what `maxAgeDays` means. The cutoff was already intended to be `now − maxAgeDays × 24h`; it is now that in every zone rather than only in zones without DST. Nothing else in either filter moved: the `organization_id` scoping, the ADR-0009 `executionPinned` exclusion and the `maxVersions` path are untouched. +- 3e7ef9c: Serve an Invalid `Date` from a driver instead of raising `RangeError` in `DatabaseLoader.stat`. + + `canonicalIsoInstant` reached `value.toISOString()` for any `Date`, and that call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN`. `stat()` is a hot read path — REST `/meta/*`, ObjectQL plan resolution, runtime overlay merges — so one legacy `sys_metadata` row answered **500** where the spelling this repair replaced had served a visibly-wrong value. + + The shape is measured: mysql2 3.23.1 hands back a constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276, which Postgres itself stores. + + The `Date` arm now guards on `Number.isNaN(value.getTime())` and answers `undefined`, so `stat()`'s own `?? new Date().toISOString()` — the branch an absent column already takes — publishes a parseable `MetadataStats.mtime`. `undefined` rather than visible text is deliberate here: `mtime` is declared `z.string().datetime()`, so the text `"Invalid Date"` would not produce a readable cell, it would produce a zod refusal at the consumer, moving the failure instead of removing it. A blank is excluded for the opposite reason — it hides the producer's bug. +- 7629f4d: feat(spec)!: retire the three inert outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (formerly `ttl`) and `maxSize` — read by nothing; `cache.databaseLoader` is the only live half (#15624, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + ADR-0049 enforce-or-remove decides it: a declared-but-unenforced key with zero + measured readers comes off, and the published reference page stops teaching it. + + `MetadataManagerConfig.cache` declared three outer knobs — `enabled` (default + `true`), `ttlSeconds` (default 3600; spelled `ttl` until #14478) and `maxSize` + ("Max cache size in bytes") — beside the nested `databaseLoader` block, and + **nothing read the outer three**. The only runtime consumer of the block is + `MetadataManager` (`packages/metadata`), which hands `cache.databaseLoader` and + nothing else to `new DatabaseLoader({ cache })`; a reader census over + `packages/**` (tests and changelogs excluded) found no runtime reader of any + outer key, while the same grep shape found the nested `cache?.databaseLoader` + read twice — the control that makes the zero a measurement. An author writing + `cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse + and a cache that behaved exactly as before, with no error and no warning, and + the published reference page (`references/kernel/metadata-loader`) documented + all three as if they configured something. + + **What is refused:** authoring `cache.enabled`, `cache.ttlSeconds`, `cache.ttl` + or `cache.maxSize` on `MetadataManagerConfig`, with any value — directly, through + `MetadataManagerOptions`, or through `MetadataPluginConfig.storage`. The nested + object is not `.strict()`, so each key is a `retiredKey()` tombstone rather than + a bare deletion (a deletion would have stripped it in silence — the same no-op + one layer down): authoring it is a `tsc` error (`never`) and a parse error + carrying the prescription, which names the live nested knob. + + **What stays, byte-identical:** the DatabaseLoader read-through cache under + `cache.databaseLoader` — `enabled` (default `true`), `maxSize` (an entry count, + default 500) and `ttlMs` (milliseconds, default 60000) — and every runtime + path. Parsed configs no longer carry the two former defaults (`enabled: true`, + `ttlSeconds: 3600`) that were materialized and never consulted. + + **The #14478 rename is folded in.** `cache.ttl` → `cache.ttlSeconds` was + registered under this same unreleased major and never reached a published + release, so it is absorbed by the removal: `cache.ttl`'s tombstone now + prescribes deletion (naming `cache.databaseLoader.ttlMs`) instead of a rename to + a key that is itself retired — an author upgrading from a published 17.x sees + one hop. The nested `cache.databaseLoader.ttl` → `ttlMs` half of that rename is + unchanged. + + ## FROM → TO + + ```ts + // before — parsed green; no runtime ever read the three outer numbers + new MetadataManager({ + datasource: 'default', + cache: { enabled: true, ttlSeconds: 3600, maxSize: 10_485_760, databaseLoader: { ttlMs: 60_000 } }, + }); + + // after — delete the outer keys; the nested block is the cache that runs + new MetadataManager({ + datasource: 'default', + cache: { databaseLoader: { enabled: true, maxSize: 500, ttlMs: 60_000 } }, + }); + ``` + + **Migration.** Delete `cache.enabled`, `cache.ttlSeconds` / `cache.ttl` and + `cache.maxSize`; nothing replaces them, because nothing ever consumed them. If + you meant to switch the cache off, cap it or set its TTL, write + `cache.databaseLoader.enabled` / `.maxSize` (entries) / `.ttlMs` (milliseconds) + — those are honoured. No `os migrate meta` conversion runs on this surface: a + `MetadataManager` config is not a stack collection member and never a stored + row, so the chain has no seam for it; the D3 semantic entry + `metadata-manager-config-inert-cache-keys-retired` carries the prescription + into `spec-changes.json`, the upgrade guide and the `spec_changes` MCP tool. + + The retirement kit: `retiredKey()` tombstones on all three (and the absorbed + `ttl`), `RETIRED_KEYS_BY_MAJOR[18]` entries for each, the D3 semantic entry + above (the #14478 entry's outer half is re-worded from a rename to a deletion), + negative pins asserting each prescription and a positive pin asserting the + parse output no longer materializes the retired defaults, the published + reference pages regenerated, and the hand-written docs page and this package's + README (`@objectstack/metadata` ships `README.md`, hence its `patch`) no longer + authoring `cache.enabled`. +- 6f23f0e: fix(metadata): keep the original notification instant when migrating `sys_notification` to the event model (#16312) + + `migrateSysNotificationToEvent` materializes each legacy inbox row into a + `sys_inbox_message` and a `sys_notification_receipt`, back-dating both to the + notification's own `created_at`. Both writes passed no options bag, so they + relied on the audit binder's create-side `record.created_at ?? now` — the + laundering #15964 removed, on the maintainer ruling of 2026-09-06. Without + that accident, every migrated inbox row and receipt is stamped with the moment + the migration RAN: a user's whole bell history collapses to "all arrived + today". + + The two writes now declare `{ context: { preserveAudit: true } }`, the explicit + historical-import channel the same ruling deliberately kept (#3493; it is what + REST import's `treatAsHistorical` sets). This is not a bypass of audit — it is + the door audit left open for a historical import. No exported symbol, schema or + config key moves. + + **Release ordering.** `@objectstack/objectql`'s side of #15964 is itself still + an unreleased changeset, so no published version of this migration has ever + written the flattened timeline. Releasing the two together keeps it that way. + + **If a deployment did run it from a build that has both halves**, the original + timeline is recoverable rather than lost: the source `sys_notification` rows + are rewritten in place, never deleted or archived, and `created_at` is not + among the legacy columns the run clears — so the notification's own instant is + still on the event row and reachable from both new rows through + `notification_id`. +- efc5447: `RemoteLoader.list()` no longer reports a nameless remote body as a literal `undefined`. + + The method declares `Promise` and read the collection as `loadMany<{ name: string }>(type)` before mapping `items.map(i => i.name)`. That type argument is an **assertion** about bodies that arrived over HTTP, and nothing checked it: a body with no top-level `name` yielded `undefined`, which went into an array the signature declares as `string[]`. `MetadataManager.listNames()` unions loader `list()` output unfiltered, so the violation reached consumers — measured on this fixture, `listNames()` answered `[ 'account', undefined, 42 ]`. + + The guard is `DatabaseLoader.list()`'s, one file away: the same cast-then-map spelling with `.filter(name => typeof name === 'string')` behind it. `RemoteLoader` was the only one of the four loaders in that directory with no guard at all — `MemoryLoader` answers with its store keys, and `FilesystemLoader` reports only names `findFile()` resolves. Dropping silently rather than throwing is the direction those siblings already carry: a name in the list that the door answers `null` for is the silent failure an author reads as their own typo, so the list is narrowed to agree with the door. + + Nothing that was validly returned before stops being returned: the only entries that disappear are the ones whose type the signature already ruled out. A caller that previously received `[undefined]` now receives `[]`. `loadMany()` is deliberately untouched — it keys nothing, so a body carrying no `name` is still served there; this loader reads over HTTP and holds no store key, so `body.name` is the only identity it has and the family's "identity is the store key" rule cannot be satisfied for it. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + - @objectstack/metadata-fs@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 7bdfc79e7a..2fbbe01d0e 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.3.0", + "version": "17.4.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 96d90aa757..5d7fff1975 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,764 @@ # @objectstack/objectql +## 17.4.0 + +### Minor Changes + +- 2ed6be6: Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. + + A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule. + + Two changes, and neither moves what a rule evaluates to: + + - **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`. + - **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects. + + `error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list. +- a56baa2: feat(metadata,objectql): a keyed plural read on `MetadataManager`, `listNames` fault parity, and an action audit that answers from the same identity and sources as the router + + Two plural reads of one metadata plane could disagree with a by-name read of + that same plane, and the ADR-0110 D5 action-governance audit stood on the + disagreement — reporting `registered handler with NO declaration … REFUSED at + dispatch` about a route the router was resolving and dispatching in the same + boot. + + **`MetadataManager.listNames` gains the per-loader `try`/`catch` that + `loadMany` and `list()` have carried since #5108.** One loader fault used to + produce two different facts depending only on which plural read a caller + reached for: `loadMany` swallowed it and answered short, `listNames` threw. It + now degrades the same way, through the same `reportLoaderReadFailure` / + `reportLoaderReadRecovered` helpers — one outage, one line, one vocabulary. + Callers that relied on `listNames` throwing to detect an outage should read + `listDiagnosed()`, which reports `degraded` explicitly. + + **New: `MetadataManager.loadManyKeyed(type, options?)`** — `loadMany` read under + the identity the STORE holds each item by, returning `{ name, data }` pairs. It + delegates to a loader's own `loadManyKeyed` where one is offered (on + `DatabaseLoader` that shares `loadMany`'s single query, so it costs nothing + extra) and otherwise falls back to that loader's `list()` + per-name `load()`. + ⛔ **`loadMany`'s published return shape does not change**, and no existing + consumer is touched: the key travels *beside* the body, never inside it, so a + body that deliberately carries no `name` stays byte-identical to what was + stored (#14205). + + **The action-governance audit now mirrors the router on both halves of the D5 + bijection.** The declaration half enumerates the plane keyed + (`loadStandaloneActionsKeyed`), so a row whose body does not name itself — a + `sys_metadata` row keyed by its `name` column, or a `FilesystemLoader` file + whose identity is its path — is a declaration to the audit exactly as it is to + the router; the handler half also probes the plane BY NAME + (`lookupMetadataAction`, `loadDiagnosed`/`load`, injected like the existing + registry rung), so a loader fault a plural read swallows can no longer turn a + dispatchable handler into an accusation. Both probes stay conservative in one + direction only: a source that throws leaves the handler on the list. + + Additive on every published signature. `runActionGovernanceInventory` and + `collectEngineActionDeclarations` gain optional parameters and keep their old + ones working unchanged; declaration rows gain an optional `storeKey` (the new + exported `ActionDeclarationRow`). + + **Population change, reported:** `unboundDeclarations` now sees declarations + whose identity is the store key. Its BEFORE was **0, structurally rather than + by sampling** — a nameless row was dropped before reconciliation ran, so it + could never be reported however many a plane held. Its one deliberate + subtraction: a row with neither an own `name` nor a store key is no longer + reported as `actionName: undefined`, which read as a parse failure in the + warning rather than as a finding. + + Known boundary, stated in the audit's docblock rather than left to be + rediscovered: a boot-time audit runs outside any request scope, so if a + composition ever registered `metadata` as `SCOPED` the audit could not reach + that instance at all — before any read method runs. No shipped composition does + (`packages/metadata/src/plugin.ts` registers a static instance), and reaching a + request-scoped service from a boot-time audit is a separate change. +- ddfbf04: Three more engine refusals publish their error `code` as an importable constant. + + Each of these classes already tells the reader, in its own docblock, that it is *"Identified by `code` rather than `instanceof` so it survives crossing package boundaries"* — and none of them offered anything to import. The only way to FOLLOW that published instruction was to re-spell the wire string in your own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. + + Three new exports from `@objectstack/objectql`, each graded on its own: + + - `DRIVER_CONNECT_CODE` — `DriverConnectError`'s ADR-0112 `code`. Thrown by `ObjectQL.init()` when boot-registered drivers fail to connect, which aborts kernel bootstrap. **Additive widening, `minor`.** + - `DATASOURCE_UNAVAILABLE_CODE` — `DatasourceUnavailableError`'s ADR-0112 `code`. Thrown by `getDriver()` when an object's datasource was declared but has no live driver. **Additive widening, `minor`.** + - `SUMMARY_RECOMPUTE_CODE` — `SummaryRecomputeError`'s ADR-0112 `code`. Thrown by `insert`/`update`/`delete` when parent roll-up summaries fail to recompute *after the triggering records were written*. **Additive widening, `minor`.** + + **The cost these close is already shipped, not hypothetical.** Three first-party packages in this repo match these refusals by `code` today and therefore carry a second spelling of the string: `packages/rest/src/error-response.ts` (datasource-unavailable), `packages/rest/src/import-runner.ts` and `packages/metadata-protocol/src/seed-loader.ts` (summary-recompute — both to implement the documented "the records WERE written, treat it as a warning" recovery). They keep working unchanged; they can now import the constant instead of authoring the string. + + **Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary, which is what these docblocks have been telling readers to do. + + **Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; every refusal throws the same `code` and the same message as before. Consumers that spell the strings themselves keep working unchanged — this adds affordances, it removes nothing. + + **All three classes were already exported and stay exported.** The constants join them on the batteries barrel; like every other `*_CODE` in this package they are deliberately not added to the lean `core.ts` entry, even though `DriverConnectError` and `DatasourceUnavailableError` themselves are published there. That asymmetry is #16260's subject for the whole family and is not decided here. +- 33388f9: Engine refusals now declare their HTTP status under both spellings: `httpStatus` beside the existing `status`, same number, at every producer in the package. + + `status` is unchanged and stays. It is what every HTTP door in this repo reads — `resolveThrownHttpError` (`@objectstack/types`) resolves `.status` then `.statusCode` and knows no other spelling — so nothing about what the REST or dispatcher doors answer changes. + + What changes is what a consumer holding the **thrown** error can read. ADR-0112 D5 records the destination as "the HTTP status lives on the transport and (optionally) `error.httpStatus`", and `httpStatus` is the key the client SDK already stamps on every wire failure. A consumer that caught an engine refusal locally had no status at all: `os migrate summary-nulls --json --recompute-undefined-on-empty customer.nope` emitted `{ error, code: 'INVALID_FIELD' }` with no status field, while the same refusal arriving over the wire carried `httpStatus: 400`. It now carries `httpStatus: 400` on both paths. + + Additive on thrown errors, so no caller that reads `status` needs to change. The 20 producers: the `INVALID_SORT` / `INVALID_FIELD` / `VALIDATION_ERROR` / `INVALID_METADATA` / `DELETE_RESTRICTED` refusals in `engine.ts`, the `INVALID_FILTER` / `INVALID_FIELD` refusals in `filter-comparand-shape.ts`, `resolveRecomputeScope` in `summary-backfill.ts`, and the eight error classes declaring a `readonly status` (`DuplicateRecordError`, `HookUnscopedDataAccessError`, `MultiUpdateHookKeyDivergenceError`, `EmptyCredentialWriteError`, `SystemWriteOrganizationRequiredError`, `NamespaceConflictError`, `ArtifactObjectNameConflictError`, `ObjectOwnershipConflictError`). +- fa125f3: feat(objectql,spec): `Field.valueDomain` binds at the write seam — a non-member is refused with `value_domain` (maintainer ruling 2026-09-02 on #14168, engine half) + + **BREAKING** accept-set narrowing on the ObjectQL record write path, shipped as + `minor` under the repo's launch-window convention for breaking changes. + + The key is **already published, and published unenforced**. The version-packages + cut `8a1bad8b8` (2026-09-04 10:20Z) consumed the spec half's changeset + `field-value-domain-slot.md` and released `@objectstack/spec@17.3.0`, which + declares `Field.valueDomain`, parses it, and refuses it on any type other than + `text` — and never reads it when a record is written. The 17.3.0 liveness ledger + states the gap in its own words: "a non-member WRITTEN to a `text` field + declaring a domain is accepted today". That write is accepted on 17.3.0 and is + refused from this release on. + + **Refused shape**, precisely: a record write that supplies a value for a `text` + field whose definition declares `valueDomain`, where the WRITTEN value is not a + member of the named standard. It fails with the field error code `value_domain`, + carrying `constraint: { valueDomain }` and a message that names the standard in + all four platform locales. Nothing else narrows — a field that declares no + `valueDomain` is untouched, and so is every other field type, because the schema + accepts the key on `text` alone and the validator judges exactly that set. + + **Remedy: write a member of the declared standard.** `iana_time_zone` admits + `UTC` and refuses `Mars/Olympus`; `iso_4217_currency` admits `CHF` and refuses + `chf`; `iso_3166_alpha2` admits `CH` and refuses `ZZ`. Dropping the + `valueDomain` declaration from the field lifts the refusal entirely, for an + author who declared a domain they did not mean. + + **No stored row is touched, and none becomes invalid.** This is the `min` / + `max` / `maxLength` transition-gate class: a value stored before the domain was + declared — or before this release — is never re-read, and it survives an edit of + another field on the same record. An absent or empty value follows the field's + `required` handling, not this check. + + + + - The membership test is the spec's shared `isValueDomainMember` — the same + predicate, over the same closed vocabulary, that a settings specifier's + `valueDomain` uses. A time zone accepted in Settings is the time zone + accepted in a field. + - The two authoring forms (`fieldForm`, `objectForm`) gain a `valueDomain` + control, shown on exactly the types the schema accepts the key on. The + object-form control's choices are derived from the vocabulary, not re-typed. +- 7778115: `ObjectQL.find()` now guarantees the array it declares: an `afterFind` hook that replaces the result container is refused with `FIND_HOOK_RESULT_NOT_ARRAY`. + + `find()` is declared `Promise`, but on the hook path it returned `hookContext.result` with nothing re-checking the value after the `afterFind` dispatch. A handler assigning `ctx.result = { records: [ … ] }` therefore made a read declared to resolve to an array resolve to an envelope instead — silently, with no throw, no diagnostic and no log, while roughly 140 call sites read the answer as an array on the strength of the declaration. + + The engine now refuses that, immediately after the `afterFind` dispatch and ahead of the two consumers that already assume the array (secret-field masking and the `__search` companion strip). The refusal is a named error, `FindHookResultNotArrayError`, carrying the registered ADR-0112 code `FIND_HOOK_RESULT_NOT_ARRAY` and HTTP `500`; its message names the hook event and the object, and `developerMessage` carries the remedy. + + **Shaping stays legal, and nothing about it changes.** A handler may still mutate rows in place, delete keys, filter rows out, or assign a *different array* built from them — `Array.isArray` is the whole predicate, deliberately, so that `ctx.result = ctx.result.map(…)` keeps working. Only the container is protected. + + What to do if this refusal fires: + + - to answer no rows, assign `[]`; + - to refuse the read, `throw` from the handler — the supported way for any hook guard to say no; + - to hand a caller a different structure, build it in the caller, not in the hook. + + `@objectstack/spec` widens by one member: `FIND_HOOK_RESULT_NOT_ARRAY` joins `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the generated `ErrorCode` union — and therefore `ApiErrorSchema.code` — accepts it. Additive: no existing code is removed or renamed. + + Scope: this closes the one `return hookContext.result` site in the engine with a concrete declared shape to violate. `findOne`, `update` and `delete` declare `Promise` and carry no enforceable declaration; that is a separate question about those declarations and is deliberately not answered here. +- f9a3c32: feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall + + `BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). + + Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. + + - **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. + - **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. + - **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). + + What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. +- d0ee598: fix(objectql): the boot loop refuses a view container whose `name` disagrees with the object it binds to, instead of silently rewriting the author's field (#14666) + + **BREAKING** accept-set narrowing on the ObjectQL boot loop's SOURCE registrar + (`registerMetadataCollections`), shipped as `minor` under the repo's + launch-window convention for breaking changes. Ruled on #14666 (2026-09-03, + direction 2). + + An aggregated `defineView` container is keyed by the OBJECT it binds to, not + by its own row identity, and `ViewSchema` declares an optional `name` whose + own description says that for an object-scoped container it *is* the object + name. Nothing enforced that. A container written as + `{ name: 'lead_views', object: 'crm_lead', list: { ... } }` therefore reached + the two SOURCE registrars and got opposite answers: this boot loop overwrote + `name` with the derived key `crm_lead` and registered it, discarding the + author's field with no diagnostic, while the artifact/HMR loader + (`MetadataPlugin._parseAndRegisterArtifact`) refused the whole artifact load + through `assertMetadataRegisterContract` (#7378 row 1, `VALIDATION_ERROR` / + 400). Same document, and whether it loaded at all depended on how the package + was loaded. + + The boot loop now **refuses loudly**, with the same `VALIDATION_ERROR` / 400 + envelope the artifact door raises, naming the container's own `name`, the + object key it derived, and both remedies: drop `name`, or set it to that + derived key. #7378 row 1 already ruled that resolving such a disagreement + silently, in either direction, files the item under a key the caller never + wrote, so the two registrars converge on the refusal rather than on the + rewrite; the artifact door is unchanged. + + **Refused shape**, precisely: an aggregated view container in a stack `views:` + collection that carries a non-empty top-level `name` AND derives a different + object key from its own `object` (or, failing that, `list.data.object` / + `form.data.object`). + + Scope, which the ruling names as this change's main risk. A container with no + `name` is untouched, and still registers under its derived key. So is a + container whose `name` already equals that key, and one that declares no + binding anywhere else, since the derivation then falls back to that same + `name` and cannot disagree with itself. No other metadata kind changes + behaviour: the refusal is gated inside the `views` branch of the generic + registration loop. Standalone ViewItems and flattened overlays travelling in + the assembled `viewItems:` channel are untouched, because a container cannot + reach that channel at all. Every one of these has a control test. + + +- 48b0fcf: `@objectstack/objectql` now publishes a recognizer for the org-less system-write refusal, so a consumer no longer has to choose between an unsound check and a re-spelled string. + + `SystemWriteOrganizationRequiredError` has always documented that it is identified by `code` rather than `instanceof`, "so the check survives crossing a package boundary where two copies of this module can exist". The convention was correct; the affordance for following it was missing. This package declares **both** realms in its own `exports` — `import` to `dist/index.mjs`, `require` to `dist/index.js` — so a consumer that loads it through the other realm than the engine did holds a second copy of the module. Measured across that split from a real consumer package: same class identity (`A === B`) **false**, `instA instanceof A` within one realm **true**, `instA instanceof B` across the two **false**, and a `code` compare **true**. So `instanceof` against this class was unsound for every consumer, and it failed silently — a `catch` that simply never fires. + + That left a consumer with one sound option: re-spelling `'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'` as a literal. That spelling is what `check:error-code-provenance` counts as a stamp site, so recognising one engine refusal cost the consumer's package a provenance decision of its own, and left the string spelled in two places with the typo failure mode standing — a typo in a `catch` produces a branch that never fires rather than an error. + + Two new exports close it, both from the package root: + + - **`SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`** — the code as a value. Same shape as this package's five existing published codes (`DUPLICATE_RECORD_CODE`, `HOOK_TARGET_REBIND_ERROR_CODE`, `HOOK_UNSCOPED_DATA_ACCESS_CODE`, `MULTI_UPDATE_HOOK_KEY_DIVERGENCE_CODE`, `EMPTY_CREDENTIAL_REFUSAL_CODE`) rather than a new abstraction. The class field now reads from it, so exactly one spelling of the string remains in the package and a typo at an import site is a compile error instead of a dead branch. + - **`isSystemWriteOrganizationRequiredError(err): boolean`** — the code compare itself, so a consumer performs the sound check without authoring the string at all. + + The predicate deliberately returns `boolean` and does **not** narrow to `err is SystemWriteOrganizationRequiredError`. A `code` compare is satisfied by any value carrying that code, including an envelope a transport rebuilt from the wire — #5437 withholds the prose and keeps the machine-readable code — so a type guard would promise `object`, `posture` and `reason` members such a value need not have, moving the unsoundness one layer down instead of removing it. + + ⛔ Nothing about the refusal itself changes: not its `code`, not its 500 status, not when it fires, and not the #8844 `derive-or-refuse` ruling behind it. `SystemWriteOrganizationRequiredError['code']` stays the literal type it was, which is what the existing cross-package consumer types its own constant from. This is purely an addition to what the package publishes. +- 11f848e: `ReadonlyFieldRejectedError`'s error `code` is now an importable constant. + + The strict-readonly refusal — thrown by `engine.update` and `engine.insert` when `options.strictReadonlyWrites` is set and the payload carried caller-supplied fields the engine would have stripped — already told readers to identify it by `code`. `content/docs/kernel/contracts/data-engine.mdx` says so in its own words: *"Catch it by `code`, not `instanceof`, and read `drops` for the per-reason breakdown"*. Until now the code was an inline string literal with nothing to import, so the only way to FOLLOW that published instruction was to re-spell `'ERR_READONLY_FIELD_REJECTED'` in your own package — which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. + + One new export from `@objectstack/objectql`: + + - `READONLY_FIELD_REJECTED_CODE` — `ReadonlyFieldRejectedError`'s ADR-0112 `code`. + + **Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of the class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary, which is exactly what the documentation has been telling readers to do. + + **Nothing about the wire changed.** The constant holds text byte-identical to the literal it replaces; the refusal throws the same `code` and the same message as before. Consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing. + + **`ReadonlyFieldRejectedError` itself was already exported and stays exported.** Unlike the classes converted alongside it on this sweep, both routes are published here, so the class and the constant must name the same refusal; a test pins that they do. +- e6279dc: The registry's three conflict refusals now publish their error `code` as an importable constant. + + `SchemaRegistry`'s install-time and registration refusals each already told the reader, in their own docblocks, to identify them by `code` rather than `instanceof` — and offered nothing to import. `NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME` and `OBJECT_OWNERSHIP_CONFLICT` were inline string literals, so the only way to follow that instruction was to re-spell the string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. + + Three new exports from `@objectstack/objectql`: + + - `NAMESPACE_CONFLICT_CODE` — the ADR-0048 Phase 1 install-time namespace gate's refusal. + - `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` — the ADR-0130 D3 one-artifact object-name refusal. + - `OBJECT_OWNERSHIP_CONFLICT_CODE` — the ADR-0029 D3 single-owner-per-object-name refusal. + + **Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary. + + **Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; the refusals throw the same `code`, the same `status: 422` and the same message as before. Existing consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing. + + **The error classes stay unexported, deliberately.** Publishing them would publish the `instanceof` route this convention exists to replace. +- ec0a6e7: feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064) + + A roll-up value has three producers — the insert-time seed, the child-write + recompute, and the one-off backfill — and **declaring a summary field on an + object that already has rows reaches none of them**. For `count`/`sum` the + backfill repairs that as a side effect (every `NULL` is a hole to it). For + `min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the + function alone, so "never computed" and "no child rows" were indistinguishable, + the column stayed `NULL` on every pre-existing parent, and the report said + `filled: 0` — a false all-clear that a timed flow built on the column then + turned into "matches nothing" (the customer case behind cloud#1908). + + **What changes** — maintainer ruling on #15064, option A: the caller who holds + the fact gets a way to say it; the predicate and the default run do not move. + + - `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]` — `object.field` + roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is + walked like a `count`: every `NULL` parent is recomputed through the same + `aggregateSummaryValue` the engine writes. A parent whose aggregate is the + empty-set reading (`null` — no child rows) already holds the engine's own + value, so it is neither counted as a hole nor written; the scoped run is + therefore idempotent in the same "re-run until it reports zero" sense. + Naming a `count`/`sum` is accepted and changes nothing, so a publish path can + pass every column it just declared without knowing the empty-set list. + - A name that resolves to no roll-up owned by an object the run walks — a typo, + a plain field, or an object `objects` left out — is **refused before any row + is read**, dry run or apply, with an ADR-0112 envelope (`code: + 'INVALID_FIELD'`, `status: 400` — the code the projection and write axes + that name a field already answer, while sorting keeps `INVALID_SORT`; + `field` names the first unresolved entry, `fields` all of them). A silent + no-op there would be the same false all-clear this option exists to end. + - `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement + of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an + unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'` + to every roll-up function, since a named `max` now appears in `fields`. + - `os migrate summary-nulls --recompute-undefined-on-empty object.field` + (repeatable) passes the scope through; the confirmation prompt names the + columns; `formatSummaryBackfillReport` lists them under "Recomputed on + request" and explains a `NULL` that remains. + + **What does not change:** without the option the walk, the writes, every + counter and the human-readable report are byte-for-byte what they were (pinned + against output captured on `main` before this change); `min`/`max`/`avg` stay + out of scope and keep being reported under `skippedUndefinedOnEmpty`; the + predicate `summaryNullIsBackfillable` is untouched, so `os migrate + summary-nulls` keeps its meaning on every deployment. The only visible delta on + an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`. + + `minor` for both packages: an optional parameter on a published exported + function, a new report key, and a new CLI flag are each a purely additive + widening of a published surface, which takes at least `minor` (bump-level rule, + 2026-09-04); the `fix`-shaped motivation does not lower it. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- eddd612: Both transaction-seam refusals publish their error `code` as an importable constant. + + `packages/objectql/src/transaction-errors.ts` opens by telling the reader that the errors in it "identify themselves by a `code` field rather than by `instanceof`, for the reason `DriverConnectError` already records: the check has to survive crossing a package boundary, where two copies of this module can exist" — and neither of them offered anything to import. The only way to FOLLOW that published instruction was to re-spell the wire string in your own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. + + Two new exports from `@objectstack/objectql`, each graded on its own: + + - `TRANSACTION_UNSUPPORTED_CODE` — `TransactionUnsupportedError`'s ADR-0112 `code`. Thrown by `transaction(cb, base, { require: true })` when the datasource's driver has no `beginTransaction`, refused before the callback runs so nothing has been written. **Additive widening, `minor`.** + - `CROSS_DATASOURCE_TRANSACTION_WRITE_CODE` — `CrossDatasourceTransactionWriteError`'s ADR-0112 `code`. Thrown when a business write inside an open `transaction()` resolves to a driver that transaction does not cover. **Additive widening, `minor`.** + + **The second one is a refusal callers are meant to recover from.** Its own message prescribes the remedy — split the work into per-datasource units and reconcile them explicitly — which is code a caller writes *around* this refusal, and therefore code that has to recognise it first. That recognition now has something to import. + + **Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary, which is what this module's header has been telling readers to do. + + **Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; every refusal throws the same `code` and the same message as before. Consumers that spell the strings themselves keep working unchanged — this adds affordances, it removes nothing. + + **Both classes were already exported and stay exported**, and neither is published from the lean `./core` entry, so the constants join them on the one entry point that publishes them: class and constant are reachable from exactly the same place. +- 6d7d740: `ValidationError` publishes its error `code` as an importable constant — the last row of #16159's census. + + `content/docs/kernel/contracts/data-engine.mdx` teaches the convention: catch an engine refusal *by `code`, not `instanceof`*. Following it for record validation meant re-spelling `'VALIDATION_FAILED'` in your own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. + + One new export from `@objectstack/objectql`: + + - `VALIDATION_FAILED_CODE` — `ValidationError`'s ADR-0112 `code`. Thrown by `validateRecord` when an insert/update payload violates the object's own field metadata, carrying the per-field breakdown on `fields[]`. **Additive widening, `minor`.** + + **This row's consumer-side drift is the widest on the card, and worth stating precisely rather than as a slogan.** `'VALIDATION_FAILED'` is re-authored as an inline literal at **148 non-test sites across 33 files** in this repo — but the honest reading of that number is that the large majority are **independent producers** minting their own house-code envelope (`@objectstack/rest`'s response bodies, `plugin-approvals`' `VALIDATION_FAILED: …` message-prefix convention, `plugin-sharing`'s locally-declared `SharingCriteriaValidationError`, `@objectstack/metadata-protocol`'s own class whose docblock calls the code *"this package's own house code"*). Those are not consumers of this class and nothing about them changes. + + The sites this export actually serves are the **recognizers**, and there are four: `packages/types/src/validation-failure.ts` and `packages/rest/src/error-response.ts` both test `code === 'VALIDATION_FAILED' || name === 'ValidationError'`, `packages/rest/src/error-response.ts` tests the wire body's `code` a second time, and `packages/plugins/plugin-auth/src/objectql-adapter.ts` does the same to map an engine refusal onto a `better-auth` `APIError`. Each holds its own copy of the string. **No consumer is rewired here** — the card's scope is the producer-side importable constant, and re-pointing another package's recognizer is a cross-package coupling this card never asked for. + + **Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of `ValidationError` gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary. + + **Nothing about the wire changed.** The constant holds text byte-identical to the literal it replaces; the refusal throws the same `code` and the same message as before. Consumers that spell the string themselves keep working unchanged — this adds an affordance and removes nothing. + + **It does not converge `VALIDATION_FAILED` with `VALIDATION_ERROR`.** `EMPTY_CREDENTIAL_REFUSAL_CODE` in the same package is `'VALIDATION_ERROR'`; #16159 explicitly leaves *"whether they should converge"* unruled, and publishing the current spelling keeps that decision exactly as open as it was — a convergence is a breaking rename of a registered wire code either way. A pin test asserts the two are still two, so a future ruling has to argue for itself rather than arrive as a side effect. + + **`ValidationError` was already exported and stays exported.** The constant joins it on the batteries barrel only, matching every existing `*_CODE` in this package; the class is *also* on the lean `./core` entry, so this adds one more instance to the asymmetry #16260 owns — deliberately not decided here. +- f7ffbd6: `accept-language: zh` now reads a Chinese refusal on the response whose labels are already Chinese. + + `@objectstack/spec` has one locale-negotiation rule — `resolveBundleLocale`: exact match, then case-insensitive, then base language, then **variant expansion**, which is the step that reaches a `zh-CN` bundle from a bare `zh`. `pickData` calls it, and every document translator (`translateObject`, `translateView`, `translateDataset`, …) goes through `pickData`. That is why an app shipping only `zh-CN` still answered `accept-language: zh` with translated object, view and dataset labels. + + The write path's message bridge was the one consumer that never negotiated. `ExecutionContext.locale` is the header's first tag verbatim — `preferredLocaleFromHeader` reports what was *asked for* and expands nothing, deliberately, because each of its callers negotiates differently — and the engine handed that tag straight to `II18nService.t()`. A served adapter resolves a locale exactly and then falls to its declared fallback (`FileI18nAdapter.t()` is `resolveFromLocale(key, locale)` then `resolveFromLocale(key, fallbackLocale)`), so `zh` missed the `zh-CN` bundle and the English text came back. The result was a half-translated response an app had no way to see coming: the bundle key was present and correct and the coverage gate was green. + + `ObjectQL`'s validation-message context now resolves the requested tag against what the bridged service reports it holds (`II18nService.getLocales()`), through that same `resolveBundleLocale`. The rule is not re-implemented in the engine — the document translators ask it about a bundle's keys, and this asks it about the service's locales. Authored `objects.._validations..message` text, `validation.field.*` overrides and translated field labels all follow, because they read one locale. + + Unchanged: **which** writes are refused, and everything machine-readable about a refusal — the `code`, the `field`, the `constraint`, the status. Only the language of the sentence moves. `preferredLocaleFromHeader` is untouched, and so is every other caller of it. A request with nothing to negotiate against — no i18n service, a service that cannot report its locales, or a tag no variant of which is on offer — passes through exactly as before. + + `ObjectQL.setI18nService` accepts an optional `getLocales?: () => string[]` alongside `t`. `II18nService` has always required `getLocales()`, so every real service already satisfies it; a partial shim that omits it keeps today's behaviour rather than being negotiated against. + +### Patch Changes + +- 7079694: fix(objectql): the audit binder stamps `created_at` from the system clock on an ordinary create, so a caller-supplied value no longer survives a plain `POST` (#15964) + + The `beforeInsert` audit stamp was `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. Since the static-`readonly` strip moved INSIDE `engine.insert` (2026-09-03 ruling, option C) it runs AFTER the before-phase hooks, and its guard treats a key a `beforeInsert` hook ASSIGNED as the hook's write rather than a caller forgery. The `??` therefore laundered the caller's bytes past that strip: a normal authenticated `POST /api/v1/data/OBJECT` carrying `created_at: '1999-01-01T00:00:00.000Z'` stored exactly that on an object declaring `created_at` as `readonly: true`, while `id`, `updated_at` and every other author-declared readonly datetime in the same payload were taken. `created_at` is the audit anchor, so a forgeable one makes after-the-fact attribution untrustworthy. + + The stamp now takes the same shape as `updated_at`: + + ```ts + record.created_at = preserveAudit ? (record.created_at ?? now) : now; + ``` + + **What changes for a caller.** An ordinary create no longer preserves a supplied `created_at` — the value is overwritten with the server instant rather than deleted, so the column is still a real stamp and no `defaultValue` re-derivation is involved. This narrows the accept set to the `readonly` contract the field already documents; no exported symbol, schema or config key moves. + + **The historical-import channel is unchanged and pinned.** `runImport({ treatAsHistorical: true })` sets `preserveAudit: true` on the write context (`@objectstack/rest`), and that branch still reinstates an original `created_at`, exactly as it has reinstated `updated_at`/`updated_by` since #3493. This is why the fix is the `preserveAudit` ternary rather than a bare `= now`. The create-side strip still does not read `preserveAudit` (2026-08-08 ruling, untouched): the preservation is the audit binder's, and it always was. + + **A creator that back-dated rows through the old `??` must now ask for it.** Any insert path that supplied a historical `created_at` without `preserveAudit` now gets the server instant. The remedy is one context key on the write (`preserveAudit: true`), the same one `treatAsHistorical` sets. + + Ruled by the maintainer on 2026-09-06 (decision batch #54, option A). +- 7783738: fix(objectql): the audit binder stamps `created_by` from the session on an ordinary create, so a caller-supplied value no longer survives a plain `POST` (#16311) + + The `beforeInsert` audit stamp was `record.created_by = record.created_by ?? session.userId` — client-preferred on every insert, with no flag and no privilege required — while its sibling one line down was already the `preserveAudit` ternary. Since the static-`readonly` strip moved INSIDE `engine.insert` (2026-09-03 ruling, option C) it runs AFTER the before-phase hooks, and its guard treats a key a `beforeInsert` hook ASSIGNED as the hook's write rather than a caller forgery. The `??` therefore laundered the caller's bytes past that strip: an authenticated `POST /api/v1/data/OBJECT` carrying `created_by: 'forged_user'` stored exactly that, on an object whose `created_by` is the registry-injected `AUDIT_FIELD_DEFS` shape (`readonly: true`), while `updated_by` in the same payload was correctly overwritten with the session user. A row could claim it was created by a user who did not create it — audit integrity, not privilege escalation. + + The stamp now takes the same shape as `updated_by`, one field over, and the same shape #15964 landed for `created_at`: + + ```ts + record.created_by = preserveAudit ? (record.created_by ?? session.userId) : session.userId; + ``` + + **What changes for a caller.** An ordinary create no longer preserves a supplied `created_by` — the value is overwritten with the session user rather than deleted, so the column is still a real attribution stamp. This narrows the accept set to the `readonly` contract the field already documents; no exported symbol, schema or config key moves. + + **The session-less insert is deliberately unchanged, and that is load-bearing.** Both audit-user assignments stay inside `if (session?.userId)`. With no session the hook assigns nothing and the engine's readonly strip takes the caller's value, so the key is absent — already the correct outcome today, reached by a different path. A shape that assigned `session.userId` unconditionally would write `undefined` into the key, making it one the hook "wrote", and the strip would then spare it: a branch that is correct today would become a new hole. That row is pinned. + + **The historical-import channel is unchanged and pinned.** `runImport({ treatAsHistorical: true })` sets `preserveAudit: true` on the write context (`@objectstack/rest`), and that branch still reinstates an original `created_by`, exactly as it has for `updated_by` since #3493. This is why the fix is the `preserveAudit` ternary rather than a bare `= session.userId`. + + **A creator that supplied a non-session `created_by` under an authenticated session must now ask for it** via `preserveAudit: true`. Creators that write an arbitrary `created_by` through a session-less system context (`{ isSystem: true }` with no `userId`) are untouched: the hook never entered that branch before this change either, and the `isSystem` strip exemption is what carries their value. +- 4b3955e: fix(objectql): a published `BulkDataEvent` now names the ONE organization the tenant wall named for the batch + + `BulkDataEventSchema.organizationId` (`@objectstack/spec/api`, declared by the + contract half) is one organization for a whole predicate write, or absent. The + only bulk producer — `publishBulkDataEvent`, behind the `multi: true` branches + of `update()` / `delete()` — never set it, so every `data.records.updated` / + `data.records.deleted` event read "not asserted" and a tenant-scoped consumer + could deliver nothing per organization on the bulk path. This is the bulk half + of the cross-tenant webhook fan-out leak; the single-record half (`DataEvent`) + landed separately. + + The producer now stamps the key from what it already holds — no second query + on the publish path: under `isolated` the caller's active organization (the + Layer 0 wall's equality term), under `group` the caller's membership set when + it names exactly one organization. It is OMITTED — never the caller's active + organization standing in — on a `single`-posture deployment, on an `isSystem` + context (no wall composed), on a multi-membership `group` sweep, when no + enforcement layer injected a posture (the `OS_TENANCY_POSTURE` env fallback is + deliberately not consulted), when the caller may have crossed the wall as a + `PLATFORM_ADMIN` or carries no resolved posture rung, and on an object the wall + does not key on. `absent` here means "the producer did not assert one + organization for the batch", deliberately NOT the `DataEvent` reading + "belongs to no organization". + + Which objects "the wall does not key on", stated exactly rather than claimed as + a mirror: plugin-security's Layer 0 composes no wall when its `tenancyDisabled` + input is true or the object carries no `organization_id`, and it folds THREE + clauses into `tenancyDisabled` — `tenancy.enabled === false`, + `systemFields.tenant === false`, and the deployment's `platformGlobalObjects` + carve-out. The producer reads the registry's binding of that predicate + (`carriesTenantScopeColumn`: the first two clauses plus the column clause) and + answers absent on a federated (`external`) object; a custom + `tenancy.tenantField` is therefore not an exit by itself — the object is walled + iff it carries `organization_id`, and the key follows the wall. The third + clause is deployment-declared and not readable by the engine: a + deployment-exempted object under an armed wall is still stamped with the + caller's organization by this producer alone, and that population's exact + answer is decided by the seam ruled on in #15706. + + `patch`, not `minor`: the act adds no member to this package's published + surface. `carriesTenantScopeColumn` is exported at module level inside + `registry.ts` only — `@objectstack/objectql`'s entries (`.`, `./core`) re-export + named members and never `export *`, so `dist/index.d.ts`, `dist/core.d.ts` and + both entries' runtime export lists are unchanged (measured on the built `dist`, + with a firing control) — and the emitted event's member was declared, typed + and paid for at `minor` by the spec half. Producer conformance to an existing + optional member under `fix(` changes no public surface of this package. +- 65846bc: fix(objectql): `DuplicateRecordError.developerMessage` names the wire spelling a client branches on (#14723) + + The envelope's `developerMessage` — the remedy sentence addressed to the + application author — told its reader to "branch on `code === 'DUPLICATE_RECORD'`", + which is the engine's THROWN identity and holds only for an in-process caller of + `engine.insert` / `engine.update`. Every REST route reports the same refusal as + `UNIQUE_VIOLATION`, and since #14723 the per-row reports of the batch and import + surfaces do too, so the sentence was a platform contradicting itself on the one + line an author is most likely to copy. It now says both halves: over the HTTP + API branch on `code === 'UNIQUE_VIOLATION'` on every route, whole-request and + per-row alike; inside the engine the thrown class carries `DUPLICATE_RECORD`. + The class's own docblock says the same. Nothing else about the envelope moves: + `code`, `status`, `cause`, `field`, `object` and the user-facing `message` are + byte-identical, and every pin on the engine's thrown code holds. +- 316a20f: The boot-time action-governance audit now reaches a SCOPED `metadata` service. + + `ObjectQLPlugin.runGovernanceInventory` acquired its metadata plane with + `ctx.getService('metadata')`, which reads only the two synchronous service maps. + A composition that registers `metadata` with `ServiceLifecycle.SCOPED` mints its + instances into `PluginLoader.scopedServices` instead, so the call threw + `Service 'metadata' is async - use await` before `loadMany`, `loadManyKeyed`, + `loadDiagnosed` or `load` could run, the plugin swallowed the throw into "no + metadata plane at all", and the ADR-0110 D5 inventory reported that scope's + `action` declarations as absent — silently, because an empty declaration set is + indistinguishable from a plane that holds nothing. On such a kernel a handler the + router dispatches was reported as "registered handler with NO declaration … + REFUSED at dispatch". + + The plane is now resolved in the router's own order — `getServiceScoped('metadata', + environmentId)` first, then the synchronous lookup — so the audit holds the same + instance `HttpDispatcher.resolveService` hands the router. Statically registered + planes (every shipped composition today) resolve to the same object as before. +- 25a3d91: Stop reporting a declarative `operation: 'update'` action as "a button wired to nothing" + + The boot action-governance inventory (ADR-0110 D5) built its `unboundDeclarations` + finding from a `type`-only test. The declarative single-record field write + (`operation: 'update'` + `patch`, #14092) is exactly the shape that test mistakes + for a dead button: `ActionSchema` refuses `target` and `body` beside it and keeps + `type` at its default `script`, because the platform action route is where the + write is performed. Every such action was named at every boot and every + `metadata:reloaded` — with a prescription ("add a `body`, or register a handler + under the declared `target`") that parse itself refuses. + + Both readers now read `operation` before `type`, the precedence the runtime doors + already use: the engine inventory, and the authoring-time AI tool-reference rule, + which had diverged from the runtime's listing door and reported a resolvable + `action_` reference as fictional. +- 3bd9b34: feat(metadata): `deriveViewContainerObject` gets a leaf `/view-container` subpath, so objectql's lean ADR-0076 entry stops loading the manager, chokidar, glob and js-yaml for a six-line pure function + + `packages/objectql/src/engine.ts` reached `deriveViewContainerObject` through + `@objectstack/metadata`'s ROOT entry. `core.ts` — the ADR-0076 lean entry — + re-exports `engine.ts`, so `@objectstack/objectql/core`'s module-init closure + inherited the whole root entry: `MetadataPlugin` -> `NodeMetadataManager` -> + `chokidar`, plus `glob`, `js-yaml` and `readdirp`. + + The same file already carried the answer 79 lines above, at its + `@objectstack/metadata/errors` import: that leaf subpath exists "precisely so a + cross-package consumer gets the predicate without the manager, the loaders or + the YAML/filesystem machinery behind the root entry". This is that pattern, + taken a second time. + + **Measured on the built artifacts, not asserted** — every module Node actually + evaluates when `@objectstack/objectql/core` is loaded in a fresh process, + recorded through a `module.registerHooks` load hook (ESM and CJS) plus + `require.cache`, byte sizes from `statSync`: + + | `@objectstack/objectql/core` | modules | bytes | + |:---|---:|---:| + | before (ESM `dist/core.mjs`) | 190 | 12,348,424 | + | after (ESM `dist/core.mjs`) | 185 | 11,849,808 | + | **delta** | **-5** | **-498,616 (-486.9 KiB)** | + | before (CJS `dist/core.js`) | 188 | 12,654,238 | + | after (CJS `dist/core.js`) | 183 | 12,141,034 | + | **delta** | **-5** | **-513,204 (-501.2 KiB)** | + + Six modules stop loading — `packages/metadata/dist/index.js` (237,747 B), + `js-yaml` (114,610 B), `glob` (82,749 B), `chokidar` (2 files, 54,220 B) and + `readdirp` (9,836 B) — and one 469-byte module takes their place. Marginal + module-init time for that root entry, measured on a warm lean closure, was + ~22 ms (median of 7; 20.4-27.5 ms) out of ~630 ms. + + ⚠️ The figure the finding was argued on — "~3.6 KB to ~450 KB" — is right about + the delta and wrong about the baseline: the lean entry's closure was already + ~11.5 MiB before this import existed, dominated by `@objectstack/spec` + (9,587,914 B) and `zod` (567,918 B), neither of which the metadata root entry + contributes. What the root import cost was ~487 KiB *on top of* that, not a + closure of 450 KB. + + The derivation itself moves to `packages/metadata/src/view-container.ts`, a + module with **no imports at all**, and `view-container-expansion.ts` imports + and re-exports it, so `index.ts`'s root export and `plugin.ts` keep their + spelling and the symbol stays on the root entry — this subpath is an additional + door, not a relocation. A re-export shim onto `view-container-expansion.ts` was + tried first and rejected on measurement: esbuild tree-shakes the unused + `expandRuntimeViewContainer` but keeps its two `@objectstack/spec` import + statements, so that shim's own closure was 84 modules / 3,035 KiB. The real + leaf's is 1 module / 469 B. + + `expandRuntimeViewContainer` is deliberately not exported from the new subpath: + `metadata-manager.ts` is its only caller, the root entry does not export it + either, and it is the half that carries the spec machinery. +- e9fcd6b: fix(objectql): the declarative hook wrapper reads the renamed `hook.timeoutMs` (#14478) + + `wrapDeclarativeHook` reads its wall-clock abort budget from `meta.timeoutMs` + instead of `meta.timeout`, following the `@objectstack/spec` rename of the + authored key (the unit now lives in the key name). Same value, same magnitude, + same abort; no public surface of this package changes. +- 26144c2: The platform-object tenancy census is derived and gated instead of hand-written in a comment. Documentation only — no runtime behaviour changes. + + `PLATFORM_OBJECT_TENANCY`'s header explained why the reclassification needs a ledger rather than a schema read, and backed the argument with three hand-written digits and a parenthetical attributing them. Nothing re-derived any of it, so it was true only until the population moved and failed silently when it did — in both of the directions a prose count can. + + The parenthetical mis-attributed the exclusion: it named `sys_sso_provider`'s `tenancy.enabled: false` as an addition to the `managedBy: 'better-auth'` set that object was already in, and left `sys_api_key`'s identical opt-out unnamed. The arithmetic stayed right, which is why no reader and no gate caught it — a wrong reason producing a right total is the shape that survives longest. The digits then went stale when an object opted out of the tenant column through a third mechanism the parenthetical's taxonomy had no slot for (`systemFields: { tenant: false }`), while the gated page next door was updated in the same commit. + + The digits and the parenthetical are deleted rather than corrected. The header now points at `scripts/platform-object-tenancy-census.json` and states the PREDICATE it was missing: an object is inside the machinery when `resolveTenantFieldName` answers non-null on the **registered** schema — after `applySystemFields` has injected the tenant column, because the injected column is what the engine sees, not what the author typed. Counting `managedBy` as if the resolver read it is the mistake that produced the wrong reason. + + The artefact is derived by `scripts/platform-object-tenancy-census.mjs`, which loads `resolveTenantFieldName` and `resolveInjectedSystemColumns` from source and executes them rather than re-spelling what they decide, and is held to the tree by `scripts/check-platform-object-tenancy-census.mjs`. It records per object the declaration on that object's own schema that puts it outside the reach; declarations are not mutually exclusive and an object carrying two keeps both. An excluded object with no declared mechanism is an error, not a default: the generator refuses to commit the row and the gate reds, so a new exclusion mechanism is adjudicated rather than absorbed into an existing total. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- d61d6e3: `os migrate value-shapes` now prescribes the key rename on a legacy `{latitude, longitude}` location, instead of reporting the missing-pair type error. + + A value-shape rejection was read positionally — `parse.error.issues[0]` — at both places the value-shape detail is produced: the write path's warn-first / strict branch, and the exported `valueShapeViolation` the scan imports. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a value whose keys were **renamed** the actionable message sorts last and was discarded. A `location` stored as `{latitude, longitude}` — the exact legacy shape the scan's own header names as one it exists to find — reported `Invalid input: expected number, received undefined`, leaving an operator to derive a rename that edit distance cannot reach (`latitude` -> `lat`), while `LocationValueSchema` had built the prescription and thrown it away. + + Both readers now prefer the undeclared-key issue when the rejection carries one, through a single shared helper — two readings of the same rejection drifting by one clause is how one path prescribes the rename and the other does not. The affected strings are the `os migrate value-shapes` finding `detail`, the warn-first `[value-shape]` log line, and the `invalid_value_shape` error's `detail` under strict enforcement. + + ⛔ No verdict moves. The same values are flagged, the same writes are rejected or admitted, and the deployment gate opens on exactly the same evidence — only the operator-facing text changes. + + Scoped by measurement rather than by assumption: of the sixteen types these readers cover, only `location` and `address` are backed by a key-closed object schema, so only they can emit `unrecognized_keys` at all — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half. The defect reaches `address` as well as `location`: every address member being optional rules out a *missing*-member type error, but not a *wrong-typed* declared one, which still sorts ahead of the undeclared-key issue. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [a56baa2] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [65846bc] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [3e7ef9c] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [e1d4f9e] +- Updated dependencies [f502898] +- Updated dependencies [c383352] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [6f23f0e] +- Updated dependencies [b4b37e5] +- Updated dependencies [ba426b0] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [efc5447] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [618f70d] +- Updated dependencies [4b0508e] +- Updated dependencies [460d4b8] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [615fac3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [ce478db] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [7d711c9] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/metadata-protocol@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 2f10bb0fd1..97cd5de946 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.3.0", + "version": "17.4.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 abc6646646..2a173981aa 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,130 @@ # @objectstack/observability +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 9a7b7963c7..e9dde2649c 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.3.0", + "version": "17.4.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 5c6a352b30..a82b5955b9 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,501 @@ # @objectstack/platform-objects +## 17.4.0 + +### Minor Changes + +- 4ca358d: `sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out + + Removing a member deleted the `sys_member` row and left the session alive, for up to seven + days. #15409 closed the security half per request (a session whose `activeOrganizationId` + is not backed by a membership resolves with no active organization). This is the courtesy + half an admin was promised, and it is **never the enforcement**: a trigger can be missed, + an evaluation cannot. + + - **New `revoke_reason` value, `organization_membership_ended`** — an accept-set widening + on a published system object, hence `minor` on `@objectstack/platform-objects`. Every + reason before it is a timer (`idle_timeout`, `absolute_max`, `concurrent_cap`) or an + interactive revoke (`user_revoked`, `admin`); this is the first authorization-event + cause. There is no Zod enum behind the column — it is free `text` — so the field's own + description is the published vocabulary, and that is where the value is declared. The + string deliberately matches the one the API-key arm of the same ruling family already + mints for this event (`ApiKeyRefusalReason` in `resolve-authz-context.ts`), so one grep + finds every place the platform acts on a membership ending. + - **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling, + decision batch #49 item 4, option B). A user who still holds another membership is + **re-pointed** to it — never signed out of organizations they legitimately belong to. A + user with no remaining membership has their session revoked through the existing + `revoked_at` / `revoke_reason` mechanism, which expires it in place: better-auth returns + nothing on the next request and the Console's existing 401 → login redirect handles it, + with **no client change**. + - **The seam is an engine hook on `sys_member`**, not a hook on better-auth's + `/organization/remove-member`. A census measured that the endpoint, a direct delete, a + bulk delete, the cascade from a `sys_user` delete and an organization re-point all reach + the hook, while an endpoint hook would have reached one of them. Same precedent as + `last-admin-guard.ts`. + - **New public surface on `@objectstack/plugin-auth`** — `MEMBERSHIP_ENDED_REVOKE_REASON`, + `endSessionClaimsForEndedMembership` and `registerMembershipEndedSessionTrigger`, hence + `minor` rather than `patch`. + + Known open by measurement, not by omission: a raw driver delete bypasses the trigger + entirely, and cloud's package-uninstall sample-data purge is one (filed as cloud#2003). The + per-request check covers it; the courtesy does not. +- 6acb37e: feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) + + + + Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238. + + **The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC. + + **What lands.** + + - `sys_business_unit.timezone` — `text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`. + - `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`. + - plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry. + + **The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written. + + **Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned. + + **One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately. + + **Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question. +- 7797102: `sys_organization` admits generic `update` on the data door, column-gated by the ADR-0092 D2 identity write guard (#15873 — maintainer ruling 2026-09-07, decision batch #64, option (a), verbatim 「同意」). + + The organization table carries four platform-owned columns better-auth never reads or writes — `require_mfa` (ADR-0069 D3), `parent_organization_id` and `sort_order` (ADR-0105 D6), `timezone` (#14238). plugin-auth declares them generically editable (`MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization`, the guard's per-object update whitelist), while the object's `enable.apiMethods: ['get', 'list']` answered every `PATCH /api/v1/data/sys_organization/:id` with 405 `OBJECT_API_METHOD_NOT_ALLOWED` before the engine — and the guard — was reached. Declared editable, reachable from no product surface: the columns could be set only by a system-context caller. The ruling answers the card's question — yes, an administrator sets these columns through the product — and refuses the alternative of declaring them system-writable only. + + What widens (Clause ②) — three published surfaces move, all column-clamped by the same guard: + + 1. The data door's accept set. `enable.apiMethods` becomes `['get', 'list', 'update']`, and `userActions: { edit: true }` declares the affordance ADR-0103 D3's `reconcileManagedApiMethods` requires before it lets a `managedBy` object keep a write verb at registration (without it the verb is stripped with a warning and the door keeps answering 405 — the second silent gate #7727 measured on `sys_api_key`). `PATCH /api/v1/data/sys_organization/:id` is admitted; `create` / `delete` still answer 405; `bulk` (`/batch`, the `*Many` routes) is not granted (recorded in `SINGLE_RECORD_WRITE_ONLY`). + 2. The derived `import` door. `API_METHOD_DERIVATION` (`@objectstack/spec` `api-derivation.ts`) derives `import` from `any: ['create', 'update']`, so granting `update` admits `POST /api/v1/data/sys_organization/import` (and the async `/import/jobs` route) in `writeMode: 'update'` — one request updates N rows, each row clamped per row by the ADR-0092 D2 guard under the caller's context (a row carrying only better-auth columns is refused `PERMISSION_DENIED`; `treatAsHistorical` does not elevate). Insert-mode and upsert-mode import stay 405 (the conjunct named is `create`). The door's own 405 envelope advertises the derived set in `allowed`. + 3. `/auth/me/permissions`. For a principal the permission layer already admits (the seeded platform admin's `admin_full_access` wildcard), `sys_organization.allowEdit` goes `false → true` (`clampManagedObjectWrites` reads `userActions.edit` for the `better-auth` bucket) and `apiOperations` gains `update` and `import` (`annotateEffectiveApiOperations`) — the payload the console renders its edit affordance from. `organization_admin` / `member_default` stay hard-denied on every better-auth table by `managed-object-write-denies.ts`, unchanged. + + What does not widen: the column set. The guard clamps every user-context update on this table to the whitelist. A PATCH of a better-auth column sent alone (`name`, `slug`, `logo`, `metadata`) is now refused by the guard's own verdict — 403 `PERMISSION_DENIED` — instead of the method gate's 405; sent beside a whitelisted column it is stripped and the whitelisted column lands. better-auth's own columns keep changing through better-auth's `organization/update` (the `update_organization` row action, unchanged). Per ADR-0092 D4's form-rendering constraint the four better-auth columns are now `readonly: true` on the object, so a standard edit form offers exactly what the guard admits; the engine's static-readonly strip exempts system-context writers, so better-auth's adapter is unaffected. + + Not breaking: no key, export or accepted value is removed; every request that succeeded before succeeds unchanged, and the 405 → 403 change applies only to requests that were refused before and are refused still. +- 0a038cc: feat(platform-objects): `sys_job.timezone` and `sys_report_schedule.timezone` are validated against the IANA domain (#15872) + + **BREAKING** accept-set narrowing on two published columns, shipped as `minor` + under the repo's launch-window convention for breaking changes. Both columns now + declare `valueDomain: 'iana_time_zone'`, so a value the shipped build stored + without complaint is refused from this release on. During the launch window the + bump level is not the carrier of breaking-ness and says nothing about whether a + release breaks you; this banner is the carrier, and the ADR-0087 disposition at + the foot of this changeset is the other one. + + **What stops being accepted.** A write to either column is now refused with the + ADR-0114 field error code `value_domain` unless the value is a member of the + IANA/tzdb set, tested with the `Intl.DateTimeFormat` probe. Three classes of + string that the previous build accepted are outside that set: + + - **UTC-offset spellings** — `UTC+8`, `GMT+0800`, `+08:00`. They name an offset, + not a zone, and no offset spelling is an IANA identifier. The tzdb's own + fixed-offset zones are members and keep working: `Etc/GMT-8` is accepted. + - **Windows / CLDR display names** — `China Standard Time`, + `Pacific Standard Time`. That is the Windows time-zone vocabulary, a different + naming scheme from tzdb, and no member of it is a tzdb identifier. + - **shape-valid identifiers for zones that do not exist** — `Mars/Olympus`. A + `Region/City` pattern cannot separate an unassigned identifier from a real + one; membership can, which is what the domain is for. + + **What keeps working.** Every genuine IANA identifier, including `UTC` — the + membership predicate is the `Intl.DateTimeFormat` probe, deliberately not the + `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC`. That + matters here rather than academically: `'UTC'` is `sys_report_schedule.timezone`'s + own declared default. + + **Stored rows are unaffected — only writes are judged.** No upgrade step, no + backfill, no DDL. In the published words of the contract this declaration is + governed by (`packages/spec/src/data/field.zod.ts`, the `valueDomain` description): + + > Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate + > class): a stored value outside a domain declared later is never re-read and + > survives unrelated edits — only a write carrying a non-member is refused, with + > the field error code `value_domain`. + + So a deployment already holding `UTC+8` in one of these columns keeps it and + reads it back unchanged; what changes is the next write. The one thing to know + before upgrading is therefore an authoring fact, not a data-at-rest one: a + producer that writes one of the three spellings above starts getting a refusal + where it previously got a success, and for `sys_report_schedule` that refusal is + the point — see the behaviour note below. + + The platform's two oldest IANA time-zone columns predate `valueDomain` and disagreed with each other in three dimensions at once — length (100 vs 64), default (none vs `'UTC'`) and validation (neither). This closes the third: both now declare `valueDomain: 'iana_time_zone'`, the same declaration and the same `Intl.DateTimeFormat` membership probe that `sys_business_unit.timezone` and `sys_organization.timezone` carry (#14238). Four columns, one spelling of "is this a real zone". + + **What it was worth, measured before the fix rather than assumed.** The two columns are not equally exposed, and only one of them was dangerous. + + - `sys_report_schedule.timezone` is read back and handed to a scheduler. `ReportService.nextRunAt` calls `new Cron(cron, { timezone }).nextRun(from)`, and croner does not reject a non-member zone at construction when there is no callback — it throws from `nextRun()`. That throw was caught and turned into a fall back to `interval_minutes`. So a typo'd zone silently discarded the cron expression: an admin's "every weekday 09:00 Asia/Shanghai" became "every 1440 minutes, forever", logged only as `invalid cron ''` — a warning naming the wrong input, because the expression was fine. Not a throw and not a fall back to UTC: the wrong instant, permanently. Refusing the write is what closes it. (`scheduleReport`'s eager create-time guard did not catch it either: it constructs a callback-less `Cron` and is blind to exactly this half of its own input. That is a separate defect in `plugin-reports`, carded, not fixed here.) + - `sys_job.timezone` is written and never read. `DbJobAdapter` mirrors the in-memory schedule onto the row; its three `sys_job` read sites take `id` / `run_count` / `failure_count` only. The zone the scheduler honours never travels through this column, and `DbJobAdapter.schedule` awaits the cron adapter before it upserts the row, so a non-member cannot even reach the column that way — croner constructed WITH a callback throws, and `AppPlugin` reports it as `Background job FAILED TO SCHEDULE — it will never run`. The door this declaration closes there is the other one: a direct write from Studio, REST or a script, which had no validation at all. + + **What is deliberately NOT converged**, and is pinned so that staying unconverged is a decision rather than a drift someone repairs by reflex: + + - **the defaults still differ.** A default here is a consumer semantic, not a shape question. `sys_report_schedule` documents and implements a UTC default; `sys_job` has no reader at all, and minting one would change what an unset row means. + - **the bounds still differ (100 vs 64).** `maxLength` is not only a write bound — it reaches DDL, and narrowing a physical `varchar(100)` is `driver-sql`'s `narrow_varchar` op at severity `error`, category destructive ("narrowing may truncate"). What the column physically holds in a deployment is not readable from the repo, so the convergence is a separate decision and #15872 stays open on it. Note what the domain declaration already costs the wider bound: no member is longer than 32 characters on the current Node baseline, so 100 now admits nothing 64 would not. + + +- a83482c: The three platform record pages carry a translated label in every shipped locale. + + `sys_user_detail`, `sys_organization_detail` and `sys_position_detail` each declare a page-level `label` — `User`, `Organization`, `Position` — and those three strings rendered in English in every locale, including `zh-CN`, `ja-JP` and `es-ES`. They are the only keys on those pages the extractor reaches: all three author `regions: []`, so the shared walk (which roots at `regions[].components[]`) finds nothing else, and their other 45 authored copy sites are inline locale maps under `slots.*` that already carry all four locales. + + `SetupAppTranslations` now declares a `pages.*` entry for each of the three in all four locale files, so `translatePage` overlays the page label the same way it already does for the plugin-carried Setup pages. Their recorded source hashes are added alongside (`.source-hashes.ts`), so a later edit to one of the English literals marks the translations stale instead of serving a translation of a string that no longer exists. + + Nothing about the pages' shape changed: `label` is still the only key the extractor offers them, and the inline maps under `slots.*` are untouched. + +### Patch Changes + +- 159dbad: `attestFreshDatastore` looks its `os migrate` remedy up instead of defaulting it + + When a fresh datastore's own boot has already admitted a value that contradicts a + migration's contract, that id is not attested and the operator is told what closes + the gate on real evidence. The sentence used to be built from a two-way branch: the + file-references id got `files-to-references`, and **every other id** got + `value-shapes` by default. + + `CREATION_ATTESTED_MIGRATION_IDS` has three members. For the third — + `adr-0030-notification-event` — that default is a wrong prescription: `os migrate + value-shapes --apply` neither attests nor clears it, and there is no `os migrate + notification-event` sub-command to send an operator to at all (that cut-over is an + operator call with no self-check). + + The branch is now an explicit id-to-remedy register, total over the ids a + value-shape tally can contradict. The loop asks it rather than falling into an arm, + so an id with no value-shape contract is never-contradictable by that evidence and + is attested on the birth observation as before. A new member therefore inherits no + remedy: adding a third arm that happened to be right today would only have moved the + same defect onto the fourth member. + + No behaviour changes for the two ADR-0104 ids, which is where every reachable path + runs today: the shipped engine keys its admitted-violation tally from a closed + `'media' | 'value-shape'` union, so it cannot name a third id. +- 85a2459: fix(spec): the dashboard `gap` field no longer describes itself to app authors in Tailwind vocabulary + + `ui/dashboard`'s `gap` key told app authors its value in the vocabulary of a CSS + library they never chose and cannot act on. **Two** independent producer strings + carried that wording, and they feed two independent customer-facing surfaces: + + - `dashboardForm`'s `helpText` — `Grid gap (Tailwind units)` — rendered verbatim in + the Studio property panel, which is spec-driven and feeds this form straight into + the generic form renderer. + - `DashboardSchema.gap`'s `.describe()` — `Grid gap in Tailwind spacing units` — + rendered as this field's row in the published reference page + `content/docs/references/ui/dashboard.mdx`. The reference corpus renders + `.describe()`, never `helpText`. + + Both now read **Space between widgets, in steps of 0.25rem (4 = 1rem)**: what the + author decides, plus the magnitude, stated in a CSS unit instead of a framework's + scale. The magnitude had to survive the rewrite rather than be dropped with the + framework name — the number is a spacing step, so `4` means `1rem` and not `4px`, + and an author who lost that would come away knowing less than before. + + The step size is stated as measured rather than inferred: the dashboard renderer + sets the grid gap as an inline style computed from this key, so every accepted + value is linear and one step is exactly `0.25rem`. "Tailwind units" was doubly + wrong — it named an implementation dependency, and it named one the consumer of + this key does not have. + + **No schema change.** `gap` stays `z.number().int().min(0).optional()` and accepts + exactly what it accepted before; nothing is added to or removed from any public + surface. `columns` is deliberately untouched on both of its producer lines — + `12` is an author-visible fact about the grid being laid out, not a framework + detail — and this is one field's two strings, not a sweep for framework words. + + The `en` metadata-forms translation bundle is a mechanical copy of the form source, + so it is regenerated to match. Translated locales are not touched: regeneration + fills gaps only and never overwrites an existing leaf. +- cbca47d: fix(platform-objects): the es-ES and ja-JP `dashboard.gap` help text says what its source now says + + `metadataForms.dashboard.fields.gap.helpText` read `Separación de cuadrícula (unidades + Tailwind)` in es-ES and 「グリッド間隔(Tailwind 単位)」 in ja-JP. Both were faithful + translations of the source they were extracted against, `Grid gap (Tailwind units)` — but + that source has since been rewritten to `Space between widgets, in steps of 0.25rem + (4 = 1rem)`, which deliberately drops the CSS framework unit an app author never chose and + cannot act on, and adds the magnitude the author can size a dashboard with. + + Both leaves kept the retired vocabulary and never gained the magnitude, because bundle + merge fills gaps only: a present-but-stale leaf is not a gap, so no amount of + re-extraction corrects it. They now read `Espacio entre widgets, en incrementos de 0.25rem + (4 = 1rem)` and 「ウィジェット間の間隔、0.25rem 刻み(4 = 1rem)」 — the grid framing is gone + exactly as it is upstream, `widgets` / 「ウィジェット」 is the word each bundle already uses + for dashboard widgets, and the conversion is carried so a Spanish- or Japanese-reading + author can size `gap` without reading the English. + + Two leaves. `columns` is unchanged upstream, so `Columnas de cuadrícula (predeterminado + 12)` and 「グリッド列(既定 12)」 stay accurate, and the other 13 source-derived prose leaves + of this subtree (5 section descriptions plus 8 further field help texts) were read against + the current English and are accurate in both locales. +- acf4d38: fix(platform-objects): nine es-ES and ja-JP metadata-form leaves say what their source says + + Nine leaves of `metadataForms` served a superseded English source revision in both es-ES + and ja-JP. Each was a faithful translation of the sentence the source carried when it was + extracted; the English moved afterwards and bundle merge fills gaps only, so a + present-but-stale leaf is never refreshed by re-extraction. + + The nine, by the test the census applies — does the string assert something the source + does not, or drop a distinct concept the source names: + + - `object.fields.fields.trackHistory.helpText` said "keep change history" in both. The + source says `Summarize this field on the record activity timeline` — a different + feature, not a loose translation. + - `object.fields.isSystem.helpText` dropped `defaults sharing to public`. + - `view.fields.filter.helpText` dropped the whole clause after the dash — the shared + visual builder and its field-type-aware operators and value inputs. + - `action.fields.body.helpText` said "JavaScript code to run", losing the L1-expression / + L2-sandboxed-body distinction. It now reads as the sibling leaf + `hook.fields.body.helpText` already renders that same source sentence in both locales. + - `action.sections.advanced.description` asserted bulk operations, which the source does + not name. + - `page.fields.type.helpText` asserted the page-kind enum the source stopped listing and + dropped the "List / Interface binds a source view into a curated surface" sentence. + - `report.sections.basics.description` said "data source" where the source says report + type. + - `report.fields.columns.helpText` said "columns to show in the report", losing both + `Dimension names across` and `matrix only`. + - `email_template.fields.variables.helpText` described a list of variable names; the + source is a JSON shape example, which is language-neutral and is now carried verbatim. + + Values only — the key set is unchanged at 773 leaves, identical across all four bundles. + The recorded-source-hash table is untouched and needs no entry: it records a digest only + while a leaf is still a byte copy of its source, so all nine, being real translations, + carry no entry and are LEGACY-TRUSTED by construction. +- e9fcd6b: fix(platform-objects): the dashboard metadata-form bundles follow the `refreshIntervalSeconds` rename (#14478) + + The `metadataForms.dashboard` translation bundles key the auto-refresh field as + `refreshIntervalSeconds`, following the `@objectstack/spec` rename of the + authored key. Regenerated with `node scripts/check-i18n-bundles.mjs --write`; the + hand-written `zh-CN` / `ja-JP` / `es-ES` label and help text were carried across + the rename unchanged, because the field still means what it meant and each help + text already named the unit. +- c930f85: Metadata forms i18n: the `object.fields.reference` help text now carries the + `tree` rule in Spanish, Japanese and Chinese, and no longer claims the field is + for `lookup` / `master_detail`. + + The English source for this row gained a normative sentence on 2026-09-05 — a + `tree` field's `reference` is optional and, when present, must name the + declaring object; a link to a different object is a `lookup`. That rule is + enforced at parse time, so an author who writes a foreign target meets it as a + refusal rather than as guidance. + + The three translated locales still served the pre-2026-09-05 sentence. They + were wrong in both directions at once: they dropped the `tree` rule entirely, + and they asserted a purpose the source no longer states — "(para + lookup/master_detail)" / "(lookup/master_detail 用)" / "(用于 lookup / + master_detail)" — which the `tree` case contradicts. A Spanish, Japanese or + Chinese console therefore told the author that `reference` was for the two + relationship types that exclude `tree`, and gave no hint of the constraint they + were about to hit. + + Only the three `helpText` values move. The `label` siblings, the key set and + the generated structure are unchanged. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- 2bb0614: fix(platform-objects): `sys_email.error` field help now covers pre-delivery rejections, not only transport failures + + `sys_email.error` was declared as *"Transport error message when status=failed"*. + Since `EmailService.recordRejectedMessage` landed, the same column also carries + the reason a message was rejected by `normalizeMessage` **before** it reached a + transport (an unsendable `from`, no recipient, no subject, no body) — those rows + are written with `status: 'failed'` too, prefixed `rejected before delivery: `. + + Nothing was misleading in the *data*: the row prefixes its own reason, so an + operator reading a failed row is never sent chasing an SMTP host for a message + that never reached one. What was stale was the field's declared `description`, + which Studio surfaces as the field's help text — it named only the transport + case, narrower than what the column has held since that change landed. + + The description now reads: *"Why the message failed — a transport error, or the + validation that rejected it before delivery."* It stays true under both row + shapes and deliberately does not name the row's own `rejected before delivery:` + prefix, so it will not go stale again if that prefix's wording changes. +- b3820c3: `sys_email.highlightFields` names the recipient column that exists, so the platform's own email log stops rendering one column short (#15629) + + The list read `['subject', 'to', 'status', 'sent_at']`. Three of those four resolve; `to` does not — `sys_email`'s recipient column is `to_addresses`. It now reads `['subject', 'to_addresses', 'status', 'sent_at']`, and nothing else about the object moved. + + `highlightFields` is the object's ordered "most important fields" pointer (ADR-0085): it drives the default list columns, record cards, previews and the detail highlight strip. Every consumer **silently skips** an entry it cannot resolve — nothing throws and nothing logs — so each of those surfaces rendered one field short, and the field missing from the platform's own outbound-email log was the recipient. + + There was a second, louder consequence that nobody could reach by accident. Since `object-field-ref-unknown` crossed onto the object write door (#15254), this body could not be republished through `PUT /api/v1/meta/object` or a package publish: the door answers `422 INVALID_METADATA`. `sys_email` reaches the runtime as a code-shipped registry object instead — `EmailServicePlugin` hands it to the manifest service, a path that runs no authoring gate — so boot was never affected and no deployment was failing. It was a trap laid for whoever next edited the object through a door rather than the file. + + `sys-email.highlight-fields-resolve.test.ts` pins it through that real door rather than by comparing the array against `Object.keys(fields)`: it runs `runRuntimeAuthoringRules({ type: 'object' })` over the shipped declaration with the audit module's other objects as resolution context, and a control case restores the old entry and requires the same call to refuse it — so a green result means the door read this object and accepted it, never that nothing looked. +- 021a735: fix(platform-objects): the zh-CN `dashboard.gap` help text says what its source now says + + `metadataForms.dashboard.fields.gap.helpText` in `zh-CN.metadata-forms.generated.ts` + read 「栅格间距(Tailwind 单位)」. That was a faithful translation of the source it was + extracted against, `Grid gap (Tailwind units)` — but the source has since been rewritten + to `Space between widgets, in steps of 0.25rem (4 = 1rem)`, which deliberately drops the + CSS framework unit an app author never chose and cannot act on, and adds the magnitude + the author can size a dashboard with. + + The leaf kept the retired vocabulary and never gained the magnitude, because bundle merge + fills gaps only: a present-but-stale leaf is not a gap, so no amount of re-extraction + corrects it. It now reads 「组件之间的间距,每级 0.25rem(4 = 1rem)」 — `widgets` is + 「组件」 as it is everywhere else in this bundle, the grid framing is gone exactly as it is + upstream, and the conversion is carried so a zh author can size `gap` without reading the + English. + + One leaf. `columns` is unchanged upstream, so 「栅格列数(默认 12)」 stays accurate, and + the other five leaves of this subtree were corrected separately. +- 7bdb163: fix(platform-objects): 21 zh-CN metadata-form leaves say what their source says + + `zh-CN.metadata-forms.generated.ts` carries 615 leaves that differ from `en` and hold no + digest in `zh-CN.source-hashes.generated.ts` — LEGACY-TRUSTED values carried in from a + pre-consolidation hand vocabulary (`e0077ea36` deleted a 746-line + `src/metadata-translations/zh-CN.ts` and imported its strings) and never reconciled + against the English the same commit range seeded. A census of all 613 (as the population + then stood) found 26 that assert something the source does not, or drop a distinct concept + the source names. Five of the 26 — the whole `dashboard` subtree — landed in `9f57f1e31`. + These are the remaining 21. + + They are not stale fills and no gate can see them: a stale fill is a byte copy of a + previous source revision, detectable by cross-locale agreement or a recorded digest, and + these are neither. Nor does re-extraction correct them — bundle merge fills gaps only, and + a present-but-wrong leaf is not a gap. + + Three defect kinds, all decided against this bundle's own usage: + + - **Asserts an input that does not exist.** `skill.sections.triggers.description` promised + 「触发关键词」 for a section holding only `triggerConditions` (`triggerPhrases` was + removed with the key); `email_template.fields.variables.helpText` promised a per-variable + 「默认值」 that `EmailTemplateDefinitionVariableSchema` does not declare; + `action.sections.advanced.description` promised 「批量」 after `bulkEnabled` was removed + from that section. + - **Names the wrong technology.** `action.fields.body.helpText` said the body is + 「JavaScript 代码」; an L1 expression is not JavaScript. It now reads + 「L1 表达式或 L2 沙箱 JS 体」 — verbatim the sibling `hook.fields.body.helpText`, which + translates the identical source sentence correctly. + - **Drops a distinct concept the source names.** `object.fields.isSystem.helpText` dropped + 「共享默认为公开」; `view.fields.filter.helpText` reduced a sentence about the shared + visual builder to 「筛选规则」; `permission.sections.identity.description` dropped both + sentences explaining how permission sets stack on profiles. + + zh-CN only: es-ES and ja-JP are untouched here. The 18 looser paraphrases the census + excluded are also untouched. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 4f6830c1eb..b66ee62c3d 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.3.0", + "version": "17.4.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 882de324f1..db87190660 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,137 @@ # @objectstack/embedder-openai +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index ae1f43d867..651d8c2858 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.3.0", + "version": "17.4.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 d17acf6db1..ecb7444040 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,151 @@ # @objectstack/knowledge-memory +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/service-knowledge@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index b40bc508b2..a0cf689cfc 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.3.0", + "version": "17.4.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 60dfc5ff23..36815a7a89 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,151 @@ # @objectstack/knowledge-ragflow +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/service-knowledge@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 9c6d3b2d69..c3e7750708 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.3.0", + "version": "17.4.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 new file mode 100644 index 0000000000..5b3c2a3d7a --- /dev/null +++ b/packages/plugins/organizations/CHANGELOG.md @@ -0,0 +1,186 @@ +# @objectstack/organizations + +## 17.4.0 + +### Minor Changes + +- c677cda: Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an + Apache-2.0 package in this repository (ADR-0132). + + Single-database, row-level organization isolation was already open — the tenant Layer 0 wall, + the three tenancy postures, the organization and invitation objects, better-auth's organization + plugin, and the `requiresService: 'org-scoping'` Setup gates. What was closed was the one + registrar of the `org-scoping` service, so an install that set `OS_TENANCY_POSTURE=isolated` + could not enforce it: `serve` refused the boot, and the only way past was + `OS_ALLOW_DEGRADED_TENANCY=1` — the wall configured but not enforced. This package is that + missing registrar. + + It provides: + + - **`organization_id` auto-stamp on insert**, from the caller's active organization. A supplied + — possibly forged — value is overwritten, never trusted. + - **Per-organization seed replay** on `sys_organization` insert, from the app's own seed + definitions. Never another organization's rows. + - **Default-organization bootstrap** for the platform admin, idempotent. + - **The walled-posture membership-policy gate**: a deployment that raises the wall must declare + what a new user joins, or the boot is refused. + + Only the commercial **entitlement** stays closed. The open class carries no licence check of any + kind and offers no hook for one; an enterprise deployment resolves the same package name to a + private, licence-gated subclass through its own `workspace:*` declaration, so which class is + mounted is decided by the manifest that declares the name. + + ⚠️ Shipping the registrar is not yet the same as an open install raising the wall: `objectstack + serve` still resolves the runtime from the served app's own declaration and is not yet wired to + mount this package off `OS_TENANCY_POSTURE`. That, and the isolation matrix run against a real + registrar rather than a posture stub, are tracked separately. + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [d4c2cb1] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [2e6a2ea] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [cfb64a6] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [41cbc54] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [9f39897] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [142c01c] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/types@17.4.0 diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json index 2de41a96b6..bb8c4be352 100644 --- a/packages/plugins/organizations/package.json +++ b/packages/plugins/organizations/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/organizations", - "version": "17.3.0", + "version": "17.4.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 e274790c75..c20d67e1be 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,246 @@ # @objectstack/plugin-approvals +## 17.4.0 + +### Minor Changes + +- 6530e04: A restored approval suspension can now be decided again, not only cancelled. + + `AutomationEngine.restoreConsumedSuspension` re-arms the pause of a run that stranded mid-resume and tells the operator to *re-issue the continuation*. For an `approval` suspension nobody could: every approvals door that stamps the resume marker — `decide`, `recall`, `sendBack`, `resubmit` — guards on a `pending` request, and the row is terminal, written by the very call that stranded the run; and the generic engine door refuses an `approval` pause outright, because that node declares `resumeAuthority: 'service'`. The only remaining verb was `cancelRun`, which discards the branch's downstream work — so the advertised repair produced a run that looked resumable and was not decidable. + + Measured against the real engine and the real decision door: the restored suspension lacks nothing. A `resumeAuthority`-marked resume walks the restored pause to completion. What was missing was an **issuer** on the approvals side, and that is what this adds. + + - **`ApprovalService.continueRestoredRun(requestId, options?)`** re-issues the continuation the recorded outcome already produced once, against a pause an operator has re-armed. It reports which outcome it replayed, which edge it walked, and whether the signal was replayed exactly or rebuilt (`source: 'journal' | 'reconstructed'`). + - **The failing door now journals the signal it was carrying** on the repairable exit — the engine's own `status: 'stranded'` discriminator, the one exit that journals a repair snapshot — under `__strandedContinuation` in the request's `node_config_json`, beside the `__decisionOutputs` side-channel that was already there. Best-effort: it is awaited but can never replace the `RESUME_FAILED` throw the decision's caller is owed. + - **The continuation is tied to this request's own pause, by three guards.** A boolean "is this run suspended" is not enough: a run outlives any one request, so a terminal row's continuation could be issued against whatever pause the run happened to be sitting on. It now requires that the request is still the newest on its run, that a pause exists (strictly — an unreadable store throws rather than reading as "not suspended"), and that the pause is parked **where this request's recorded outcome was issued from**. That node is signal-aware, not simply the row's own: `approve`, `reject`, `revise` and `recall` are all issued at the request's own approval node, but a `resubmit` is only ever issued from the revise window the request's `revise` edge leads to, so its pause is re-armed there while the row still records the approval node. Comparing against the row's own node refused exactly that case, and told the operator the pause was not this request's when it was. The node check is fail-closed in every direction, including an engine that cannot report where a run is parked and a revise window this service cannot derive from the flow definition. This needs no new automation-engine surface: `listSuspendedRunsDurable` is already public, and the approvals-side resume interface simply declares it. + - **Runs stranded before this shipped are served too**, and where the signal cannot be proved the verb **refuses instead of guessing**. A status is not the same thing as a continuation, and three of the four terminal statuses have more than one writer or issuer: `approved` is unambiguous; `rejected` has two writers, discriminated by the `revise` action row that only ADR-0044's revision-limit auto-rejection leaves behind; `returned` has one writer but **two** issuers, discriminated by the `resubmit` action row whose sole writer is `resubmit` — without it a stranded resubmit was rebuilt as a send-back and walked the wrong edge, proceeding only through the engine's unmatched-label fallback with the wrong output; and `recalled` has two writers across **three** behaviours, two of which issue no continuation at all, so it is **refused on the rebuild path** with a message naming what an operator can do instead. Journal-recoverable is a **measured, named set** rather than a blanket claim: `approve`, `reject`, `resubmit` and `recall` continuations replay end to end through the verb, and `reject` and `resubmit` do so on the rebuild path as well. Two shapes are refused by design and stay refused — a `rejected` row that also carries a `revise` action, and a `recalled` row with no journal. NOT covered by a pin, and so not claimed: the `approve` rebuild path. + + - **A journalled signal is checked against what the row's status can have issued, before it is replayed.** The journal records what the last FAILED resume was carrying, and nothing rewrites it when a later door moves the row on — so a signal can outlive the state that issued it. Measured, with no injected failure beyond the strand: a `resubmit` strands and journals `resubmit`; the submitter then recalls, a real `cancelRun` on an already-stranded run answers `false`, the row is marked `recalled` and the run stays parked; the restore re-arms the pause; and the stale `resubmit` was replayed, opening a fresh `pending` round on a request somebody deliberately withdrew. Every step an ordinary action answering ordinarily. A row is now replayable only for a continuation its own status can have issued — `approved`→`approve`, `rejected`→`reject`, `returned`→`revise` or `resubmit`, `recalled`→`recall`, and nothing at all for a status nobody has enumerated. ⛔ Clearing the journal after a successful replay does not close this and was measured not to: the offending replay is the FIRST replay of that journal, so a clear that fires afterwards can never run before the advance it would prevent. + + ⛔ What this deliberately does not do, each pinned: it does not re-open or rewrite the request row — all four `pending` guards are untouched and no status, mirror field or audit row is written, so a decided request still cannot be decided again through the front door; it does not relax `resumeAuthority: 'service'`, since the resume still goes through the one call site that stamps the marker; and it does not change `ApprovalDecisionResult`, whose shape is the subject of an open ruling. It also grants no capability in-process code did not already have — `RESUME_AUTHORITY_SERVICE` is importable by any host — what it adds is the guarded form, and the guards are stated as what they actually check: that this request is still the newest on its run, that a pause exists at all, that it is parked where this outcome was issued from, and that the recorded signal is one the row's present status can have issued. ⛔ None of them checks that the pause was consumed and genuinely re-armed, and an earlier wording of this entry claimed one did: a `returned` row with a resubmit action row and a pause that was never consumed is admitted, with `restoreConsumedSuspension` itself answering *"already resumable — nothing to restore"*. That shape is benign — the recorded action is the submitter's own resubmit, so the step it walks was decided — but it is not what any guard tests. Like the engine verb it completes, it is an in-process operator repair: no REST route, and no entry in the spec `ApprovalService` contract. +- 4c31f02: The stranded-request inspection tells a repairable strand from a cascade-failed run — through a dedicated read-only engine member, not through the wire (#15358, ruling B′). + + `ApprovalService.inspectStrandedRequests` keyed on `run.status === 'failed'`, which over-reports in one direction: a **cascade-failed** run — an ancestor `failAncestors` failed while it was parked at its `subflow` node, whose pause `failSuspendedRun` consumed and journalled nothing — has the same terminal `failed` row as the #13909 strand, so both came back `runState: 'failed'`, and `restoreConsumedSuspension` re-arms one and refuses the other (`NO_CONSUMED_SUSPENSION`). The engine's discriminator (the consumed-suspension snapshot on the durable `RunRecord`) is deliberately NOT on the `ExecutionLogEntry` that `getRun` answers, because `GET /automation/:name/runs/:runId` serves that object verbatim — so the plugin could not read it, and reading its absence as "not a strand" would have called the repairable row dead. + + **`@objectstack/service-automation` — additive, `minor`.** `AutomationEngine.inspectConsumedSuspension(runId)` answers whether `restoreConsumedSuspension` would have a consumed suspension to put back, from the SAME two witnesses that verb reads (this process's hot journal and the durable row, reconciled by the same `rowSupersedesJournal` / `persisted` / drop-notice rules — the read is now one private method both call), and re-arms nothing. Four answers, none folded: `repairable: true` (with the pause it would re-arm and which witness answered); `SNAPSHOT_DROPPED` (the strand happened, the store could not persist the snapshot, and this process holds no hot copy — repairable only by the replica that stranded it, while it lives); `NO_CONSUMED_SUSPENSION` (cascade-failed or never paused); `RUN_SUSPENDED` (already resumable). It REJECTS when a store cannot be read — an outage is unknown, not "nothing to restore". The result type is exported as `ConsumedSuspensionInspection`. `restoreConsumedSuspension` behaves exactly as before; nothing on `ExecutionLogEntry`, the run-detail route, or `@objectstack/spec` changes. + + **`@objectstack/plugin-approvals` — additive on two published types, `minor`.** + + - `ApprovalResumeSurface` gains the optional `inspectConsumedSuspension?(runId)`, declared the way `listSuspendedRunsDurable` is: a method `AutomationEngine` already implements, widening no engine surface. + - `StrandedRunState` splits `'failed'` three ways and keeps `'missing'` untouched: `'repairable'` (the #13909 strand — restore, then `continueRestoredRun`), `'snapshot_dropped'` (its own class: as `repairable` it over-reports, as `unrepairable` it is a false negative), and `'unrepairable'` (the cascade-failed / never-paused run, #15222's shape — nothing re-arms it). **`'failed'` stays a member, on purpose**: it is what a `failed` row is reported as when the attached surface has no `inspectConsumedSuspension` (an engine build older than this plugin, or a host double). Absence of the discriminator is fail-closed for a report — the row is reported, undifferentiated, never labelled `unrepairable` and never dropped. A thrown read counts `undetermined`, as the other two oracles' do. + + A consumer switching exhaustively over `StrandedRunState` gains three arms; nothing it matched before stops arriving. The inspection's summary log adds `runRepairable` / `runSnapshotDropped` / `runUnrepairable` beside the existing counts. +- 3d3f60e: An approval decision that lands while its flow run strands now says so in fields, not only in prose. + + `POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all. + + The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said: + + - **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status. + - **`repairable` carries the engine's own discriminator** — `AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise. + - **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built. + + `@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope. + +### Patch Changes + +- ea03c7c: Fix: a `department` approver on a seeded business unit no longer routes the approval to another organization's members. + + `ApprovalService.expandBusinessUnitUsers` screened the `sys_business_unit` rows with the null-inclusive tenant predicate (#3807 — a seeded unit carries no organization and is admitted on purpose) but read `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either. A seeded unit id exists identically in every tenant, so a `department:` approver on tenant A's request resolved the shared unit and then collected every tenant's membership rows hanging off it — approval authority over A's record, routed to B's users. The member read now carries a strict `organization_id` equality against the directory organization the approver resolves in: the same screen `plugin-sharing` applies to these rows, and the same posture this package already takes for `sys_team_member` and `sys_user_position`. + + The screen is strict rather than null-inclusive on purpose. `sys_business_unit_member.organization_id` is filled by REST/session writes but left NULL by seed replay and by elevated system-context writes (tracked in #14570), so a NULL on a membership row means unknown tenancy, not "platform-global", and routing fails closed on it. Declared cost: on a deployment whose membership rows (not merely its units) were seeded or system-written, a `department` approver on a request that carries an organization now expands to nobody — the slot falls to the `department:` literal, the existing `expanded to nobody` warning (#3807) names it, and `onEmptyApprovers` governs the request as for any unstaffed target. The repair is to stamp those membership rows. A request that carries no organization is unchanged, and so is every unit-level screen. +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- ac6213e: Four server-side authorization sites stop deriving platform-operator authority from a NAME in `ExecutionContext.positions`, and read the ADR-0095 posture rung instead. + + `positions[]` is the security axis, so it carries ADR-0057 D4 `sys_user_position` names alongside the built-ins. `sys_user_position` is `apiEnabled` and its `position` values are unconstrained, so a tenant could mint a row spelling `platform_admin` for one of their own users: `resolveUserAuthzGrants` pushed that name straight onto `grants.positions`, while `grants.posture` — derived from the unscoped `admin_full_access` grant and nothing else — correctly stayed `MEMBER`. Every reader of the name therefore answered `true` for a principal enforcement treats as an ordinary member. `resolve-authz-context.ts` states the rule at `hasPlatformAdminStanding` ("read the RUNG — never `positions.includes(...)`"), but a comment is not a gate and these four had not followed it. + + Each site now tests `posture === 'PLATFORM_ADMIN'`, byte-for-byte what `hasPlatformAdminStanding` returns: + + - **`plugin-sharing`** — `hasPlatformAuthority`. The minted row satisfied `assertResolvableAdminScope`, so an org-less caller holding only the ORG-scoped `manage_sharing` capability was answered with **every tenant's** sharing rules, and could delete platform-global rules. The `manage_platform_settings` capability spelling is unchanged. + - **`plugin-approvals`** — `isOverrideActor`. This predicate already read the rung and then ORed the name onto it, which is no protection: an OR is only as strong as its weakest arm. Because the platform arm deliberately crosses the tenant wall, the minted row let a member of one organization approve, reject or recall a **different organization's** pending request while holding no slot in its slate. The `ADMIN_FULL_ACCESS` capability arm and both TENANT_ADMIN arms are unchanged. + - **`runtime`** — the ADR-0126 §5 activation gate. Under a `group` or `isolated` posture this gate is the only thing between a tenant org admin and the **install-wide** `sys_metadata_activation` row, so the minted row reopened #10243 with a durable row behind it. + - **`plugin-security`** — `derivePosture` in the explain engine. Narrower than the other three, and stated precisely rather than overclaimed: the name-read sat behind an early `ctx.posture` return that `buildContextForUser` always populates, so the shipping path was already gated and a D4 row never moved it. What the read did reach was a posture-less hand-built context, where it made the panel **report** `PLATFORM_ADMIN` for a principal enforcement treats as a MEMBER — a misreport rather than an admission, but in the one tool an administrator opens to check exactly this. + + No behaviour changes for a genuine platform operator: their resolved context carries the rung, and the built-in position is still projected onto `positions[]` for display and predicate use. What changes is that the name alone no longer answers the authorization question. + + Graded `patch` on the surface it moves: no exported type, signature or contract changes, and no authorable metadata is added, removed or renamed. The only observable difference is that a principal who never held the capability grant stops being admitted — which is the defect, not a feature anyone could have depended on. +- 455d037: Documentation: `ApprovalService.recall`'s docblock summary line no longer claims the submitter is the only actor. + + The block opened with "Withdraw a pending request (submitter only)" and then, three paragraphs down, stated the #3424 privileged override correctly — "The #3424 privileged override reaches a PENDING request only (#12775, maintainer ruling 2026-09-02)". Both cannot be true, and the code settles it in the paragraph's favour: `overrideAdmits` short-circuits the non-submitter guard on a `pending` request. A reader who finishes the block is not misled, but the summary line is the one an editor shows on hover and the one any single-line extraction takes. + + The summary line now reads "Withdraw an undecided request." — status is the axis and the actor rules are left to the paragraphs that already state them correctly, the same structural move the `IApprovalService.recall` docstring makes on the spec side. + + Prose only: no guard, no branch and no signature changed. It earns a changeset rather than `skip-changeset` because `@objectstack/plugin-approvals` publishes `dist/`, and this text ships inside the published `dist/index.d.ts` for `ApprovalService.recall`. +- 8c7cca1: `inspectStrandedRequests` no longer drops a row it could not differentiate, and no longer lets one misbehaving host abort the whole scan (#16709, items 2 and 3). + + Both are the same mistake at two altitudes: the method exists to **enumerate** the terminal approval requests whose flow run cannot advance, so a failure to read the #15358 third oracle must never remove a row from the answer — and never remove the *other* rows either. + + - **A thrown third read now leaves its row in `stranded`, as `'failed'`.** It used to be counted `undetermined` and skipped, exactly as a thrown `hasSuspendedRun` or `getRun` is. Those two are not the same question: a throw from either leaves it unknown *whether* the row is stranded at all, and a storage outage must not be published as a lost run. By the time the third oracle is asked, both have answered — no live pause, terminal `failed` — and it is asked only *which* of the three shapes the row is. A read that could not be made is therefore the textbook "could not differentiate", which is what `'failed'` already means (`StrandedRunState`, #15358 ruling item 1). Dropping the row let `stranded: []` read as "nothing stranded" while a row was in fact stuck, with a log line as its only trace; for a report, fail-closed means showing the row. + - **A host that violates `ApprovalResumeSurface` no longer aborts the scan.** `refineFailedRunState(verdict)` ran outside the `try` that wrapped the read, so an implementation resolving `undefined` where a verdict is declared threw a `TypeError` out of `inspectStrandedRequests` itself and the scan enumerated **nothing**. The refinement now runs inside that `try`; a malformed verdict costs its own row the differentiation, is counted `undetermined`, and costs every other row nothing. + + ⛔ No new `StrandedRunState` member and no widened export: both cases map onto the existing undifferentiated `'failed'`. The `undetermined` counter is kept as telemetry and now **overlaps** `stranded` by design — a row can be both reported and counted — so neither number alone sizes the scan's blind spot. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index 7b502338a7..c099d89bd7 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.3.0", + "version": "17.4.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 1482cae17b..e5207455ee 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,181 @@ # @objectstack/plugin-audit +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [2025b1f] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 6a369ad2f3..ed2780c46b 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.3.0", + "version": "17.4.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 016275eb38..ef144bb5c7 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,493 @@ # Changelog +## 17.4.0 + +### Minor Changes + +- cfb64a6: `createHonoApp` mounts the auth surface where the auth service actually serves, and refuses a prefix it cannot serve it under. + + The documented embed did not reach better-auth at all. `createHonoApp` mounted `/auth/*` under its own `prefix` (default `/api`) while `AuthPlugin` configures better-auth with `basePath: '/api/v1/auth'`, so the two never intersected. The forwarded request could only 404, that 404 fell through to the terminal dispatcher catch-all, and the caller got a `200` with an empty body. Measured on a real kernel with `AuthPlugin`, driving `createHonoApp({ kernel })` with both defaults untouched: + + ``` + POST /api/auth/sign-in/email (valid shape, wrong password) -> 200 {} + GET /api/auth/get-session -> 200 {} + POST /api/auth/sign-up/email -> 200 {} + ``` + + A failed sign-in answering `200 {}` is the silent-success shape: a client that reads `res.ok` sends the user into an authenticated view with no session. The same boot now answers, through the same embed: + + ``` + POST /api/v1/auth/sign-in/email (wrong password) -> 401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"} + GET /api/v1/auth/get-session -> 200 null + POST /api/v1/auth/delete-user -> 401 {"message":"Unauthorized","code":"UNAUTHORIZED"} + ``` + + **Neither default moves.** `prefix` still defaults to `/api` and the auth `basePath` still defaults to `/api/v1/auth`. What changed is which of the two decides the mount: + + - **`@objectstack/hono`** — the `/auth/*` mount is derived from the auth service's configured `basePath`, read at app-construction time, rather than from `prefix`. An auth service that does not expose its base path keeps the previous `${prefix}/auth` mount, so a custom or older auth service is unaffected. + - **`@objectstack/hono`** — a `prefix` the auth base path is not inside now **refuses at construction**, naming both values and every one-line fix that actually constructs: move the app up to the base path's own parent namespace, or configure better-auth down under the prefix (carrying the leading slash the prefix may itself be missing). ⛔ A direction with no working answer is not offered rather than offered wrongly — a single-segment base has no usable parent prefix, because `''` falls back to `/api` and `'/'` mounts every other route of the app under `//`. Previously that composition served auth outside the namespace the host asked for while `${prefix}/auth/*` answered `200 {}`. This is the one behaviour that can stop an app booting: a deployment passing, say, `prefix: '/custom'` alongside the default auth base path was already not serving auth, and now says so instead of failing silently. + - **`@objectstack/plugin-auth`** — `AuthManager.getBasePath()` is new and public: the configured base path in its one normalised spelling (a leading slash added when absent, trailing slashes stripped), which is the spelling an HTTP adapter can mount on. ⛔ **Purely additive — no configured `basePath` changes anything this package does.** better-auth is still handed the configured string verbatim, and the route-ownership walk still normalises its own copy; that copy now reads this accessor instead of repeating the expression. ⛔ It is **not** the string better-auth receives, and it is **not** the single definition of the value. `getAuthIssuer()` and `getMcpResourceUrl()` still derive their own copies and are deliberately unchanged: they are the OAuth `iss` this AS advertises and the RFC 8707 resource identifier a token's `aud` is matched against, both compared by exact string by relying parties, so retiring their copies moves published identifiers and is not a tidy-up that belongs on this card (filed as #16399). Normalising the string handed to better-auth is that same move seen from the other side — it shifts the access-token `iss` off `getAuthIssuer()`, and this manager's own `verifyMcpAccessToken` then rejects every MCP token the deployment mints. Measured on a real `client_credentials` token, and not done. +- 142c01c: MCP OAuth can complete again: the MCP resource is registered as an RFC 8707 resource and DCR-registered clients are linked to it, so `authorize?resource=` no longer answers `invalid_target`. + + On 17.3.0 no MCP client could ever obtain a token. `plugin-auth` configured `@better-auth/oauth-provider` with `validAudiences: [authIssuer, mcpResourceUrl]`, an option the pinned 1.7.2 does not read — the string does not occur once in its dist. In 1.7.2 a requested `resource` is resolved from the `oauthResource` table (`sys_oauth_resource`) and `enforcePerClientResources` defaults to `true`, so the client must also be linked in `oauthClientResource` (`sys_oauth_client_resource`). Neither row was ever written, so every client that sends `resource=` — Claude Code does — was refused at `/oauth2/authorize` with `invalid_target: requested resource is not configured`. Discovery, dynamic client registration and the login page all worked; the flow died one step before consent. + + - **`resources: [mcpResourceUrl]`** seeds the `sys_oauth_resource` row from the provider's own `init`. Seeding is idempotent and defaults to `insertOnly`, so an administrator's later edits to the row's token policy are never reverted by a restart. + - **`clientRegistrationDefaultResources: [mcpResourceUrl]`** links each newly registered client to that resource inside the DCR transaction. This is the only place the link can be made: a client registers anonymously about one second before the browser login, leaving no window for an administrator to insert the row by hand. + - **`enforcePerClientResources` is left at its `true` default.** The per-client linkage check stays on — the fix makes the link exist rather than switching the check off. A client with no link row is still refused with `invalid_target`, and a test asserts that. + - **`validAudiences` is removed.** It was passed and read by nobody, which is precisely how the defect survived a version bump: it looked like configuration and enforced nothing. + + Two boot-path defects the resource seed uncovered are fixed in the same change, because seeding is the first thing this package ever wrote from a plugin `init`: + + - **`getAuthInstance()` now settles better-auth's plugin `init` hooks before it resolves.** `betterAuth()` returns synchronously and runs those hooks behind `auth.$context`, so a failure inside one had no catcher and escaped as an unhandled rejection — which Node terminates the process for by default. A boot failure now rejects the call that asked for the instance. + - **The no-`dataEngine` development fallback builds its own in-memory adapter instead of letting better-auth build one.** better-auth 1.7.2 keys that store by the schema *key* while every read resolves by `modelName`, so on that path every model this package renames was unreachable — `user`/`sys_user` as much as `oauthResource`/`sys_oauth_resource` — answering `Model not found`. Production never took this branch (it uses the ObjectQL adapter); development and tests did. + + No configuration change is required. Deployments that already ran 17.3.0 get the resource row on the next boot; MCP clients that failed to connect need to reconnect so a fresh registration picks up the link. +- 4ca358d: `sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out + + Removing a member deleted the `sys_member` row and left the session alive, for up to seven + days. #15409 closed the security half per request (a session whose `activeOrganizationId` + is not backed by a membership resolves with no active organization). This is the courtesy + half an admin was promised, and it is **never the enforcement**: a trigger can be missed, + an evaluation cannot. + + - **New `revoke_reason` value, `organization_membership_ended`** — an accept-set widening + on a published system object, hence `minor` on `@objectstack/platform-objects`. Every + reason before it is a timer (`idle_timeout`, `absolute_max`, `concurrent_cap`) or an + interactive revoke (`user_revoked`, `admin`); this is the first authorization-event + cause. There is no Zod enum behind the column — it is free `text` — so the field's own + description is the published vocabulary, and that is where the value is declared. The + string deliberately matches the one the API-key arm of the same ruling family already + mints for this event (`ApiKeyRefusalReason` in `resolve-authz-context.ts`), so one grep + finds every place the platform acts on a membership ending. + - **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling, + decision batch #49 item 4, option B). A user who still holds another membership is + **re-pointed** to it — never signed out of organizations they legitimately belong to. A + user with no remaining membership has their session revoked through the existing + `revoked_at` / `revoke_reason` mechanism, which expires it in place: better-auth returns + nothing on the next request and the Console's existing 401 → login redirect handles it, + with **no client change**. + - **The seam is an engine hook on `sys_member`**, not a hook on better-auth's + `/organization/remove-member`. A census measured that the endpoint, a direct delete, a + bulk delete, the cascade from a `sys_user` delete and an organization re-point all reach + the hook, while an endpoint hook would have reached one of them. Same precedent as + `last-admin-guard.ts`. + - **New public surface on `@objectstack/plugin-auth`** — `MEMBERSHIP_ENDED_REVOKE_REASON`, + `endSessionClaimsForEndedMembership` and `registerMembershipEndedSessionTrigger`, hence + `minor` rather than `patch`. + + Known open by measurement, not by omission: a raw driver delete bypasses the trigger + entirely, and cloud's package-uninstall sample-data purge is one (filed as cloud#2003). The + per-request check covers it; the courtesy does not. +- 6acb37e: feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) + + + + Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238. + + **The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC. + + **What lands.** + + - `sys_business_unit.timezone` — `text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`. + - `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`. + - plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry. + + **The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written. + + **Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned. + + **One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately. + + **Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question. +- 8e0b297: fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136) + + + + **BREAKING** meaning change on a published payload — `user.positions` in + `GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's + launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on + #15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one + name, one meaning. + + `customSession` built the array from the better-auth `sys_user.role` scalar + split on commas, plus the active membership mapped to `org_*`, plus + `platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057 + D4 table that is the source of truth for custom positions. The Console binds + that array straight through as the CEL root `current_user`, so an + `action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed + by a business position answered FALSE for **everyone**, including the user who + genuinely held it. + + ⭐ It failed **silently and in the invisible direction**: the root was bound and + the key was present, so `has(current_user.positions)` was true, CEL raised + nothing, and the predicate simply returned FALSE. A predicate that *faults* + fails OPEN in the shell and would have shown the button; a successful FALSE + shows nothing and reports nothing. The documented example + (`'org_admin' in current_user.positions`) kept working throughout, because + `org_admin` is the one name that sits on **both** axes. + + This was a **declared** contract being violated, not an ambiguous name: + `EvalUserSchema` already specified `positions` as "built-in identity names + + position names", exposed to "every predicate surface (server formula, server + RLS, client UI gates) ... with an identical shape" so that a predicate + "evaluates identically wherever it is written". `/auth/me/permissions` and + every server-side evaluator (`ExecutionContext.positions`) already resolved the + security axis; only the session payload did not. + + **What changes** + + - `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**, + not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE + authority (`core/security/resolve-authz-context.ts`, whose header forbids + every entry point from re-reading the `sys_*` grant tables itself), scoped to + the session's active organization. The payload therefore carries the + `sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and + agrees with `/auth/me/permissions` set for set. Same move + `isPlatformAdminUserId` made at #10348. + - `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as + an alias of `'platform_admin' in positions`), so one authority answers both. + - `packages/spec` — `EvalUserSchema` states which axis `positions` is, and + states that the better-auth role scalar is not it. + + **No key is renamed, and none is added.** The ruling anticipated a renamed + auth-role array; measured against the tree, it has no content to carry and no + consumer. Everything the old union contributed beyond the security axis was the + `sys_user.role` scalar's own tokens — and that scalar is **already published, + unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban + carves out, for third-party schema this platform does not own). Minting a + `roles` array would revive that banned word to publish information the payload + already carries. (Precisely: `check:role-word` ratchets the reserved word in + `content/docs` and `skills/` PROSE, while the identifier ban over authored + metadata lives in `packages/lint`; a TypeScript payload key trips neither + mechanically until it is documented. The ADR-level prohibition is what rules + here, not a gate that would have caught it.) A consumer that wants the + better-auth role reads `user.role`. + + **What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2); + `platform_admin` still derives from the unscoped `admin_full_access` grant with + its ADR-0091 validity window and ADR-0049 active flag intact — + `platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over + those shapes. + + ⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.** + `positions.includes('platform_admin')` is the form + `resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position` + row may spell that very name — and this card is what made that reachable, by + moving `positions` onto an axis a tenant admin can write. Reading the name would + have let a tenant mint platform standing and pass the `/admin/*` mount gate. + `platform-admin-gate.ts` drops its positions leg for the same reason. + `session-platform-admin-rung-agreement.test.ts` requires the payload alias, that + gate and `hasPlatformAdminStanding` to agree, driven with such a row present and + a genuine grant as the control. + + **Upgrade.** If you gate on the better-auth role scalar, read `user.role` + instead of looking for its tokens in `user.positions`. Predicates written + against real position names, built-in identity names, or `everyone` need no + change — they start working. Deployments that stored business role names in + `sys_user.role` rather than assigning positions should assign them through + `sys_user_position` (the governed ADR-0090 D12 channel). + + A name in `sys_member.role` is still projected, **with one carve-out**: for a + session carrying NO active organization, membership names are now *added*, from + **every** membership the user holds — the resolver projects them all when no + tenant scopes it, where the old derivation contributed none. Measured on the + real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`): + `[]` before, `[org_admin, everyone]` after, pinned by + `session-positions-security-axis.test.ts`. With an active organization the + projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so + membership-derived names there are unchanged. +- aedbaef: `POST /sign-up/email` for an address that already has a `sys_user` row is refused explicitly, instead of answering 200 for a row that is never written (#15587) + + **This is a wire-behaviour change on one lane**: a call that answers `200 {"token":null,"user":{…}}` today answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` after this change. Nothing is newly admitted — the response that changes is one that reported a creation that never happened. + + ### What was measured + + Under audience posture `email_domain` (domain allowlisted, `selfRegistrationPermissionSet` resolvable), a sign-up for an address that already carried a `sys_user` row answered **200 with a freshly minted user id** and persisted nothing: no new `sys_user`, no `sys_account`, and the next sign-in a `401` with nothing anywhere explaining it. The same call on the same population under the `invite_only` default was refused honestly with `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`. An operator, a provisioning script or the console reading the status code concludes the account exists — and this sits directly on the recovery path a locked-out deployment walks, where widening the posture to let a seeded person register is exactly the remedy an operator is pointed at. + + ### The mechanism + + better-auth's sign-up route computes `shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false` and, when it is on, answers a duplicate with a synthetic in-memory user instead of throwing. **No insert is attempted and nothing is swallowed**: the vendor's `findUserByEmail` short-circuits ahead of `createUser`, which is why no row and no credential appear. + + The posture is not itself the cause — it is only what arms the shield: a posture that permits self-registration **forces** `requireEmailVerification` on. Holding the posture constant at the `invite_only` default and moving only that flag reproduces the divergence exactly, which also means the defect was never confined to the widened postures: `emailAndPassword.autoSignIn: false` arms the same shield under any posture. + + ### The fix + + The uniqueness refusal is raised on the `/sign-up/email` before-hook, the same seam and the same reason the audience-posture refusal is already raised there, and built from better-auth's own `BASE_ERROR_CODES` entry so both lanes answer byte-identically. + + **Order is load-bearing: it runs only for a caller the posture already admitted.** Asking uniqueness first would hand an uninvited stranger an account-existence oracle under the `invite_only` default (422 for a real address versus 403 for an unknown one). After the gate, `invite_only` is untouched — a stranger still gets `SELF_REGISTRATION_CLOSED` and learns nothing. + + **Operators of `open` / `email_domain` should know what the honest refusal costs:** on those postures a caller the audience gate admits can now distinguish an address that has an account from one that does not, where the synthetic 200 previously hid it. That is the disclosure the `invite_only` lane has always made to an invitation holder, and the platform's answer for a widened posture is now the same fact rather than a false receipt. + + `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` is registered in the ADR-0112 error-code ledger under `@objectstack/plugin-auth`: the platform now **emits** it rather than only passing it through, and an emitted-but-unregistered code is the silent fourth state that ledger exists to prevent. + +### Patch Changes + +- d4c2cb1: The auth catch-all yields only a 404 that disclaims ownership — better-auth's own 404 answers can no longer be replaced by another route's + + `registerAuthRoutes` mounts one catch-all over the whole auth namespace (`rawApp.all(`${basePath}/*`)`), and since #4088 that catch-all is deliberately not terminal: when better-auth answers 404 it calls `next()` and lets whatever else matched answer instead. That yield is load-bearing — `plugin-hono-server` mounts `/auth/me/permissions` and `/auth/me/localization` from its own `kernel:ready` hook, and without it those two are reachable only when HonoServerPlugin happens to register first. + + What the yield could not express is **which** 404 may be handed on, because it had only the status to go on. So every 404 was yielded, including the ones that are better-auth's own answer on a path its router serves. Measured with the shipped handler on a real Hono app: add one broad downstream mount — `app.all('/api/v1/*', c => c.json({}))`, the shape a composition adds — and + + ``` + POST /api/v1/auth/delete-user -> 200 {} + ``` + + where better-auth answered 404 because `user.deleteUser` is deliberately unconfigured. That route is not hypothetical: `auth-route-ledger.ts` carries it under the `disabled` disposition precisely because it is published and refused — and the same holds for every 404 a routed endpoint produces for a bad token, an unknown id, or an admin family the deployment does mount. Those answers were all up for grabs. + + The catch-all now asks better-auth's live instance whether it owns the path before it yields. The seam is `auth.api` — the same one `auth-route-ledger.conformance.test.ts` reads and the same one the `/admin/` dogfood sweep derives from, because there is no route table to enumerate by hand; matching mirrors better-call's own `createRouter` walk, including its `SERVER_ONLY` skip and its `:param` syntax. That skip is load-bearing rather than cosmetic: measured on the stock boot, the nine `/admin/oauth2/*` endpoints are in `auth.api` and every one carries `SERVER_ONLY: true`, so better-call never routes them — their 404 is an unrouted one and stays yieldable, because ownership is "does better-call route this", not "is it in `auth.api`". An ownership table that cannot be built answers "not owned", so an enumeration failure degrades to the previous behaviour rather than taking the #4088 surface down with it. + + **The mount is untouched.** It still claims exactly `${basePath}/*` and still forwards every request under it to better-auth. What narrowed is only which 404 may be handed on. + + **Upgrade note — a composition that mounts a route matching paths under the auth base path may see a 404 where it previously saw its own answer.** Affected: deployments that register a route which also matches `/api/v1/auth/...` — most often a broad wildcard over the API prefix — mounted *after* AuthPlugin. Before this release, any request to a path better-auth serves but answers 404 on (a switched-off capability, not an unknown path) was passed to that route and the caller received *its* response, commonly `200` with an empty object. From this release the caller receives better-auth's 404. Callers that treated such a response as success — `res.ok`, `status === 200`, "no error thrown" — will start seeing the refusal that was always the real answer; that is the point of the change, and the wire shape they now get is the one a deployment without the extra mount has always returned. Nothing to do if you mount no such route: paths better-auth does **not** own are yielded as before, so `/auth/me/permissions`, `/auth/me/localization` and any other sibling route under the auth prefix are unaffected in either registration order. + + **One carve-out to that sentence, measured and bounded.** A **trailing-slash or doubled-slash spelling of a path better-auth DOES own** — `/api/v1/auth/delete-user/`, `/api/v1/auth//sign-in/social` — is now claimed rather than yielded. better-call treats those spellings as unrouted (it refuses on a `//` and on trailing-slash parity before it looks the route up), while this ownership table strips the trailing slash and drops empty segments and so counts them as owned. On a composition with a broad downstream mount, such a spelling therefore answers better-auth's 404 instead of that mount's response. Only those two spellings, only of a path better-auth already owns, and only where such a mount exists: no route in this repo registers a spelling of that shape, and every genuinely unowned path — every `/auth/me/*` route included — is yielded exactly as it was. Aligning the table with better-call's own pre-checks is tracked as a follow-up rather than carried here. +- 2e6a2ea: `@objectstack/plugin-auth` pins the `better-auth` family to an exact `1.7.2`, so a fresh install of a published `@objectstack/*` release loads the auth plugin again — and with it creates the system tables and seeds the admin. + + Published 17.1.0, 17.2.0 and 17.3.0 declared `"@better-auth/core": "^1.7.2"` and imported `createLocalAccountIssuer` / `createOAuthAccountIssuer` from `@better-auth/core/db`. `@better-auth/core@1.7.3` — a **patch** — deleted both names, and the `account.issuer` column behind them, because upstream rolled the issuer-scoped account identity back to opt-in (better-auth/better-auth#10909). A static ESM named import of a missing export is a link-time `SyntaxError`, so the plugin could not load at all. Every symptom followed from that one failure and every one of them was quiet: the scaffolded project's CLI printed the `SyntaxError` as a scrollable oclif warning and carried on, the server printed `✓ Server is ready` on the broken boot, `sys_user` / `sys_organization` / `sys_permission_set` / `sys_position` were never created, the seeded admin sign-in never answered, and the Console's sign-in form answered `Auth request failed with status 404`. + + **This is a stopgap, deliberately, and it is labelled as one.** Upstream removed the export on purpose; adopting 1.7.3 means dropping `sys_account.issuer` — a required column with a unique `(issuer, accountId)` index — from the platform object, retiring the boot-time backfill that stamps it, and migrating every existing deployment. That is its own change with its own decision to make; this one restores a working install today. + + All five members `plugin-auth` declares move together (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`), because they are only correct as one line: `@better-auth/core@1.7.2` and `@better-auth/kysely-adapter@1.7.3` are mutually incompatible in both directions. `better-auth@1.7.2` declares its own siblings exactly, so pinning those five resolves all twelve family members to 1.7.2 — measured on a fresh `npm install` with no lockfile. + + The workspace `overrides` move to the same exact target in step, so the version this repository tests is the version a consumer resolves. In-repo resolutions are unchanged: the lockfile already held 1.7.2 for all eleven overridden members. +- 8e500f2: The `no_sign_in_account_at_boot` report now names a remedy that works — and warns off the one that silences the report itself. + + That boot line fires on the deployment nobody can sign in to: human `sys_user` rows, zero `sys_account` rows. It ended with two remedies, and measured on the exact population it fires on, neither did what its sentence said: + + - **"Open the audience posture so an existing person can register their own login"** produced no login, and for an existing person it never can: self-registration is a user-creation path, so it cannot attach a login to an address that already carries a `sys_user` row, whatever the posture. Widening only ever admits a *new* address — and then every posture other than `invite_only` forces `requireEmailVerification` on, so that login is refused `EMAIL_NOT_VERIFIED` at its first sign-in, and a locked-out self-hosted install is usually the shape with no mail transport wired. + - **"Write a `sys_account` credential row directly against the store"** was worse than useless. The `password` column carries a secret in the platform's own hash format, so a plaintext one authenticates nothing — and the probe behind this report asks only whether *any* `sys_account` row exists, so writing one turns the report off. The operator's first attempt at the named remedy turned the loud dead end back into the silent one the report was written to end. + + The line now names the path that was measured to work: write one pending `sys_invitation` row directly against the store — a lowercase address the directory does not already hold, `status` `pending`, a future `expires_at`, `inviter_id` of any existing `sys_user` — then register through the ordinary sign-up endpoint. The invitation carve-out admits that one creation under every posture, so no door needs widening. It is an admission verdict and not a verification bypass, though, so the line scopes what follows from that: only under the default `invite_only` posture is the recovery mail-transport-free, and it tells the operator to close a widened posture back to `invite_only` before the invited person registers — otherwise the invited login is created, refused `EMAIL_NOT_VERIFIED` at first sign-in, and has silenced this report on the way past. On the `single` tenancy posture that account holder is then promoted to platform admin. The other two are still named, as the two things that look like remedies and are not, because an operator who is going to hand-write a credential row anyway needs to know it blinds the probe. + + **Message text only — no admission semantics move.** Nothing widens, nothing narrows, no accept set changes, and the probe is untouched: this changes what an operator *reads*, not what the platform *admits*. The long form of the same three facts is on the self-hosting deployment page. +- 41cbc54: `GET /organization/list-user-invitations` now honours the declared `requireEmailVerificationOnInvitation` — the per-user invitation inbox works for the unverified sessions it was declared open to + + `AuthManager` constructs better-auth's organization plugin with `requireEmailVerificationOnInvitation: false` on purpose: without a mailer wired in, nothing can ever verify an invitee, so requiring verification would dead-end every invite flow. The pinned better-auth 1.7.2 reads that option on `accept-invitation`, `reject-invitation` and `get-invitation`, but its `listUserInvitations` handler refuses every unverified session unconditionally. Measured on the real pipeline: the same unverified invitee got `200` from all three id-addressed routes and `403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION` from the listing, so on exactly the deployment shape the declaration exists for, an invitee could accept an invitation they were handed but never list it, and the SDK's `organizations.invitations.listMine()` inbox page was empty-by-403 for every user. + + The endpoint is now rebuilt in place on the organization plugin's own `endpoints` record, from the vendor endpoint's own options object (same path, method, query schema and OpenAPI entry), with one predicate changed: the verification refusal is asked against the declared option instead of assumed. The listing itself is still the vendor's own `getOrgAdapter(...).listUserInvitations(sessionEmail)` — invitations addressed to the session's email, pending only — so nothing widens beyond what the same session can already accept one by one. A client-side `?email=` is still refused with the vendor's `400`, and a request with no session keeps the vendor's `400`. + + Declared `true` keeps today's refusal byte-for-byte; an undeclared option keeps the vendor's list-route posture (refuse) rather than re-deriving the vendor-internal default the sibling routes use. No new public error code, no new export from the package entry. +- 9f39897: fix(plugin-auth): the magic-link mail reads the recipient's own `sys_user.locale` (#15106) + + `sendMagicLink` was the last of the five auth mail sends still on the two-rung + #14319 ladder — the request's `Accept-Language`, then the deployment default. + #14762 put the recipient's stored `sys_user.locale` above both at the three + sends that hold a user row, and #14641 reached the invitation; the magic link + was fenced out because it is handed `{ email, url, token }` and no row, so the + column has to be read on the address rather than on an id. The visible cost was + one deployment answering the same person in two languages: a Chinese + password-reset mail and an English magic link, decided by whichever browser + happened to send the request. + + It now reads the column behind the existing placeholder-address refusal, in the + same shape #14641 gave the invitation send — one projected `findOne` on + `sys_user` under a system context, best-effort, and never a reason a send fails. + This completes the #14788 option-D ladder (`sys_user.locale` when set → the + request's `Accept-Language` → the deployment default) across the whole auth mail + surface: all five `sendTemplate` sites now answer per recipient. + + The request rung is kept rather than replaced. A magic link is requested BY its + recipient, so its `Accept-Language` is the recipient's own and remains a + legitimate second rung for an account that has stated no language; ruling D + inserts the column above the header, it does not remove the header. + + Two branches, because a magic link is also a sign-up: an address that carries a + row is written in that account's language, and an address with no row keeps + exactly the previous behaviour. The address is lowercased for the lookup — + better-auth applies no case transform to the magic-link request body, while + `findUserByEmail`, which `/magic-link/verify` resolves the very same link with, + matches on `email.toLowerCase()`, so the column is read for the row the link + will sign into. An address that resolves nothing lands on the rungs below, which + is the documented floor. +- 9e9f03a: A self-registration grant is refused, not silently redirected, when a permission-set row is malformed — and the fourteen dead `{ records }` / `{ data }` normalizer limbs behind that code are gone. + + `plugin-auth` carried fourteen array-or-envelope normalizer blocks of the shape `Array.isArray(x) ? x : x.records ?? []` (thirteen on a `records` limb, one on a `data` limb, four of them written as a guard clause rather than a ternary). All fourteen read the same concrete engine — the `ObjectQL` instance the kernel registers as the `objectql` / `data` service — which answers a bare array on every path, populated or empty. The envelope limb was unreachable code that read as a contract, so the next author writing a defensive normalizer here believed an envelope was possible. The limbs are removed, and the three local engine ports that declared `Promise` (`BootProbeEngine`, `DevAdminSeedProbeEngine`, `PhoneSmsTemplateEngine`) now declare the array they always returned. + + The user-visible change is in `settleSelfRegistrationGrant`, which carried the opposite defect. Its candidate filter dropped any permission-set row whose `id` was missing or blank, silently, before choosing which row to grant: + + - When the malformed row was the only one, the operator was told `no active sys_permission_set row named 'X' resolves` — false, since an active row named exactly that was present. That report is the only signal this path emits, and nothing retries it. + - When the malformed row was the **organization-scoped** one and a global row also carried the declared name, dropping it let the `organization_id == null` arm match instead, and the self-registrant was granted the **global** permission set their organization never declared — with a success log and no other trace. + + `active !== false` remains a selection predicate: a deactivated set still reports the ordinary "does not resolve". A malformed row is no longer a selection at all — the grant is refused and the report names the malformed row, so the ambiguity is surfaced instead of resolved by accident. A well-formed family grants exactly as before. + + **Upgrade note — one family now gets a refusal where it previously got a grant.** If a deployment's `sys_permission_set` already contains a row that is active and carries the declared name but whose `id` is missing or blank, self-registration grants against that name now stop and report, including the case where the malformed row is one nobody was relying on: a malformed **global** row sitting alongside a well-formed **organization-scoped** row used to be dropped silently, letting the org row be granted, and is now refused. This is deliberate — the old behaviour could not tell that family apart from the one where the silent drop granted the *wrong* set — and it is fully reversible without a code change: repair or delete the malformed row and the grant proceeds exactly as before. The refusal is loud and names the row, so it is visible rather than something to discover later; nothing is written while it stands. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [61b4eb3] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [d5c4022] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [1e43386] +- Updated dependencies [7370989] +- Updated dependencies [289bb43] +- Updated dependencies [094b8fd] +- Updated dependencies [46803fa] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [ce478db] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/service-messaging@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 7ced877e7e..82f19a736d 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.3.0", + "version": "17.4.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 696df98cbb..8235abdf4e 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,288 @@ # @objectstack/plugin-dev +## 17.4.0 + +### Patch Changes + +- 88a35c2: fix(plugin-dev): the i18n auto-detect resolves `translations` from `packages[]`, not only the flattened top level (#15232) + + `DevPlugin.init`'s 3b block read `options.stack.translations` and nothing else. + For a multi-package app under the ADR-0130 D4 option-B shape — where + `packages[]` carries each definition exactly once and the flattened top-level + copy is gone — that read returns `undefined`, the detection concludes "this app + declared no copy", and the boot continues. Nothing throws and nothing logs. + + What the developer gets instead is the wrong strings. `I18nServicePlugin` + (`@objectstack/service-i18n`) is never registered, so the `i18n` slot keeps the + core in-memory fallback: `os dev` serves message KEYS, or last release's copy, + for an app that declared real translations. It reads as "the translations are + broken", not as "a collection went missing", which is why it is a reader fix + rather than a footnote. + + The detection now reads the flattened top level FIRST and then each package + body, in the order `resolveArtifactPackageOrder` (`@objectstack/core`, + ADR-0130 D4+D5) registers them: + + - **Every artifact the platform emits today answers bit-identically.** The + flattened level still answers first and short-circuits, so the `packages[]` + pass can only supply a declaration the top level did not have. This is the + reader half of the ruled order (readers first, emitter last, the artifact + additive throughout), so it lands with no change to what any command emits. + - **The caller's original expression is preserved, not re-expressed.** + `Array.isArray(t) && t.length > 0` still decides the top level, per package + body as well — re-expressing a gate as a resolved-and-counted traversal is + what silently changes the verdict for a stack that declares the key empty. + - **⛔ `stack.packages` is not iterated directly.** + `resolveArtifactPackageOrder` is the platform's one traversal and also the + GATE that parses each entry, so a second traversal would disagree with the + load path about which artifacts are loadable. An artifact with no `packages` + key is left entirely on the old path — the key's absence is checked before + the call, because D4's second branch would otherwise hand the caller's own + object back and read the same `translations` twice. + - **A malformed `packages` is refused, not skipped.** A non-array `packages`, + an entry inlined instead of wrapped under `manifest:`, or a duplicate package + id raises the same ADR-0112 envelope (`code` + `status: 422`) that + `ObjectQL.registerApp` raises for the same object later in the same boot. + + The decision — detection plus the locales it derives — is now one exported + function, `devI18nPluginOptions`, so the #15004 option-B acceptance pin + measures it by CALLING it rather than re-implementing the read. `DevPlugin` + keeps the dynamic import and its degradation: those are about the optional + package being installed, which is a different question from what the stack + declares. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [429ec1e] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [c1eafe6] +- Updated dependencies [ac9376a] +- Updated dependencies [68437d4] +- Updated dependencies [44c849c] +- Updated dependencies [abb140c] +- Updated dependencies [2e6a2ea] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [68f8f77] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [ce8bfc9] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [10d05bb] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [2025b1f] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [fa85759] +- Updated dependencies [61b4eb3] +- Updated dependencies [cfb64a6] +- Updated dependencies [5f7fa1d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [41cbc54] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [3e560da] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [6615a02] +- Updated dependencies [9f39897] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [142c01c] +- Updated dependencies [4ca358d] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [f2f6684] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [26144c2] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [ac6213e] +- Updated dependencies [f4e6adf] +- Updated dependencies [d5c4022] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [1e43386] +- Updated dependencies [7370989] +- Updated dependencies [289bb43] +- Updated dependencies [094b8fd] +- Updated dependencies [46803fa] +- Updated dependencies [c7aca0d] +- Updated dependencies [1d73d45] +- Updated dependencies [8a12067] +- Updated dependencies [de75e40] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4c0b22b] +- Updated dependencies [c1d8f98] +- Updated dependencies [8744de9] +- Updated dependencies [ebb5550] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [b8c82de] +- Updated dependencies [dd2184a] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [ce478db] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/service-storage@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/driver-memory@17.4.0 + - @objectstack/plugin-hono-server@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/service-i18n@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/account@17.4.0 + - @objectstack/setup@17.4.0 + - @objectstack/service-realtime@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 8e0dc430da..5a0bfb18e7 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.3.0", + "version": "17.4.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 2befc51292..7947830168 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,186 @@ # @objectstack/plugin-email +## 17.4.0 + +### Patch Changes + +- dfb7a0d: `plugin-email` strips read decorations with the shared list, not a blanket underscore sweep. + + `readEffectiveTemplate` — the layered read a `DELETE /meta/email_template/:name` runs to restore the packaged baseline an overlay was hiding — removed decorations with a module-local copy of `stripReadDecorations` that dropped **every** key beginning with `_`. The shared list it drifted from, `METADATA_READ_DECORATIONS` in `@objectstack/spec/kernel`, is exactly `['_diagnostics', '_draft']`, and its module header names the ADR-0010 protection envelope (`_lock`, `_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`) as deliberately **not** a member: it is envelope state the write path legitimately carries, and the closed metadata schemas allowlist it so a served document keeps its provenance on re-parse. + + The private copy justified its sweep on the claim that `EmailTemplateDefinitionSchema` "declares no underscore key". That is false — `email-template.zod.ts` spreads `MetadataProtectionFields` into its `strictObject`, so every envelope key is declared and parses clean. The copy was removing keys the schema was deliberately widened to accept, and the list lives in `spec` precisely so a producer and its consumers cannot drift like this. + + The path now calls the shared helper, matching the other read-back-envelope consumers (the dataset query in `rest-server.ts`, the cold-boot flow bind in `service-automation`, `saveMetaItem`'s verbatim persist, and the route-level seed apply). Two behavioural consequences: + + - An underscore key that is neither a decoration nor declared is no longer swallowed before validation. The closed schemas exist to reject exactly that (protocol 17), and the rejection is now reported on the write's own response through the mutation projector, instead of the reset quietly succeeding against a body the schema would have refused. + - The ADR-0010 envelope survives the strip. It still does not reach `sys_email_template`: `upsertDeclaredEmailTemplate` projects the parsed template through `mapTemplateToRow`, a closed column list, and the object declares no underscore column — so no stored row changes shape. There is deliberately no second, envelope-stripping pass beside the shared one; spelling one would re-create the drift this fixes, one layer up. +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- a4816a7: The three provenance-stamp `beforeUpdate` hooks stop re-reading a row the engine has already read, and their contract now states what they actually do on a multi-row update. + + `sys_email_template`, `sys_sharing_rule` and `sys_webhook` each carry a hook that stamps `customized: true` when a non-system caller edits a package- or platform-seeded row — the half of seed-not-clobber that detects the admin edit. All three carried the same two comments, and both were assertions about runtime behaviour that runtime measurement falsifies: + + - **"multi-row updates (no single `input.id`) are not stamped."** Not true on any engine these packages ship against. A predicate (`multi: true`) update dispatches `beforeUpdate` once per matched row, and every per-row context arrives with `input.id` bound — so the `if (!id) return` guard answered "single write" on every row of a batch and declined nothing. The rows were being stamped all along. + - **"`previous` is not resolved before beforeUpdate hooks run — read the current row ourselves."** The engine binds `previous` before dispatching `beforeUpdate` on both write shapes, so each hook was issuing its own `find` for a row the engine had just read — on a bulk edit, one extra read **per matched row**. + + Observable behaviour is deliberately unchanged: the same rows are stamped, with the same values, and a bulk edit whose matched rows disagree on `managed_by` is still refused by the engine with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP 400) rather than widening one row's stamp across the batch. What changes is the cost and the contract: the redundant per-row read is gone, and the header of each hook now describes the per-row dispatch, the single `SET` clause a predicate write shares, and why declining to stamp on a bulk edit was rejected — unstamped rows are exactly the ones the next boot's seeder overwrites. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 4b4acde517..f3b8c2c755 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.3.0", + "version": "17.4.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 d21c0b02fb..b1973dd7d4 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,193 @@ # @objectstack/plugin-hono-server +## 17.4.0 + +### Minor Changes + +- 5f7fa1d: feat(hono-server): `GET /auth/me/localization` → `locale` is now the signed-in user's language — `sys_user.locale` when set, then the request's `Accept-Language`, then the deployment default (#14788) + + Maintainer ruling 2026-09-03 (option D on #14788): this endpoint is the ONE + read face for "what language is this user", now that `sys_user.locale` is a + user-stated preference (#13881 / #14787) and the never-produced + `SessionUser.language` is retired from the session contract + (`@objectstack/spec`, same release). + + What changed, for an authenticated caller: + + - `locale` resolves **the user's own `sys_user.locale`** first — read under a + system context by the caller's own id and accepted only when it passes the + column's OWN `locale_bcp47_shape` rule as the registry declares it (the + endpoint evaluates that rule; it carries no second locale parser). A + malformed, blank or unverifiable value falls through, it is never served. + - then **the request's `Accept-Language`** preference (`preferredLocaleFromHeader`, + the same parse REST and the runtime dispatcher feed `execCtx.locale` from); + - then **the deployment default** (`resolveLocalizationContext` — the + `localization.locale` settings cascade, floor `en-US`). + + Before, the resolver behind this endpoint assembled no localization at all, so + `locale` was `null` for every authenticated caller; it is now always a string + for an authenticated caller. The response shape is unchanged + (`{ authenticated, currency, locale, timezone }`), `currency` / `timezone` + are untouched, and the unauthenticated answer (`{ authenticated: false }`) is + unchanged. `resolveSignedInUserLocale` is exported for hosts that compose the + current-user endpoints directly. +- 6615a02: fix(plugin-hono-server): the current-user faces assemble their `ExecutionContext` through the shared assembler (#15747) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). + + `makeExecutionContextResolver` is exported from this package's index. Its declared return moves **from** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise` — in practice `any`, since the exported function carried no return annotation at all and the envelope it built was a hand-rolled object literal cast `as any` — **to** `(ctx: CurrentUserEndpointsContext) => (c: any) => Promise`. `any` is assignable to everything and admits every property read, so a consumer's code really can stop compiling. + + What this asks of a consumer holding the resolver directly (the serverless host path that composes it, cloud#924): narrow the `undefined` arm before reading the envelope — under `strictNullChecks` the resolver has always been able to answer `undefined` for a request with no session, and no caller was ever asked to handle it; and stop reading members `ExecutionContext` does not declare, since the receiver is no longer `any`. A consumer that only calls `registerCurrentUserEndpoints` sees no change. + + The envelope itself is now assembled by `assembleExecutionContext` (`@objectstack/core`) — the fail-closed entry every other HTTP transport already uses — instead of the hand-rolled literal, which omitted six fields of the closed entry set: `principalKind`, `onBehalfOf`, `audience`, `accessToken`, `authGate` and `oauthScopes`. `principalKind` is `'human'` on these faces, the value the shared assembler derives for a session-backed principal; the other five are withheld on the record. A field added to `ExecutionContext` from now on fails to compile here until this face decides it. + + No runtime behaviour changes: `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` answer byte-identical bodies, pinned as goldens. + + + +### Patch Changes + +- fa85759: `GET /auth/me/localization` answers the deployment's resolved `currency` and `timezone` instead of `null` + + The handler read both off the request `ExecutionContext`, citing ADR-0053, but the resolver serving this surface is a hand-rolled envelope that never carried them — so every authenticated caller was answered `currency: null, timezone: null` whatever the `localization` settings said, and the console's regional-formatting seed was fed nulls. All three values now come from one reading of the same `resolveLocalizationContext` cascade the dispatcher's shared assembler uses. `locale` resolution is unchanged. `timezone` now always answers (cascade floor `UTC`); `currency` still answers `null` when the deployment configures none — that value has no floor. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 8fe69ada78..ac5d841ae4 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.3.0", + "version": "17.4.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 240f65cbe7..0d10108bbb 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,64 @@ # @objectstack/plugin-pinyin-search +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [2ed6be6] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [4b3955e] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [ddfbf04] +- Updated dependencies [65846bc] +- Updated dependencies [316a20f] +- Updated dependencies [2025b1f] +- Updated dependencies [33388f9] +- Updated dependencies [fa125f3] +- Updated dependencies [7778115] +- Updated dependencies [088f761] +- Updated dependencies [e9fcd6b] +- Updated dependencies [f9a3c32] +- Updated dependencies [51ae731] +- Updated dependencies [25a3d91] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [3bd9b34] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [26144c2] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [11f848e] +- Updated dependencies [4771bd9] +- Updated dependencies [e6279dc] +- Updated dependencies [d4f9b2a] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [ec0a6e7] +- Updated dependencies [b398ad2] +- Updated dependencies [eddd612] +- Updated dependencies [3d3f60e] +- Updated dependencies [f89812e] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [d61d6e3] + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index 278c1d9cbb..f155791f60 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.3.0", + "version": "17.4.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 a245ee4bc6..b6d74083b1 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,190 @@ # @objectstack/plugin-reports +## 17.4.0 + +### Minor Changes + +- afa3a26: fix(plugin-reports)!: a non-member schedule `timezone` no longer discards the cron expression, and a schedule already holding one stops instead of firing on a cadence nobody asked for (#16291) + + **BREAKING** for a deployment that already stores a report schedule with a cron expression and a `timezone` that is not an IANA member. Such a schedule is delivering today, on the wrong cadence; after this change it does not deliver at all until a human corrects the zone. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs` refuses `major`); the version number is not the signal here, this entry is. + + + + ## What an upgrading operator has to do, and how to find out + + If `sys_report_schedule` holds a row whose `timezone` is not a real IANA zone **and** whose `cron_expression` is set, the sweep now marks it `last_status: 'failed'` with a `last_error` naming the zone, and stops running it. Correct the `timezone` on that row; the schedule resumes on the next sweep with no re-enable and no second action, because `active` and the past `next_run_at` are deliberately left alone. + + Only rows written **before** `valueDomain: 'iana_time_zone'` landed on that column can be in this state, and the set cannot grow: measured on a real kernel with a real SQLite driver, `insert` into `sys_report_schedule` with `timezone: 'Mars/Olympus'` is already refused today — `VALIDATION_FAILED · Timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")`. A set that cannot grow is still not an empty one, which is why this carries a banner rather than a shrug. + + ## The defect + + croner (10.0.1) answers a non-member zone in three different ways, and only the middle one was ever reached here: `new Cron(expr, { timezone })` **without a callback** validates the expression and lets any zone through, `nextRun()` on that instance then throws a `CronDate` conversion `TypeError`, and the callback form throws at construction. `scheduleReport`'s eager guard used the callback-less form, so the timezone half of its own input passed straight under a guard whose stated purpose was "a clear error at schedule time instead of a schedule that silently falls back to interval on sweep" — and `nextRunAt` caught that deferred throw and returned `from + interval_minutes`. A schedule authored as "every weekday 09:00 Asia/Shanghai" became "every 1440 minutes, forever", re-derived on every sweep, logged only as a complaint about a cron expression that was perfectly good. + + ## What changed + + - **The create-time guard now asks the right question.** `scheduleReport` consults `isValueDomainMember('iana_time_zone', …)` from `@objectstack/spec/shared` — the same predicate `sys_report_schedule.timezone`'s `valueDomain` declaration enforces on write — and refuses a non-member with `VALIDATION_FAILED: invalid timezone '': not a member of the 'iana_time_zone' value domain`. One answer at both doors, so this one cannot accept what the storage door refuses; it says so earlier and names the input that is actually wrong. It applies whether or not a `cron_expression` is set, because the storage gate does too. **This is not what makes the change breaking:** the storage door already refuses the same value today, so no reachable accept set narrows — what moves is which door answers and how clearly. + - **The row now stores the string the scheduler evaluates.** An empty `timezone` was stored verbatim while every `new Cron` call site read it as `UTC`; it is normalised to `UTC` on the way in. + - **A schedule already holding an unusable zone is stopped, not rescheduled.** It is not run and its `next_run_at` is not advanced; `last_status` / `last_error` carry the reason. Repairing the value automatically was rejected: the intended zone is not recoverable from a typo, and rewriting it to `UTC` would deliver at yet another set of wrong instants while the row looked healthy. Interval-only schedules are untouched — interval arithmetic never consults the zone, so a legacy bad value there still delivers on the cadence its author asked for. + - **Both fall-back warnings name both inputs.** The "no next occurrence" and the former "invalid cron" lines each mentioned only the expression, so either of them on a timezone fault sent an investigator to audit the half that was fine. They now carry the expression *and* the zone, and the second no longer asserts the expression is the broken one. + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 30e0c9099e..1ddd9e2302 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.3.0", + "version": "17.4.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 a21770d6ce..fc485968ef 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,286 @@ # @objectstack/plugin-security +## 17.4.0 + +### Minor Changes + +- f9a3c32: feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall + + `BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). + + Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. + + - **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. + - **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. + - **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). + + What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. + +### Patch Changes + +- c677cda: Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an + Apache-2.0 package in this repository (ADR-0132). + + Single-database, row-level organization isolation was already open — the tenant Layer 0 wall, + the three tenancy postures, the organization and invitation objects, better-auth's organization + plugin, and the `requiresService: 'org-scoping'` Setup gates. What was closed was the one + registrar of the `org-scoping` service, so an install that set `OS_TENANCY_POSTURE=isolated` + could not enforce it: `serve` refused the boot, and the only way past was + `OS_ALLOW_DEGRADED_TENANCY=1` — the wall configured but not enforced. This package is that + missing registrar. + + It provides: + + - **`organization_id` auto-stamp on insert**, from the caller's active organization. A supplied + — possibly forged — value is overwritten, never trusted. + - **Per-organization seed replay** on `sys_organization` insert, from the app's own seed + definitions. Never another organization's rows. + - **Default-organization bootstrap** for the platform admin, idempotent. + - **The walled-posture membership-policy gate**: a deployment that raises the wall must declare + what a new user joins, or the boot is refused. + + Only the commercial **entitlement** stays closed. The open class carries no licence check of any + kind and offers no hook for one; an enterprise deployment resolves the same package name to a + private, licence-gated subclass through its own `workspace:*` declaration, so which class is + mounted is decided by the manifest that declares the name. + + ⚠️ Shipping the registrar is not yet the same as an open install raising the wall: `objectstack + serve` still resolves the runtime from the served app's own declaration and is not yet wired to + mount this package off `OS_TENANCY_POSTURE`. That, and the isolation matrix run against a real + registrar rather than a posture stub, are tracked separately. +- c64e65f: fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298) + + `declaredPermissionSets` carried a docblock stating a short-circuit its code did + not have: + + > The `packages[]` pass only supplies a set where the top level had none — which + > is precisely the option-B artifact. + + The code pushed the flattened top level and then **every** package body + unconditionally, so on today's additive artifact (flattened level *and* + `packages[]` both present) every permission set was collected twice. Nothing + observable came of it — the sole caller is private and takes the first + `isDefault` set, which the flattened copy still supplied — so this corrects a + false written contract on a security-path reader, not a live defect. That + distinction is the point: the sentence was load-bearing, because it was the + stated reason the reader half was revertible on its own and safe to land before + the emitter half (#14512), and the next reader would have believed the mechanism + was there. + + ⚠️ Release-notes note: this supersedes one sentence of the #15226 entry in this same + unreleased batch — "The resolution now reads the flattened top level FIRST and then each + package body". That described #15226 accurately when it landed; after this change the + `packages[]` pass runs only where the top level named no default. The earlier entry is + left as written rather than retro-edited, so whoever compiles the notes collapses the two + deliberately instead of reading a contradiction. + + The reader now walks the discipline the docblock claims — start from the + expression this program replaced, `appDefaultPermissionSetName(config.permissions)`, + and consult `packages[]` only where it came back `undefined`. + + - **The condition is the resolved NAME, never the `permissions` container.** + Branching on the container re-creates the silent loss the reader program + exists to remove, one shape further along: a flattened level that carries + permission sets but marks none of them `isDefault` is legal today and + hand-authorable in any `objectstack.config.ts`, and a container-shaped + condition (`Array.isArray(flattened)`, with or without `&& length > 0`) shorts + it past the whole `packages[]` pass and answers `undefined` — nothing thrown, + nothing logged, every member of the app back down to the platform floor alone. + Reading the answer also retires the `[]`-is-truthy trap rather than patching + around it. + - **The package order is resolved BEFORE the top level is consulted.** + `resolveArtifactPackageOrder` refuses a malformed `packages` — not an array, + an entry inlined instead of wrapped under `manifest:`, a duplicate package id + — with an ADR-0112 envelope this reader does not catch, and that refusal must + not become conditional on whether the flattened level happened to name a + default first. An artifact is either loadable or refused; which level answered + is not part of that question. + - **No emitted artifact changes its answer.** Measured, not argued: 26 shapes — + the composed additive artifact, its option-B derivative, the collection-zoo + fixtures behind the #15004 acceptance pin, every config the unit suite drives, + the three malformed-`packages` refusals, and the hand-authored mixed shapes — + return byte-identical results before and after, with `@objectstack/plugin-security` + rebuilt and the change proven present in `dist/` on each leg. +- ac6213e: Four server-side authorization sites stop deriving platform-operator authority from a NAME in `ExecutionContext.positions`, and read the ADR-0095 posture rung instead. + + `positions[]` is the security axis, so it carries ADR-0057 D4 `sys_user_position` names alongside the built-ins. `sys_user_position` is `apiEnabled` and its `position` values are unconstrained, so a tenant could mint a row spelling `platform_admin` for one of their own users: `resolveUserAuthzGrants` pushed that name straight onto `grants.positions`, while `grants.posture` — derived from the unscoped `admin_full_access` grant and nothing else — correctly stayed `MEMBER`. Every reader of the name therefore answered `true` for a principal enforcement treats as an ordinary member. `resolve-authz-context.ts` states the rule at `hasPlatformAdminStanding` ("read the RUNG — never `positions.includes(...)`"), but a comment is not a gate and these four had not followed it. + + Each site now tests `posture === 'PLATFORM_ADMIN'`, byte-for-byte what `hasPlatformAdminStanding` returns: + + - **`plugin-sharing`** — `hasPlatformAuthority`. The minted row satisfied `assertResolvableAdminScope`, so an org-less caller holding only the ORG-scoped `manage_sharing` capability was answered with **every tenant's** sharing rules, and could delete platform-global rules. The `manage_platform_settings` capability spelling is unchanged. + - **`plugin-approvals`** — `isOverrideActor`. This predicate already read the rung and then ORed the name onto it, which is no protection: an OR is only as strong as its weakest arm. Because the platform arm deliberately crosses the tenant wall, the minted row let a member of one organization approve, reject or recall a **different organization's** pending request while holding no slot in its slate. The `ADMIN_FULL_ACCESS` capability arm and both TENANT_ADMIN arms are unchanged. + - **`runtime`** — the ADR-0126 §5 activation gate. Under a `group` or `isolated` posture this gate is the only thing between a tenant org admin and the **install-wide** `sys_metadata_activation` row, so the minted row reopened #10243 with a durable row behind it. + - **`plugin-security`** — `derivePosture` in the explain engine. Narrower than the other three, and stated precisely rather than overclaimed: the name-read sat behind an early `ctx.posture` return that `buildContextForUser` always populates, so the shipping path was already gated and a D4 row never moved it. What the read did reach was a posture-less hand-built context, where it made the panel **report** `PLATFORM_ADMIN` for a principal enforcement treats as a MEMBER — a misreport rather than an admission, but in the one tool an administrator opens to check exactly this. + + No behaviour changes for a genuine platform operator: their resolved context carries the rung, and the built-in position is still projected onto `positions[]` for display and predicate use. What changes is that the name alone no longer answers the authorization question. + + Graded `patch` on the surface it moves: no exported type, signature or contract changes, and no authorable metadata is added, removed or renamed. The only observable difference is that a principal who never held the capability grant stops being admitted — which is the defect, not a feature anyone could have depended on. +- 06c762e: Remove seven dead `{ records }` union-normalizer limbs on engine `find()` results, and repair the one that was silently dropping instead of gapping. + + Six seams in this plugin normalized an engine read as `Array.isArray(x) ? x : x.records`. The envelope limb was unreachable: `ObjectQL.find` resolves a bare array of row objects, measured by booting a real engine over a real `SqlDriver` and driving each seam through the shipped function that owns it, rather than inferred from `IDataEngine.find`'s declared `Promise` (a declared type is not proof — this repo also has a `find()` that resolves an envelope). Each seam keeps its existing disposition for a non-array; only the dead limb is gone. + + The seventh is repaired in the opposite direction. `SecurityPlugin`'s `sys_permission_set` loader mapped three different facts onto one value: a read that succeeded on an empty catalog, a read that threw, and a read that resolved something it could not read all left as `[]`. On the enforcement plane that silently withdraws grants that exist while every request still looks normal, and it made `PermissionEvaluator`'s existing "db lookup failed" warning unreachable — so a transient database error and an empty catalog produced identical, undiagnosable 403s. The loader now lets the read fault propagate and refuses an unreadable result with `DATABASE_ERROR`. Enforcement is unchanged for every result the shipped engine produces; an envelope or a non-row element now refuses (fail-closed) where the old code read through it. An unanswered read still grants nothing; what changes is that it is now reported instead of silent. +- 1d73d45: An RLS `check` clause that reads a membership-resolver key now resolves on a bare insert. + + An app that registers an `IRlsMembershipResolver` (ADR-0105 D11) and authors `using` + `check` twins reading its key — `record.employer_org in current_user.employer_org_ids` — saw reads resolve the key and every bare insert refused with `PERMISSION_DENIED` ("would violate a row-level CHECK"), whether or not the payload carried the value the policy wanted. The membership sets were staged onto the request context only inside the read-filter computation; `computeWriteCheckFilter` compiled the `check` clause against a context in which the key had never been staged, so the variable was unresolved, the policy dropped, and the write failed closed. The two write shapes that passed — a by-id update and an insert of a `controlled_by_parent` child — passed only because an earlier read on the same context (the pre-image, the master read) happened to stage it first. + + The write path now stages the resolver's sets itself, immediately before the `check` clause compiles, so a `check` resolves exactly the variables its `using` twin resolves regardless of whether the request read first. Staging is memoized per request context, so the read-first shapes still consult the resolver once. Nothing is relaxed: with no resolver registered, a resolver that throws, or a key the resolver does not publish, the policy still drops out and the write is still refused. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 5539cdb0b9..792e46f5ee 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.3.0", + "version": "17.4.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 b1ffd9d3cd..dd0b8da9ea 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,267 @@ # @objectstack/plugin-sharing +## 17.4.0 + +### Minor Changes + +- 9fa5775: feat(plugin-sharing): the `field` sharing recipient is enforced — expanded once per matched record + + `ShareRecipientType` gained `field` on the spec side (#14103, maintainer ruling + B): `sharedWith: { type: 'field', value: '' }` shares each + record the rule's criteria match with the user or users named by that column + on the record. This is the executor half (#15072): + + - `SharingRuleService` reads the named user-typed column on each matched + record. A `multiple: true` column shares with every user it names; a single- + user column with the one it names. **Fail-closed on empty**: a null or empty + column materialises no grant — never a match-all principal, never a fallback + to the record owner. `field` is the only recipient resolved per record; every + other kind (`user`, `team`, `position`, `business_unit`, + `unit_and_subordinates`) still expands once per rule. + - The grants re-materialise on the record's own write: the existing + `afterUpdate` hook has no changed-field gating, so an update that touches only + the recipient column re-runs the per-record reconcile, which revokes the + stale grant and materialises the new one. No second trigger was added. + - The whole-rule pass (`evaluateRule` — the background re-grant after an + unbounded bulk write, the `kernel:bootstrapped` backfill and the REST evaluate + endpoint) derives per-record (record, user) pairs for a `field` rule instead + of a matched-records × recipients product, so the rule is as correct after a + bulk write and a restart as it is inline. The recipient-axis revoke + (`revokeRuleGrantsForRetiredRecipients`) declines `field` rules — they have no + rule-wide recipient set to retire against. + - The declared-rule bootstrap seeds `field` rules (previously skipped with a + warning), the `sys_sharing_rule.recipient_type` select accepts `field`, and + `defineRule` refuses a `field` recipient whose `recipientId` is not a field + name (the same grammar the spec applies at parse). + - An active `field` rule whose column the object does not declare as user-typed + grants nobody and says so once per rule. + + There is no `manager` recipient: "the owner's manager" is a user field the + application stores on the record, named by a `field` recipient. + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- ac6213e: Four server-side authorization sites stop deriving platform-operator authority from a NAME in `ExecutionContext.positions`, and read the ADR-0095 posture rung instead. + + `positions[]` is the security axis, so it carries ADR-0057 D4 `sys_user_position` names alongside the built-ins. `sys_user_position` is `apiEnabled` and its `position` values are unconstrained, so a tenant could mint a row spelling `platform_admin` for one of their own users: `resolveUserAuthzGrants` pushed that name straight onto `grants.positions`, while `grants.posture` — derived from the unscoped `admin_full_access` grant and nothing else — correctly stayed `MEMBER`. Every reader of the name therefore answered `true` for a principal enforcement treats as an ordinary member. `resolve-authz-context.ts` states the rule at `hasPlatformAdminStanding` ("read the RUNG — never `positions.includes(...)`"), but a comment is not a gate and these four had not followed it. + + Each site now tests `posture === 'PLATFORM_ADMIN'`, byte-for-byte what `hasPlatformAdminStanding` returns: + + - **`plugin-sharing`** — `hasPlatformAuthority`. The minted row satisfied `assertResolvableAdminScope`, so an org-less caller holding only the ORG-scoped `manage_sharing` capability was answered with **every tenant's** sharing rules, and could delete platform-global rules. The `manage_platform_settings` capability spelling is unchanged. + - **`plugin-approvals`** — `isOverrideActor`. This predicate already read the rung and then ORed the name onto it, which is no protection: an OR is only as strong as its weakest arm. Because the platform arm deliberately crosses the tenant wall, the minted row let a member of one organization approve, reject or recall a **different organization's** pending request while holding no slot in its slate. The `ADMIN_FULL_ACCESS` capability arm and both TENANT_ADMIN arms are unchanged. + - **`runtime`** — the ADR-0126 §5 activation gate. Under a `group` or `isolated` posture this gate is the only thing between a tenant org admin and the **install-wide** `sys_metadata_activation` row, so the minted row reopened #10243 with a durable row behind it. + - **`plugin-security`** — `derivePosture` in the explain engine. Narrower than the other three, and stated precisely rather than overclaimed: the name-read sat behind an early `ctx.posture` return that `buildContextForUser` always populates, so the shipping path was already gated and a D4 row never moved it. What the read did reach was a posture-less hand-built context, where it made the panel **report** `PLATFORM_ADMIN` for a principal enforcement treats as a MEMBER — a misreport rather than an admission, but in the one tool an administrator opens to check exactly this. + + No behaviour changes for a genuine platform operator: their resolved context carries the rung, and the built-in position is still projected onto `positions[]` for display and predicate use. What changes is that the name alone no longer answers the authorization question. + + Graded `patch` on the surface it moves: no exported type, signature or contract changes, and no authorable metadata is added, removed or renamed. The only observable difference is that a principal who never held the capability grant stops being admitted — which is the defect, not a feature anyone could have depended on. +- a4816a7: The three provenance-stamp `beforeUpdate` hooks stop re-reading a row the engine has already read, and their contract now states what they actually do on a multi-row update. + + `sys_email_template`, `sys_sharing_rule` and `sys_webhook` each carry a hook that stamps `customized: true` when a non-system caller edits a package- or platform-seeded row — the half of seed-not-clobber that detects the admin edit. All three carried the same two comments, and both were assertions about runtime behaviour that runtime measurement falsifies: + + - **"multi-row updates (no single `input.id`) are not stamped."** Not true on any engine these packages ship against. A predicate (`multi: true`) update dispatches `beforeUpdate` once per matched row, and every per-row context arrives with `input.id` bound — so the `if (!id) return` guard answered "single write" on every row of a batch and declined nothing. The rows were being stamped all along. + - **"`previous` is not resolved before beforeUpdate hooks run — read the current row ourselves."** The engine binds `previous` before dispatching `beforeUpdate` on both write shapes, so each hook was issuing its own `find` for a row the engine had just read — on a bulk edit, one extra read **per matched row**. + + Observable behaviour is deliberately unchanged: the same rows are stamped, with the same values, and a bulk edit whose matched rows disagree on `managed_by` is still refused by the engine with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP 400) rather than widening one row's stamp across the batch. What changes is the cost and the contract: the redundant per-row read is gone, and the header of each hook now describes the per-row dispatch, the single `SET` clause a predicate write shares, and why declining to stamp on a bulk edit was rejected — unstamped rows are exactly the ones the next boot's seeder overwrites. +- 4db3c61: `publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. + + `isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. + + Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. + + Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. +- 2e35765: The share-link REST surface now derives the tenancy posture before it resolves the caller, so an API key stamped with an organization its owner has left can no longer mint links into that organization. + + `resolveAuthzContext` gates every posture-conditional refusal on a `tenancyPosture` its **caller** supplies. `SharingServicePlugin`'s share-link door supplied none, so none of them ran: `organization_required` (`core/security/api-key.ts`), `organization_membership_ended` (`core/security/resolve-authz-context.ts`), and the session arm beside it that drops an `activeOrganizationId` claim no `sys_member` row backs. An API key's tenant is `sys_api_key.active_organization_id` copied verbatim — the caller's own stored claim, never vetted against current membership — so under a wall-enforcing posture (`isolated`, `group`) a key belonging to an ex-member was admitted carrying that organization, and `createLink` minted a capability token on a record inside it. The same door carried the session half: a browser session whose owner had been removed kept its organization claim until the session expired. + + Measured at the door, under `isolated`: the ex-member's key went from `200` / `201` with the link landing in the store to `401` / `401` with nothing landing; an organization-less key went from admitted to `401`; an ex-member's *session* now has its stale claim dropped and is refused by Layer 0 at `403` while staying signed in. A current member and an anonymous caller are unchanged in every wiring. + + A `tenancy` service that was **never registered** stays a supported composition and resolves quietly to "no posture" — behaviour on an embedding without `plugin-auth` is exactly what it was. A `tenancy` service that **was registered and failed to build** now raises `AuthzStoreUnavailableError`, which reaches the wire as `SERVICE_UNAVAILABLE` / 503 rather than being laundered into a `401`: admission was never decided, so it must not be answered. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [2025b1f] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 564749b0e5..f170e1c036 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.3.0", + "version": "17.4.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 6c08a73fec..df7ec377a5 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,184 @@ # @objectstack/plugin-webhooks +## 17.4.0 + +### Patch Changes + +- a4816a7: The three provenance-stamp `beforeUpdate` hooks stop re-reading a row the engine has already read, and their contract now states what they actually do on a multi-row update. + + `sys_email_template`, `sys_sharing_rule` and `sys_webhook` each carry a hook that stamps `customized: true` when a non-system caller edits a package- or platform-seeded row — the half of seed-not-clobber that detects the admin edit. All three carried the same two comments, and both were assertions about runtime behaviour that runtime measurement falsifies: + + - **"multi-row updates (no single `input.id`) are not stamped."** Not true on any engine these packages ship against. A predicate (`multi: true`) update dispatches `beforeUpdate` once per matched row, and every per-row context arrives with `input.id` bound — so the `if (!id) return` guard answered "single write" on every row of a batch and declined nothing. The rows were being stamped all along. + - **"`previous` is not resolved before beforeUpdate hooks run — read the current row ourselves."** The engine binds `previous` before dispatching `beforeUpdate` on both write shapes, so each hook was issuing its own `find` for a row the engine had just read — on a bulk edit, one extra read **per matched row**. + + Observable behaviour is deliberately unchanged: the same rows are stamped, with the same values, and a bulk edit whose matched rows disagree on `managed_by` is still refused by the engine with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP 400) rather than widening one row's stamp across the batch. What changes is the cost and the contract: the redundant per-row read is gone, and the header of each hook now describes the per-row dispatch, the single `SET` clause a predicate write shares, and why declining to stamp on a bulk edit was rejected — unstamped rows are exactly the ones the next boot's seeder overwrites. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- 7ceb416: Webhook fan-out now matches subscriptions on the organization dimension, closing a cross-organization delivery on walled deployments (`OS_TENANCY_POSTURE=isolated|group`). + + `AutoEnqueuer` selected the subscriptions to deliver to by object name and trigger only, and every organization's `sys_webhook` rows live in one cache — so organization A's record events reached organization B's webhook endpoint, signed with B's secret, on first delivery. Both the per-record (`data.record.*`) and the bulk (`data.records.*`) fan-out paths now compare the subscription's own organization (`sys_webhook.organization_id`) with the organization the engine stamps on the event (`DataEvent.organizationId`, `BulkDataEvent.organizationId`): one equality per candidate, no lookup on the hot path. + + What changes for a subscription: + + - **Owned by organization A** — receives only events stamped A. An event that names no organization (an environment-wide row or an object outside the wall on the per-record path; a batch the tenant wall could not attribute to one organization on the bulk path) is not delivered inside the wall — fail-closed — and the first such refusal is logged once with the reason. + - **With no organization** (`organization_id` NULL — for example a package-declared webhook on a walled deployment) — no longer receives any organization-stamped event; the refusal is logged once per subscription. It still receives events that name no organization. On a `single`-posture deployment nothing stamps either side, so delivery there is unchanged. + + An event whose `organizationId` is present but not a non-empty string is dropped loudly as off-contract, delivering to nobody. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/service-messaging@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index b450749bee..81f65bc654 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.3.0", + "version": "17.4.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 7c015328e0..f7257492ce 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,230 @@ # @objectstack/dogfood +## 0.0.44 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [dcad825] +- Updated dependencies [6136293] +- Updated dependencies [07f40e5] +- Updated dependencies [6573af9] +- Updated dependencies [54bb2f1] +- Updated dependencies [fd014b1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [ac9376a] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [2e6a2ea] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [81919a7] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [dfb7a0d] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [cfb64a6] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [41cbc54] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [9f39897] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [be92d46] +- Updated dependencies [142c01c] +- Updated dependencies [17f8604] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [6f23f0e] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [9fa5775] +- Updated dependencies [ac6213e] +- Updated dependencies [d770b3e] +- Updated dependencies [f4e6adf] +- Updated dependencies [a4816a7] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [efc5447] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [1d73d45] +- Updated dependencies [c1d8f98] +- Updated dependencies [a646120] +- Updated dependencies [ebb5550] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [2e35765] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [b8c82de] +- Updated dependencies [dd2184a] +- Updated dependencies [9408b7f] +- Updated dependencies [ec0a6e7] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] +- Updated dependencies [c550baf] +- Updated dependencies [7ceb416] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/service-analytics@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/service-storage@17.4.0 + - @objectstack/plugin-email@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/connector-mcp@17.4.0 + - @objectstack/connector-openapi@17.4.0 + - @objectstack/connector-rest@17.4.0 + - @objectstack/plugin-sharing@17.4.0 + - @objectstack/mcp@17.4.0 + - @objectstack/plugin-webhooks@17.4.0 + - @objectstack/example-showcase@0.3.18 + - @objectstack/metadata-core@17.4.0 + - @objectstack/verify@17.4.0 + - @objectstack/example-crm@4.0.96 + - @objectstack/example-multi-package@0.0.3 + - @objectstack/plugin-audit@17.4.0 + - @objectstack/service-messaging@17.4.0 + ## 0.0.43 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index f4175a4288..0093702bd7 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.43", + "version": "0.0.44", "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 e2c941d381..04975035d0 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,130 @@ # @objectstack/downstream-contract +## 0.0.42 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + ## 0.0.41 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index cab016a168..8fc05f97b7 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.41", + "version": "0.0.42", "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 66f2c4fb83..38eb98f273 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,28 @@ # @objectstack/http-conformance +## 0.1.4 + +### Patch Changes + +- Updated dependencies [2ed6be6] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [2025b1f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [51ae731] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [4771bd9] +- Updated dependencies [d4f9b2a] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [f89812e] + - @objectstack/core@17.4.0 + ## 0.1.3 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 15c8e98b84..1816578767 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.3", + "version": "0.1.4", "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 15a7f00c9c..09ed103030 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,600 @@ # @objectstack/rest +## 17.4.0 + +### Minor Changes + +- 65846bc: fix(rest)!: an import ROW report spells a unique-constraint refusal `UNIQUE_VIOLATION` — the same wire code as the whole-request failure on the same route (#14723) + + + + **BREAKING** on the per-row results of the import runner + (`POST /api/v1/data/:object/import` and the import job): a row refused by the + engine's `DuplicateRecordError` envelope now reports `code: 'UNIQUE_VIOLATION'` + where it reported `'DUPLICATE_RECORD'`. Shipped as `minor` under the repo's + launch-window convention for breaking changes. Maintainer ruling 2026-09-03 on + #14723 (verbatim 「同意,然后执行契约复审」), adopting option A: one wire + spelling for a unique-constraint refusal on every route. + + **Why.** `toFailedResult` relayed the thrown error's own `code`, and the engine's + envelope carries the registered `DUPLICATE_RECORD` — while the whole-request + failure on the same import route answered `UNIQUE_VIOLATION` through + `mapDataError`. Two spellings of one condition on one route, which ADR-0112's + one-name-per-concept and the error-code ledger's header both forbid. The + duplication is removed, not declared: no ledger waiver is added. + + **What changes.** The import row derivation applies the whole-request arm's own + predicate — the registered code AND the class name `DuplicateRecordError`, + exported from `error-response.ts` as `isEngineDuplicateRecordEnvelope` and now + shared by the arm and the row report — and reports `UNIQUE_VIOLATION`. A + field-level finding still takes precedence (the envelope carries none), the + row's sentence is unchanged (the platform sentence, sanitised as before; no + driver text), and a producer that merely throws the registered + `DUPLICATE_RECORD` without being the engine's class keeps its own code. + + **What does NOT change.** The whole-request doors (single-record, bulk, import, + metadata, UI) already answered `UNIQUE_VIOLATION` and keep doing so; the arm's + logic is untouched beyond reading the shared predicate. The engine's thrown + identity stays `DUPLICATE_RECORD` in-process. This package's `error-response.ts` + docblock that disclosed the fork under the #14541 contract review now states + the converged rule. + + **Consumer note.** An import client that branched on a row's `code` reading + `DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it + already handles for the whole-request 409. Measured in-repo and in the sibling + repos (hotcrm, objectui, non-test sources): zero consumers branch on either + spelling of a row code. +- 784cb92: feat(spec): every `metadata.endpoints.*` switch gates exactly the face its name states, and the whole-store operations get their own key `maintenance` (#15542, #15854) + + `RestServerConfig.metadata.endpoints` declared three switches, each `describe()` naming + exactly one route, and each gated a different set. The mismatch ran in **both** + directions at once: + + - **`items`** — declared "GET /meta/:type - List items of type" — also gated the + whole-store family: the cross-type spec-validation sweep `GET /meta/diagnostics`, the + draft list `GET /meta/_drafts`, and the **`POST /meta/_migrate-stored` write door**. + An operator who switched off a listing read they considered chatty silently unmounted + a migration door. + - **`item`** — declared "GET /meta/:type/:name - Get specific item" — gated four + *reads* (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and left the + per-item **writes** `PUT` and `DELETE /meta/:type/:name` plus the whole history family + (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) answering to + `api.enableMetadata` alone. An operator who closed the per-item surface left its + writes mounted. + + Neither is a liveness defect — all three keys were genuinely read — which is why no + ADR-0049 census could ever flag them: what drifted was each key's **radius** against its + own documentation. + + **One principle now holds across the block: a switch gates exactly the face its name + states, reads and writes alike.** + + | key | mounts it gates (default prefix `/meta`) | + |---|---| + | `types` | `GET /meta`, `GET /meta/types` — one handler, two paths (unchanged) | + | `items` | `GET /meta/:type` — and nothing else | + | `item` | `GET` / `PUT` / `DELETE /meta/:type/:name`, `/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`, and `GET /meta/book/:name/tree` | + | `maintenance` | **new** — `GET /meta/diagnostics`, `GET /meta/_drafts`, `POST /meta/_migrate-stored` | + + All four `describe()` strings are rewritten to enumerate what they gate, so the + generated reference page is the radius rather than a sample of it. + `api.enableMetadata` remains the master switch above all four, and + `GET /meta/object/:name/state/:field` — the object FSM read, addressed by object name + rather than by `:type/:name` — deliberately stays under that master switch alone. + + **BREAKING** — for a programmatic embedder that authors `RestServerConfig.metadata.endpoints`, + the mounted route table moves for two of the four keys, in opposite directions: + + - **`items: false` now removes one route instead of four.** An embedder relying on it to + close `/diagnostics`, `/_drafts` and the `POST /_migrate-stored` door **regains all + three** unless it also sets `maintenance: false`. That is a write door coming back, so + it is the half to read twice. One line restores the old table: + `endpoints: { items: false, maintenance: false }`. + - **`item: false` now removes twelve routes instead of four.** An embedder relying on it + to close only the per-item *reads* while keeping `PUT`, `DELETE` and the history family + mounted **loses those eight**. There is no key that restores them — the per-item face is + one face by this ruling — so an embedder that wants the writes keeps `item` on and + closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`. + - **The exported type `MetadataEndpointsConfigParsed` narrows: `endpoints` gains a + REQUIRED member `maintenance: boolean`.** `maintenance` is `z.boolean().default(true)`, + so it is optional on the way *in* and always present on the way *out* — and + `MetadataEndpointsConfigParsed` is `z.infer`, the + OUTPUT side. Any code that builds one of these objects by hand — a test fixture, a + helper returning the parsed shape, a `satisfies MetadataEndpointsConfigParsed` literal — + stops compiling with `TS2741: Property 'maintenance' is missing`. This one IS + compiler-carried (the ADR-0087 D8 class), which is the good case: the break is loud, it + lands at build time, and no runtime behaviour depends on the author noticing a + changelog. Add `maintenance: true` to restore the previous mounts, or `false` to keep + the whole-store family closed. In-repo consumers of the type: none — the narrowing was + measured against a probe compiled from the rebuilt declaration, not assumed. + + Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no + shipped boot path** today (`os serve` fixes the config and the dev plugin passes none, + #15543), so the measured population of affected authors is **zero** and the blast radius + is programmatic embedders only. That is precisely why this lands now — once a boot path + starts authoring the config, the same change becomes a behaviour change on live + operators. + + **ADR-0087 disposition: a D3 semantic migration, no D2 conversion.** No + authored key changes shape or spelling — `items: false` still parses to `items: false`, + `maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema` + stays the #14691 tombstone it already was). There is nothing for the conversion layer to + convert: a `RestServerConfig` is plugin TS configuration, never a stack collection member + and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no + rehydration seam sees it. What changes is a mounted route table at construction time. + + Nor is the RADIUS change compiler-carried on the AUTHORED side — and that is the half a + D3 is owed for. Every authored key is an optional boolean, so `{ items: false }` still + compiles and still parses and simply mounts a different table: the author is told + nothing. (The parsed-type narrowing in the third BREAKING bullet above *is* + compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot + reach the embedder who authored `{ items: false }` and now silently gets three routes + back.) So for the change that actually moves the route table, both channels that would + otherwise reach a consumer are blind, which is precisely the residue D3 exists for — the prescription is registered as + `metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands + it to an upgrading embedder instead of leaving it as prose in a changelog. + + + + `@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where + the gates live, so the route-table change is observable there and not only in the + declaration. + + Every key's radius is pinned route by route, in both directions, in + `packages/rest/src/rest-config-mount-table.pin.test.ts` — the #15544 shape, which asserts + each route is **absent from the mounted table** when its switch is off rather than what + the switch normalizes to. A gate that grows or loses a route reddens there. +- f5cc78b: fix(rest): the generic declared-status passthrough names its object on both error doors (#14725) + + **Response-body change on the published bulk / metadata / UI doors: one optional + key is added, `object`.** Nothing is removed, no status moves, and no `code` + value changes spelling. + + #14541 made the two REST error doors agree for every refusal a *bespoke* arm + classifies. They still disagreed for every refusal that reached the *generic* + declared-status passthrough, because the two copies of that one passthrough + differed by exactly one key: `classifyDataError`'s copy ends + `...(object ? { object } : {})` and `resolveErrorResponse`'s 4xx arm had no such + limb. Measured on `main` @ `a12b15e394` — one error object, both doors: + + | door | before | + |---|---| + | `mapDataError(err, 'duly_note')` (single-record `/data`) | `409 {"error":"…","code":"DUPLICATE_RECORD","object":"duly_note"}` | + | `sendThrownError(res, err, 'duly_note')` (bulk / metadata / UI) | `409 {"error":"…","code":"DUPLICATE_RECORD"}` | + + One refusal, two bodies, decided by which route caught it — the #14541 shape one + arm over. The bulk door now answers the first row too. + + It closes the same card's second residue with it. `recordNotFoundError` + (`@objectstack/core`) declares `code`, `status = 404` **and** `object`, so that + declared status carries a record-level not-found past the `RECORD_NOT_FOUND` arm + into this same generic passthrough on every route reporting through + `handleRouteError` / `sendThrownError`, while the single-record `/data` door + reached the generic arm in `classifyDataError` and shipped the name. Both doors + now agree for that producer in every combination of declared status and + door-supplied object. + + **Who sees the new key.** The name comes from the door's `object` *argument*, + never from `error.object`, so only a route that supplies one is widened. Of 35 + route call sites of this door, **9** pass an argument that can be a non-empty + object name — `POST /data/:object/batch`, `/createMany`, `/updateMany`, + `/deleteMany`, `POST /data/:object/:id/clone`, `POST /data/:object/import`, + `POST /data/:object/import/jobs`, `GET /data/:object/export`, and + `GET /ui/view/:object/:type`. The other 26 (21 passing nothing, 5 passing the + literal `''`) answer byte-identical bodies. `classifiedRefusalAnswer` — the + entry point the analytics dataset face and the record-share family re-dress — + calls this door with no `object` argument at all, so those envelopes' key sets + do not move. + + **What deliberately does not change.** The declared-**5xx** arm gains nothing: + its sibling `declaredServerFaultAnswer` names no object either, so the two doors + already agreed in that band and adding the limb there would *create* a + divergence, on top of putting a caller-supplied name into a body whose whole + rule is that a declared server fault says nothing beyond status and code. The + `RECORD_NOT_FOUND` arm's message-**text** limb + (`/^Record \S+ not found in \S+/i`) is not lifted above the passthrough either — + that boundary is #14541's, and it is now pinned behaviourally and positionally + rather than described. + + Consumer note: a client that key-counts or exact-matches an error body from a + bulk, import, export, clone or UI-view route will see `object` alongside `error` + and `code` where the equivalent single-record `/data` response has carried it all + along. A client that reads named fields is unaffected. +- 7370989: `GET /api/v1/packages`, `GET /api/v1/packages/:id` and `DELETE /api/v1/packages/:id` have one implementation: the runtime dispatcher's `/packages` domain. `@objectstack/rest`'s `registerPackageRoutes` no longer mounts its own copies of those three routes; it mounts `POST /api/v1/packages/publish` and nothing else. + + The two copies had already diverged, and a comment in the REST registrar claimed its copies shadowed the dispatcher's while on a stock boot they were never mounted at all (the registrar decided at registration time, before the `package` service had registered). One URL, one body, ruled on #14503. + + What changes on the wire, for a deployment whose composition really did reach the REST copies: + + - `GET /packages/:id` answers `{ success: true, data: }` — the installed-package row directly under `data`. FROM `data.package` TO `data`. There is no `{ package }` wrapper. + - The rows on `GET /packages` and the row on `GET /packages/:id` carry no `source: 'registry' | 'database' | 'both'` key. **Deliberately removed**, not ported: it had no reader outside the REST registrar's own tests — none in this repo's production code, the Console, the docs or the OpenAPI document, and the SDK declined to declare it twice on purpose. + - `GET /packages` and `GET /packages/:id` read the **installed** packages from the in-memory registry (`registry.getAllPackages()` / `registry.getPackage(id)`) and nothing else. The REST copies merged the durable `sys_packages` rows (`PackageService.list()` / `.get(id, version)`) into the registry set, so a package **published but not installed** was listed there and gettable there; on the surviving door it is neither. **Deliberately removed** with the routes, not silently dropped: the published-artifact store keeps its own surface (`POST /packages/publish` here, the marketplace browse elsewhere), and the family this door serves is the installed set. + - `?version=` is not read on `GET /packages/:id` or `DELETE /packages/:id`, so its repeated-parameter refusal (`400 VALIDATION_ERROR` on `?version=a&version=b`) is gone with it. **Deliberately removed**: the single implementation reads the installed package from the registry, and a version-scoped durable lookup was a behaviour only the REST copy had. The one in-tree sender is the SDK's `ScopedEnvironmentClient.packages.get(id, version?)`, whose binding is tracked on #12034. + - A missing package answers `404 RESOURCE_NOT_FOUND` with the message `Package '' not found` (the dispatcher's spelling) instead of `Package "" was not found.`. + - `DELETE /packages/:id` uninstalls the package (registry plus persisted metadata rows, `?keepData=true` to keep the object tables); the REST copy's version-scoped delete of a published artifact is gone. + - **The uninstall's tenancy width narrows.** The REST copy called `protocol.deletePackage({ packageId, allTenants: true })` — a package-wide uninstall across every tenant, the width #7705 case 4 pinned on purpose because that registrar had no organization to resolve. The surviving door calls `protocol.deletePackage({ packageId, organizationId?, keepData? })` with the organization it resolves for the caller (`resolveActiveOrganizationId`), so a `DELETE /packages/:id` that used to reach the REST copy now removes the package's metadata for the caller's active organization, not for all tenants. **Deliberately narrowed**, not silently dropped: one door, one width, and it is the width the dispatcher has always answered on every stock boot. + - **Capability refusals answer a different `error.code`.** On all three routes a caller holding neither `manage_metadata` (write) nor `studio.access` / `setup.access` (read) is refused with `403 PERMISSION_DENIED`. FROM `403 FORBIDDEN` TO `403 PERMISSION_DENIED`: the removed REST copies emitted `sendError(res, 403, 'FORBIDDEN', …)` explicitly, while the dispatcher's `requireManageMetadata` / `requireReadCapability` (`packages/runtime/src/domains/packages.ts`) call `deps.error(message, 403)` with no code and `packages/runtime/src/error-envelope.ts` derives one from the status — `standardErrorCodeForHttpStatus(403)` = `PERMISSION_DENIED`. **Same status, same message**: the two cohort messages ("Managing packages requires the `manage_metadata` capability." and "Reading packages requires the `studio.access` or `setup.access` capability.") are identical on both doors. Both codes are ADR-0112 standard members, so the envelope shape is unchanged; what moves is that a client branching on `err.code === 'FORBIDDEN'` for a package read or delete refusal stops matching on any composition that really did reach the REST copies. + + `POST /api/v1/packages/publish` is unchanged. + + Spec conformance on the surviving door is claimed for `GET /packages/:id` **only**: its `{ success, data: , meta }` is exactly `GetInstalledPackageResponseSchema` (`packages/spec/src/api/package-api.zod.ts`, `data: InstalledPackageSchema` bare). The other two routes do **not** match their declarations, and the REST copies did not either — this drift is **pre-existing, not introduced by this release**, and is carded on #16781: + + - `GET /packages` answers `{ packages, total }`, while `ListInstalledPackagesResponseSchema` requires `hasMore` (and declares `enabled` / `limit` / `cursor` inputs the door does not read). + - `DELETE /packages/:id` answers `{ success, registryRemoved, persisted }`, while `UninstallPackageApiResponseSchema` requires `packageId`. + + Nothing in this release changes either shape; with one implementation there is now exactly one thing to reconcile, and #16781 carries that reconciliation together with the `responseSchema` pins the runtime ledger rows for `packages.list` / `packages.uninstall` still lack. + + `GET /discovery` on the REST server now advertises `routes.packages` on every boot — the family base under which its publish route is mounted — instead of only when its own copy of the list route had been mounted at start. On a stock `objectstack serve` boot that copy never was (the `package` service registers after the REST plugin starts), so discovery omitted `routes.packages` while the dispatcher served the family; the SDK's convention fallback covered it. + + The three removed REST rows are gone from `REST_ROUTE_LEDGER`; the runtime route ledger carries the surviving routes. + + The environment-scoped mount (`/environments/:environmentId/packages…`) is served by the same dispatcher domain **only where the `@objectstack/hono` catch-all is mounted** (`createHonoApp`): the catch-all strips the environment prefix and hands the request to the domain. The dispatcher plugin's own explicit mounts (`plugin-hono-server`) register `/packages*` at the **unscoped** prefix only, and that plugin's sole route into the dispatcher (`setFallbackHandler`) serves declarative `apis:` endpoints, not domains. So a host composed as `plugin-hono-server` + the REST plugin with `enableProjectScoping: true` + the dispatcher plugin, **without** `createHonoApp`, had exactly one door for scoped package reads and deletes — the REST mirror this release removes — and after it has none: the scoped `GET /environments/:id/packages`, `GET /environments/:id/packages/:id` and `DELETE /environments/:id/packages/:id` answer the transport's plain 404 there. That composition is reachable from the open-core CLI when the standalone boot is skipped (`shouldBootWithLibrary()` false — any host config, or `OS_MODE=off`) and `api.enableProjectScoping` is forwarded verbatim. Every consumer population reachable from this repo is zero for the scoped mount (no in-repo production caller of `ScopedEnvironmentClient.packages.*`, no Console call to a scoped `/packages` URL); it is stated here so it is a known gap rather than a silent one. On a `plugin-hono-server` composition with `enableProjectScoping` and no `createHonoApp`, the scoped `/api/v1/environments/:id/packages[/:id]` routes have no door until #16781 lands (ruled C′ on #14503). +- 3d3f60e: An approval decision that lands while its flow run strands now says so in fields, not only in prose. + + `POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all. + + The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said: + + - **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status. + - **`repairable` carries the engine's own discriminator** — `AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise. + - **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built. + + `@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope. + +### Patch Changes + +- 4bc9821: An organization-scoped caller's own items now appear in the untyped metadata diagnostics sweep. + + `GET /api/v1/meta/diagnostics` has two arms. The `?type=` arm has stated the caller's organization since #13753; the untyped whole-registry sweep passed none, so the Studio governance summary reported clean tiles over a partition it never read — undercounting relative to the per-type drill-down screen you reach by clicking into it. A summary whose whole job is surfacing problems, and which structurally cannot see a class of them while its own drill-down can, issues a false all-clear. The untyped arm now forwards the caller's organization, so items that organization authored on the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`) are counted in `stats`, `total` and `scannedItems`. + + The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field, status code or contract surface. There is no single type to fold on for a whole-registry sweep, and folding on any one of them would suppress the organization for every type at once; instead `getMetaDiagnostics` reads each swept type through `getMetaItems`, which applies the `allowOrgOverride` read gate to its own request type, so every type is scoped on its own registry flag. A non-overridable type (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into the report. An anonymous or organization-less caller reads exactly what it read before, and the `stats` / `total` / `scannedTypes` arithmetic is unchanged in shape. +- 61b4eb3: `client.meta.getHistory` answers the published `HistoryMetaItemResponse` on **both** of its exits, and the route ledger names the schema. + + **BREAKING (types):** the unscoped `client.meta.getHistory` declared a hand-written inline shape whose `actor` member was `string`. The door answers `null` there for every system-initiated write — boot sync, migration, a scheduled job — and the published schema declares it "never a sentinel string", so consumers that resolve the actor against `sys_user` must be able to tell "nobody" from "a user id". Reading `actor` without a null check compiled against a promise the door has never made; it no longer compiles. The same rebind closes the vocabulary of `op` (the ADR-0008 §2.4 change-log verbs, previously a plain `string`). + + Three members the inline shape omitted become reachable in the same move: `version` (the per-`(org,type,name)` lineage counter that `rollbackItem({ toVersion })` pins against), `previousName` (set on `op: "rename"`), and `ref.version`. `ref.org` was declared optional and is now what the producer always writes. + + The scoped twin — `client.environments.use(id).meta.getHistory` — carried no declaration at all: no return annotation, and the SDK's internal unwrap called with no type argument, so the published method resolved to `Promise` and every caller had to narrow by hand against nothing. It is the SAME mount as the unscoped exit, replayed against `/environments/:environmentId`, so it answers a byte-identical body; the two now name one type. Binding only one exit would have relocated that divergence rather than removed it, and the equality of the two declared types is pinned rather than left to review. + + `@objectstack/rest` is `patch`: the route-ledger row for `GET /api/v1/meta/:type/:name/history` now names `HistoryMetaItemResponseSchema`. Data only, in a package-internal module — no route, handler or emitted byte changes. The row could not name the schema before because the declaration (#12005) landed after the row was written. + + No wire byte moves anywhere in this change. `HistoryMetaItemResponseSchema` is a describe-only transcription of what `historyMetaItem` already returned, and the SDK's runtime path is untouched — only what the compiler knows about it. + + +- a84e1ce: fix(rest): metadata label lookup honours the stack's declared `i18n.fallbackLocale` / `defaultLocale` instead of falling through to the `en` bundle (#14882) + + On a workspace whose labels are authored in `zh-CN` (`defaultLocale: 'zh-CN'`, + `fallbackLocale: 'zh-CN'`) and which ships only a courtesy `en` translation bundle, + `GET /api/v1/meta/object/:name`, the `/meta/:type` list, `GET /api/v1/meta` and the + public-form schema served the ENGLISH bundle labels to a `zh-CN` request (`Entry Sheet` + for an authored `填报单`, `KPI Assessment` for `KPI 考核管理`). The document translators walk + `requested locale → fallback chain → authored label` and default the chain to a literal + `['en']`; every REST seam passed none, so the declared fallback never reached the chain + and `en` was consulted before the authored label. + + Every metadata translation seam now passes `fallbackChain: [i18n.getFallbackLocale()]` — + the locale the i18n service's own `t()` falls back to, which `I18nServicePlugin` receives + from the stack config as `fallbackLocale || defaultLocale || 'en'`. For the workspace + above a `zh-CN` request now resolves `zh-CN → zh-CN → authored label` (the authored + Chinese labels), an `en` request still gets the `en` bundle, and a `zh-CN` bundle, when one + is shipped, still wins over the authored label. + + Feature-detected: an i18n service that does not declare a fallback (the method is + optional on `II18nService`; the core in-memory fallback has none) gets no chain and the + resolver's own default applies exactly as before. A stack declaring `defaultLocale: 'zh-CN'` + with `fallbackLocale: 'en'` is likewise unchanged — the declared `en` is honoured as it + reads. +- 3e7ef9c: Serve an Invalid `Date` from a driver as visible text instead of raising `RangeError` in the import-job DTO and the CSV export. + + `canonicalIsoStamp` and `formatCsvCell` both reached `value.toISOString()` for any `Date`, and that call raises `RangeError: Invalid time value` for the one `Date` whose time value is `NaN` — so one bad timestamp column answered **500** on `GET /api/v1/data/import/jobs/:jobId` and aborted a CSV export mid-stream. + + The shape is measured: mysql2 3.23.1 returns a module constant literally named `INVALID_DATE` for a zero `DATETIME`, and postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276, a range Postgres itself stores. + + Both arms now guard on `Number.isNaN(value.getTime())` and render the visible text `"Invalid Date"` — the rendering the spelling they replaced produced. Both are read by a human, and the declared contracts allow it: the four import-job stamps are plain `z.string()` (not `z.string().datetime()`), and a CSV cell has no schema at all. The operator sees a wrong-looking field they can report, rather than an error naming no row. + + The CSV arm needs its own guard rather than a fall-through, because the branch below it is `JSON.stringify` and `Date.prototype.toJSON` answers `null` for an Invalid `Date` — the silent blank this change exists to avoid. Both CSV paths land on the guarded arm: with field metadata, `formatDate` rejects an Invalid `Date` and passes the value through unchanged. +- d5c4022: `GET /forms/:slug/lookup/:field` answers a search again: the public-form lookup picker no longer refuses every non-empty query with `400 INVALID_FILTER`. + + The route composed its filter list out of `ViewFilterRule` objects — the `{ field, operator, value }` dialect `FormFieldPublicPickerSchema.filter` declares in so many words ("Same `{ field, operator, value }` dialect as list-view filters") — and put them straight onto the `findData` filter slot. That slot accepts a `FilterCondition` object or a `FilterArray` (`[field, operator, value]`, a logical node, or a list of those) and refuses anything else. The refusal did not depend on an author declaring `publicPicker.filter`: the route's own `q` predicate is built in the same object shape, so **every** non-empty search was refused and only the degenerate empty-filter call could succeed — on an anonymous surface where a public-form applicant has no way around it. + + - **The route lowers; the parser is untouched.** The composed rows are translated to the array grammar the ingress parses, at the one door that speaks both dialects. ⛔ The repair deliberately NOT taken is teaching `findData` a second dialect: that maintains two filter grammars in the data layer permanently and spreads the object shape to every `findData` caller. The declaration already promises the object dialect on the authoring surface, so what changes is the side that failed to honour the promise. A test keeps the control that the object shape fed to the parser directly is still refused, so "the route lowers" cannot be confused with "the parser was loosened". + - **Both branches.** The declared `publicPicker.filter` rows and the route's own `contains` search row are lowered together and ANDed explicitly; no declared filter still means no filter (`[]`), never an empty logical node the ingress would refuse. + - **The operator fold is the spec's own.** Lowering reuses `normalizeFilterOperator` from `@objectstack/spec/ui` — the fold `ViewFilterRuleSchema.operator` itself runs — so a stored row carrying a legacy spelling (`notEquals`, `isNotEmpty`, `gt`) folds exactly as the schema folds it. No second alias table. + - **A rule that cannot be read is forwarded, not dropped.** The request is then refused exactly as before. That direction is deliberate: a picker's static filter is often the only thing keeping an anonymous visitor's search inside the rows a form may expose, and silently skipping a row nobody understood would answer 200 over an unfiltered table. + + No authoring surface moves: `FormFieldPublicPickerSchema` already declared this dialect as accepted, and this makes the runtime honour it. +- e13ede8: The admin "Used by" panel no longer clears a delete when the caller's own organization is using the item. + + `GET /api/v1/meta/:type/:name/references` backs that panel, whose empty case reads "Nothing in the metadata graph points at this item. Safe to delete." — advice given to an operator about to delete something. The door supplied no organization, so the reference sweep read the environment partition only: an organization-scoped `view` (or `dashboard`, `report`, `translation`, `email_template`) pointing straight at the object being deleted was invisible, and the panel issued a false clearance. It now passes the caller's organization, and those references are returned. + + The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field or contract surface. `req.params.type` is the reference TARGET, while the sweep spends the organization on the SOURCES it reads per type; `getMetaItems` applies the `allowOrgOverride` read gate to its own request type, so each source is scoped on its own registry flag. A non-overridable source (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into a delete clearance. An anonymous or organization-less caller reads exactly what it read before, and no status code or response shape moves. +- 7d7ca6c: `GET /api/v1/meta/:type/:name/references`: both of the door's 501 refusals now answer the same ADR-0112 nested envelope, and the unanswerable-target refusal keeps the prescriptive message ADR-0110 D3 requires of it. + + The route can refuse in two ways, and the two answers agreed on neither the envelope nor the message: + + ``` + A the protocol cannot answer for this TARGET type (a `field`) + 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} + B the resolved kernel has no `findReferencesToMeta` at all + 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} + ``` + + A now answers in B's shape, carrying the producer's own sentence: + + ``` + 501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}} + ``` + + Why the message matters more than it looks. This door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." to an operator whose next click is a delete. A `field` target can never MATCH a reference site — fields are addressed by the composite `.` key while every property naming one holds the bare name — so the protocol refuses instead of answering an empty list, and its message names the question that IS answerable: ask the owning object. Relayed as "Internal server error", that instruction never reached the operator. + + Two consequences for a caller: + + - `body.error.code` now reads `NOT_IMPLEMENTED` on **both** refusals; the top-level sibling `body.code` this route used to answer on refusal A is gone. `@objectstack/client` reads either position, so `err.code` is unchanged for SDK callers; `err.message` improves from `Internal server error` to the prescriptive sentence. A raw HTTP caller branching on `body.code` for this route's 501 should read `body.error.code`, which is what the route's other refusal has always answered. + - Nothing else on the door moves. A genuine server fault reaching this route — the 503 a `sys_metadata` outage raises — keeps its withheld generic message and its flat body, and 200 answers are untouched. +- 53cbad9: The REST server's `api` configuration defaults now come from `RestApiConfigSchema` alone, instead of being restated in `packages/rest`. + + `RestServer.normalizeConfig` already parsed `config.api` against `RestApiConfigSchema` — and then discarded the result, rebuilding the block from a `??` chain over the raw input. That chain restated the schema's eleven top-level `z.default(...)`s as eleven literals in a second package. They agreed key for key, and nothing measured that they would keep agreeing: changing a default in `@objectstack/spec` silently failed to propagate, because `api.enableUi ?? true` answers `true` for an absent key whatever the schema declares. Consuming the parse deletes the duplicate and makes the schema authoritative. + + The parse itself is unchanged, so **nothing new is accepted or refused**: the same schema, with the same `.omit({ requireAuth: true })`, already ran at construction. `api.requireAuth` keeps its retired warn-and-ignore posture (`@objectstack/rest`'s plugin reads it off the raw config, so the warning is untouched), and every authored value still wins over the default. + + One bounded behaviour change, for a caller who writes `api.documentation` or `api.responseFormat` — and it runs in two directions, not one. **Filled in:** those objects now arrive carrying their own declared inner defaults — `documentation.enabled` / `.title`, and `responseFormat.envelope` / `.includeMetadata` / `.includePagination`. **Stripped:** inner keys the schema does not declare no longer survive, at either depth — an authored `documentation.logo`, a `documentation.contact.phone` or a `documentation.license.spdxId` inside the nested objects, a `responseFormat.extra` — where the `??` chain passed the authored object through by reference and kept every key on it. Both halves are the same parse: `documentation` / `responseFormat` (and their `contact` / `license`) are non-strict `z.object()`s, which fill in their `.default()`s and drop what they do not name — dropped silently, so this is a strip and not a new refusal. An object left unwritten stays absent, and nothing in the platform reads either key today: the normalized block is `private` to `RestServer`, which reads only scalars off it (`apiPath` / `basePath` / `version` in `getApiBasePath`, the `enable*` flags, `projectResolution`), and the repo has no other read site for either key — so no consumer observes either half. +- 9b459b7: The REST data doors' protocol requests are compiled against the declared contract again, so a field added to a data request schema reddens the build instead of going silently unsent. + + No runtime behaviour changes — every door assembles and forwards exactly the object it did before. What changes is what the compiler is allowed to see. `packages/rest/src/rest-server.ts` dispatched to the protocol through two erasing forms: `p.deleteData({ … } as any)` on the argument, and the stronger `(p as any).updateData({ … })` on the protocol object itself, which erases the check on *every* member — a misspelled method name would not have errored. Across the file that was 22 dispatch sites spanning `findData` / `getData` / `createData` / `updateData` / `deleteData`, their `*Many` and batch siblings, and `getUiView`. + + The casts were load-bearing rather than lazy: these call sites pass `environmentId` and `context`, and neither is a member of any data request schema. Neither should become one. `environmentId` is the transport routing key that selects the kernel *before* the protocol call and is already ruled out of the request shape; `context` is the server-derived execution context, and a caller-supplied `context` is a privilege escalation the ingress deletes unconditionally — putting it in the published request schema would re-open that door. Both are now declared on a typed envelope alongside the request type, so they stay server-side *and* compiled, and every other member of every literal is checked against the spec. + + One slot stays deliberately untyped and is now named rather than diffuse: `findData`'s `query` accepts both the declared AST and an undeclared wire dialect (`$top`, `$orderby`, `filters`, …) that the protocol normalizer folds. Three server-built literals speak that dialect; the erasure there is confined to the query slot alone, and the declared-versus-shipped mismatch is filed as its own question. +- 1e43386: `OBJECT_API_METHOD_NOT_ALLOWED` now names the conjunct that actually failed, instead of one its own `allowed` array lists. + + An object declaring `apiMethods: ['get','list','update','bulk']` refused `deleteMany`, `createMany` and each op of a cross-object `POST /batch` with an identical body: + + ```json + { "error": "API operation 'bulk' is not allowed on object 'sys_user'", + "code": "OBJECT_API_METHOD_NOT_ALLOWED", + "allowed": ["get","list","update","bulk","aggregate","history","search","import","export"] } + ``` + + Every one of those refusals was correct in outcome — `deleteMany` is `bulk ∧ delete`, `createMany` is `bulk ∧ create`, and `updateMany` / `batch`, which need only `bulk`, are still admitted — but the message named the half that PASSED, and the same envelope listed it as allowed. The writeMode-refined `import` had the identical shape: `import` derives from create ∨ update, so `update` alone puts `import` in the effective set while an `insert` import still needs `create`. + + The message now names a conjunct that is genuinely missing: `delete`, `create` or `update` for the cases above, and still `bulk` when the `bulk` primitive itself is what the object withholds. Three requests that previously produced one indistinguishable envelope are now told apart. + + **`allowed` is unchanged, in contents and in meaning** — it is still the object's declared effective operation set, not the set the gate evaluated against. That matters because the array is read as a discriminator: a declaration re-widened to create/update can still 405 for an unrelated reason, so only the set proves which gate answered. Nothing about which requests are admitted or refused moved; the HTTP status, the `code` and the `object` field are all as before. A client matching on the `error` string for these bulk and import refusals sees the new name. +- 289bb43: The REST server's own `findData` calls now build the canonical QueryAST instead of an undeclared wire dialect, and the helper that erased the type on that one slot is gone. + + Four server-built query literals in `rest-server.ts` — the import-job loader, the import-job listing, the export chunk loop and the public reference picker — spelled their query in transport aliases (`$filter`, `$top`, `$skip`, `$orderby`, `$expand`, plus the bare `filters` / `select` / `sort`). None of those spellings is declared by `QuerySchema`, so three of them were routed through a `wireDialectQuery` helper that cast the `query` member to `FindDataRequest['query']`, and the fourth escaped the compiler entirely because its protocol handle was typed `any`. All four now spell `object` / `where` / `orderBy` / `limit` / `offset` / `fields` / `expand`, so the slot compiles against the declared contract like every other member of the request, and the helper is retired. + + **No behaviour moves, and that is measured rather than asserted.** `@objectstack/metadata-protocol`'s `findData` folds every alias onto its canonical key by the spec's own table (`RPC_QUERY_ALIAS_SLOTS`) and moves the value verbatim, so both spellings reach `engine.find` as the same option bag. `rest-server-canonical-query-ast.test.ts` drives all four before/after pairs through the real normalizer and asserts that equality, and reads the source to keep the erasure retired — a cast compiles, so a type-check alone could not hold this ground. + + **Nothing is removed from the published surface.** `wireDialectQuery` was a module-local `const` in `rest-server.ts`: it carried no `export` keyword, `packages/rest/src/index.ts` never named it, and it appeared in no other file in the tree. Deleting it moves no exported symbol, which is why this is a patch. + + **What this change deliberately does NOT do:** it does not touch how the HTTP door treats a *caller's* query. The wire aliases stay accepted on `GET /data/:object` exactly as before — declaring them in the spec's alias table is a separate piece of work — and `GET /data/:object` still forwards the caller's own querystring bag untouched. +- 46803fa: fix(rest): the metadata reads pass the declared default locale to the label resolvers, so a request for it answers with the authored label (#15711) + + `translateOptionsFor` — the single seam every metadata-document translation in the REST server goes through — now threads `i18n.getDefaultLocale()` into `ResolveOptions.defaultLocale` beside the declared fallback chain it has passed since #14882. Both accessors are optional on `II18nService` and both are feature-detected: a provider that declares no default gets no default, one that declares no fallback gets no chain, and the seam never answers `'en'` on a provider's behalf. + + Measured on the reporter's stack shape (`defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, an `en` bundle and no `zh-CN` bundle): `GET /api/v1/meta/object/kpi_entry_sheet` with `Accept-Language: zh-CN` — or with no header at all, which resolves to the default — now serves the authored `填报单`, not the `en` bundle's `Entry Sheet`; a `fr` request still walks the declared `en` bundle; an `en` request still gets the `en` bundle. Pinned in `meta-i18n-declared-fallback-chain.test.ts` §4 and §5. +- a727043: fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200 + + Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an + organization its owner is no longer a member of **read and wrote that + organization's rows** on the wiring the open core actually builds. Not a silent + empty set — a GET that returned the other organization's records, and a POST + that landed a row read back from the store carrying that organization's id and + the ex-member as its creator. An organization-less key on the same deployment + read `200` with an empty set, which is the silent failure the wall exists to + replace. + + The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the + effective tenancy posture from a per-request kernel, and on the single-kernel + wiring there is no per-request kernel — so the posture was `undefined` on every + request, and both posture-conditional API-key refusals are gated on it: + `organization_required` in `api-key.ts` and `organization_membership_ended` in + `resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was + active the whole time; it compares against the caller's active organization, + and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim + — the holder's own stored claim. Enforcing the wall is what let the ex-member + through, because the one fact that would expose the ended membership was not an + input to the layer that could act on it. + + The single-kernel branch now derives the posture from a provider `rest-api-plugin` + wires to the lone local kernel's `tenancy` service, in the same shape as the + auth-service provider beside it. A host that registers no `tenancy` service is + unchanged and still admits: there is no wall on such a deployment, so there is + nothing for an organization-less key to be walled out of. A `tenancy` service + that was registered and **failed to build** is an outage and answers `503`, not + an admission — a posture that could not be read is not a posture that is absent. + + Refusals are now also said out loud on the server side, at `warn`, where each + one is decided: the key's row id (never the credential or its hash), the + principal, the organization and the reason. **The wire is unchanged** — both + refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the + body, so a holder of someone else's key learns nothing a plain 401 does not + already tell them. The operator, who previously had a key that was neither + revoked nor expired and a 401 that said nothing, now has a line to find. + + Behaviour that does not move: a current member's key on the same route still + returns its rows and still writes; a request with no credential still answers + 401; and an unknown, revoked or expired key is not a refusal at all, so a key + scanner produces no log volume. +- ce478db: Correct the out-of-package comments that still described `SqlDriver#formatOutput`'s + two timestamp passes as gated on `if (this.isSqlite)`. + + Since ADR-0053 D-F1 (#13973) both passes — the `AUDIT_TIMESTAMP_COLUMNS` pass and the + `normalizeSqliteDatetimeOutput` pass over `datetimeFields` — run on every dialect, so a + declared `Field.datetime` and the builtin audit columns are presented as canonical + ISO-8601-`Z` text on Postgres and MySQL as well as SQLite. The `rest-server.ts` comment + went further than staleness: it warned future authors that "a declared `Field.datetime` + is therefore NOT protected on Postgres/MySQL", inviting exactly the tolerant consumer-side + coercion ADR-0053 forbids. + + Comments only — no runtime behaviour, no exported symbol and no public type changes; the + published `.d.ts` of both packages is byte-identical. These two packages are named because + their bundled `dist/index.js` / `dist/index.cjs` carry the amended comment text verbatim, + so the published output does change. `@objectstack/metadata` carries the same correction + in `database-loader.ts` but is deliberately NOT named: its edits are all JSDoc blocks, + which its bundle strips, so its published output is unchanged. + + Two carve-outs are preserved rather than flattened: `withPostgresCalendarDayAsText` is + untouched by that ruling (D-F2 — the client library still materialises `timestamptz` / + `DATETIME(3)` as a `Date`; the driver now folds it at its own read boundary), and the + Invalid `Date` residue still stands (D-F3 — the one `Date` shape with no canonical text + leaves the read door unchanged). +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/metadata-core@17.4.0 + - @objectstack/observability@17.4.0 + - @objectstack/service-package@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 3bfe41d842..e915bf76f1 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.3.0", + "version": "17.4.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 357f7534a5..bde87a6903 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,916 @@ # @objectstack/runtime +## 17.4.0 + +### Minor Changes + +- 233222e: feat(spec)!: `timeDimensions[].dateRange`'s string arm closes to the date-range preset vocabulary; any other string is refused with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16041) + + + + **BREAKING** — an accept-set narrowing on a published analytics contract. + `AnalyticsQuerySchema.timeDimensions[].dateRange` (and with it the + `POST /analytics/query` / `/analytics/sql` bodies, `AnalyticsQueryRequestSchema`, + and the `AnalyticsQuery` type every driver and `AnalyticsService.query` caller is + typed against) used to accept ANY string. It now accepts exactly the thirteen + dashboard date-range preset names, derived from `data/date-range-presets.ts` + (`z.enum(DATE_RANGE_PRESETS)` — the vocabulary's single source of truth since + #4614, so the two cannot drift), or the unchanged `[start, end]` array arm. + Shipped as `minor` under the repo's launch-window convention for breaking + changes; the hand-migration prescription is registered under protocol major 18. + Maintainer ruling on #16041 (2026-09-06, decision batch #57, option A — + contract first, 「同意」): 「本项目以协议为基准。所以开发应该对其协议,协议有问题应该立卡修改协议」. + + ## What was wrong + + The arm was a bare `z.string()` whose only documented example — `"Last 7 days"`, + in the schema's own comment — was a value no driver could parse. `driver-memory` + recognised exactly `today` and a case-sensitive `last N ` and fell every + other string through to a `[range, range]` pseudo-window that (measured through + mingo, 2026-09-05) matched **every `Date`-typed row**, 2099 included, because a + `Date` compares above a `String` under BSON cross-type ordering. The SQL + strategies read the same bare string as a single ISO day. A dashboard asking for + one week silently got all of history on one backend and one day on the other, + at HTTP 200 on both. + + ## What it does now + + - The string arm is `AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS)` + (`today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, + `this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_7_days`, + `last_30_days`, `last_90_days`); the schema example is corrected to + `'last_7_days'`. + - Any other value raises ONE prescriptive issue at `timeDimensions.N.dateRange` + (`analyticsDateRangeRefusalMessage`: the value, the vocabulary, the array + spelling for an explicit window). `@objectstack/spec/data` exports the + structural predicate `isAnalyticsDateRangeRefusalIssue` for doors. + - `POST /analytics/query` and `/analytics/sql` answer the ADR-0112 envelope + **`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`** — a new `ERROR_CODE_LEDGER` member + registered under `@objectstack/runtime` — and the analytics service is never + reached. A body wrong in more places than the `dateRange` stays the generic + `400 VALIDATION_FAILED` + `details.fields[]`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'Last 30 days'` / `'last 30 days'` | `dateRange: 'last_30_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: 'This week'` | `dateRange: 'this_week'` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + Measured in this repository at the ruling: three authored `'Last 7 days'`, all + in `packages/spec` tests (re-spelled here), and no published dashboard authors + the string arm at all — the shipped console lowers presets to the array arm + before querying. The drivers' own refusal of a non-conforming value that reaches + them in-process (past the schema) is the sibling card #16322, blocked by this + one; the fenced `service-analytics` fixture that authors the retired bare-ISO + spelling is that card's to re-triage. +- 68437d4: The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded. + + `POST /api/v1/automation/:name/runs/:runId/resume` answers a run that consumed its pause and then failed with `400 FLOW_FAILED`, and until now its `error.details` carried the run's two artefacts only (`errorMessage`, `summary`). The engine's own verdict was dropped at the door: `AutomationResult.status: 'stranded'` — a run that is terminally failed *but* repairable by an explicit operator verb, because the pause a durable decision was waiting on is gone with the failure — reached the wire as the same `400` a plain terminal failure does, so an HTTP-only caller could not tell "beyond reach" from "repair waiting". + + - **`@objectstack/spec`** declares `ResumeFailureDetailsSchema` (`@objectstack/spec/api`): `{ runId, status?: 'failed' | 'stranded', repairable }` — the machine-readable shape of a resume failure told to the caller, declared once so every carrier of the family ruling spells the same members. + - **`@objectstack/runtime`**: the resume door's `400 FLOW_FAILED` details now carry that structure beside `errorMessage` / `summary`. `runId` is the run the resume was addressed to; `status` is the engine's own stamp, forwarded verbatim when it set one and never synthesised (the subflow-child-failed exit stamps none today); `repairable` is `status === 'stranded'` and is **always present on this arm** — present-and-false on a plain terminal failure, deliberately, so an absent member reads as an older server rather than as "not repairable". The code stays `FLOW_FAILED` (no `FLOW_STRANDED` sibling is minted), so a client that treats it as terminal keeps working and one that wants to offer a repair branches on `details.repairable`, never on the message text. The trigger door and `/actions` are unchanged: they never resume, so the member is absent there and absent means "not a resume". + - **`@objectstack/client`**: `automation.resume` documents the new members. +- 44c849c: The two operator run-lifecycle verbs get a door: `POST /automation/:name/runs/:runId/cancel` and `POST /automation/:name/runs/:runId/restore-suspension`, both gated on the platform operator. + + `AutomationEngine` has carried `cancelRun` (ADR-0044) and `restoreConsumedSuspension` (#13909) for as long as either has existed, and neither was reachable by an operator: no REST route, no CLI command, and — until `IAutomationService` declared them — no way for a host holding only the service contract to call them either. The gap mattered most for the repair verb, which has no in-process caller by design: it is meant to be asked for by a person, and there was no way to ask. Maintainer ruling, 2026-09-05 (option A): both are platform-operator verbs, and listing and acting go through `sys_automation_run`, never engine memory. + + - **The gate is the ADR-0095 D2/D3 posture rung** (`posture === 'PLATFORM_ADMIN'`), required **unconditionally**, with the usual `isSystem` bypass so `plugin-approvals`' in-process revise-window recall is untouched. It is deliberately not the `positions[]` entry spelling the built-in: `sys_user_position` is `apiEnabled` with unconstrained values, so a tenant can mint that row. It is also deliberately not posture-conditional the way the ADR-0126 §5 activation gate is — that gate falls open under `single` because a `manage_metadata` tier still stands in front of it, and this door has no tier in front of it, so the same conditionality would open an operator verb to any authenticated caller on every single-organization deployment. Which routes is one predicate, read by the gate and by both route arms so they cannot drift. + - **Refusing fail-closed on an absent member is the door's own job.** Both verbs are optional members of the contract, as 13 of its 15 are. A service that does not declare one answers **501 `NOT_IMPLEMENTED`** naming the member — never a 200 carrying a lifecycle verdict for a verb that was never dispatched, and never the `{ handled: false }` fall-through that renders as a 404 with a discovery hint for a route discovery does not list. + - **Refusals are refusals.** The restore door maps the implementation's refusal code onto the statuses this same door already answers those conditions with on `resume` — `RUN_NOT_FOUND` 404, `STORE_UNAVAILABLE` 503, and the run-state conflicts 409. The contract types that code as `refusal?: string`, a covariant widening of the engine's closed eight-member union, so the mapping is a **non-exhaustive string switch by construction**: an unrecognised code — or a refusal carrying none — answers **500**, not one of the 409s, which would claim a diagnosis the door did not make. The vocabulary is neither narrowed nor extended at the call site. The code rides `details.refusal`, never `details.code`, so `error.code` stays inside the ADR-0112 closed catalog. + - **`requestedBy` comes from the authenticated caller, never the wire.** The repair verb's trace records who asked and why; a wire-settable `requestedBy` would let one operator write another's name into the record of who re-armed a terminally-failed run. The body envelope is closed to `{ reason? }` and refuses the key by name, so a caller who tries gets a loud refusal instead of the silent impression that it took. + - **No once-only side effect keys off `cancelRun`'s return, and the wire says why.** The engine has no cancel-side compare-and-set, so two overlapping cancels each answer `true` and each record the terminal log. This door fires no notification, writes no audit entry and announces no kernel event; the `true` answer carries a notice stating the non-exclusivity so a caller does not build that side effect one tier up. The `false` answer carries the other half: the contract's idempotent-success reading **and** the unreadable-store reading, which land on the same `false` and which nothing above the engine can tell apart. + + ⛔ No lister ships here. A lister backed by the engine's in-memory journal answers zero in any process that did not itself strand the run, and a confident zero is the failure this class is about; a correct one is a query over `sys_automation_run` terminal rows and is its own card. ⛔ No CLI command either — the ruling declines one for want of pull. +- 6491463: `/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. + + **A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. + + **The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. + + **Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". + + What to read instead, per case: + + - deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; + - asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. + + Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. + + New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. +- ce8bfc9: Dispatcher domain routes stop claiming their lexical neighbours: `DomainRoute.match` now defaults to `'segment'`, and the project-membership skip list gained the same boundary. + + Ten shipped routes — `/actions`, `/ai`, `/analytics`, `/automation`, `/data`, `/i18n`, `/meta`, `/notifications`, `/packages`, `/ui` — carried the implicit `'prefix'` default, a bare `path.startsWith(prefix)` with no segment boundary. So `/datax`, `/metaxyz`, `/uifoo`, `/aixx` and `/packagesomething` were each claimed by a domain that does not own them, and a package mounting one of those namespaces later would have been shadowed by a domain that never wanted it. `/auth` was the eleventh member of the family and was repaired on its own; this closes the rest at the seam rather than one route at a time, so the eleventh domain someone adds is boundary-correct without having to remember anything. + + - **The default moved, the mode did not go away.** `match: 'segment'` (the prefix exactly, plus everything under `prefix + '/'`) is the default; `match: 'prefix'` still buys the bare `startsWith` claim for a route that asks for it in writing. One shape genuinely needs it and now declares it: a prefix ending in `'?'` (`/keys?`, `/mcp?`, `/mcp/skill?`), which reproduces the legacy branch's query-string form for adapters that pass the query through in the path. There is no `/` after that `'?'`, so a segment match cannot express it — those three routes match exactly what they always did. + - **What each narrowed claim used to answer, measured per domain rather than assumed.** They were not uniform: `/actionsx`, `/aixx`, `/automationx`, `/metaxyz` and `/packagesomething` answered `401`; `/i18nxx` and `/notificationsx` answered `501`; `/analyticsx` and `/uifoo` fell through unhandled. `/data` was the worst and the reason per-domain measurement was owed — its handler reads the sub-path as an OBJECT NAME, so `GET /datax` answered a **success envelope for a fabricated object** and `GET /datax/foo` **threw** `Record foo not found in x`. Each of the ten now answers the dispatcher's `ROUTE_NOT_FOUND` envelope, which is what they should always have answered. No caller depended on any of these: nothing in the repo builds a dispatch path by concatenating a domain prefix without a separator, and no route-ledger row or SDK method addresses a shape of this kind. + - **The membership skip list, which was the same predicate with a worse consequence.** `enforceProjectMembership` skipped the control plane with `skipPaths.some(p => path.startsWith(p))` and `'/auth'` in the list, so `/authentication/foo` was waved **past the membership check** rather than merely routed somewhere wrong. It was latent — nothing claims `/authentication/*`, so such a request 404s first — and it would have gone live the day any domain claimed a path of that shape. The skip list stops at `'/'`, `'?'` or end-of-string now; the `'?'` form is part of the boundary on purpose, so `/auth?redirect=…` keeps the exemption it has today and the control plane is not newly gated. + + Every domain still claims itself and every path under it, `/auth/me/permissions` included; the registry header comment that described the old rough edges as deliberate no longer describes code that has them. +- bca21f7: `POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy. + + Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why. + + `copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.* + + - **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use. + - **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead. + + ⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing. + + Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question. +- 2c753fe: feat(runtime): a flow action's run context now carries `recordLoadDenied` (#15168) + + The previous release declared `AutomationContext.recordLoadDenied?: true` and + said so plainly: **declared, not yet populated on the flow face.** The + script/body face of both action doors emitted the signal, but + `dispatchFlowAction` handed `automation.execute` a context without it, so a + `runAs: 'system'` flow that guarded on the documented key was inert — never + `true`, never wrong, and indistinguishable from a flow whose caller could read + the row. + + **This release populates it, on both doors in one stroke** — REST + `POST /api/v1/actions/...` and the MCP `run_action` bridge: + + ```js + // a runAs:'system' flow, guarding before it acts on the subject row + if (context.recordLoadDenied === true) { /* the invoker cannot read this row */ } + ``` + + - **The exact producer shape, unchanged.** The one shared producer + (`loadActionSubjectRecord` → `actionRecordLoadSignal`) already returns + `{ recordLoadDenied?: true }`, and the flow door now spreads it as a + **sibling of `record`** — never a key on the record, and **absent**, never + `false`, when nothing was refused. So a flow reads it exactly as a handler + does, `recordLoadDenied === true`. + - **Both doors, structurally.** `dispatchFlowAction`'s wiring now takes the + load OUTCOME (`subject`) instead of a bare `record`, and derives both the + record and the signal from it. A caller can no longer forward the row while + dropping the verdict that says the caller could not read it — the omission is + a compile error rather than a guard silently inert one door over, which is + the defect the handler-face signal was filed for. + - **Purely additive.** Nothing is refused that was not refused before, no + existing key changes value, and the `recordId` stamp is deliberately kept: + `record.id` still arrives exactly as it did, which is why the flag — and not + `record.id` — is the authorization predicate. Whether the automation engine + *acts* on the key (a flow-level refusal, a step condition) is a separate + decision and is deliberately not part of this change. + - **`@objectstack/spec` (docs only).** The contract's "not yet populated on the + flow face" sentence is retired; no type changes. +- 3e560da: `GET /api/v1/health` answers 200 whenever the process can serve HTTP, even while a configuration fault is making every other route 503. + + The dispatcher resolves a per-request identity before any route handler runs, and that step reads the tenancy posture for every request — credentialed or not. Since a `tenancy` service that is registered and fails to build is (correctly) re-raised as a 503 rather than absorbed into "there is no posture", an uncredentialed liveness probe was answered 503 for the length of the outage. A liveness probe reads 503 as *restart me*; the service then fails to build again on the new pod. A restart cannot fix a service that cannot build, so the result was a restart loop that hid the very fault the 503 exists to make loud. + + **Liveness is now carved out of the identity step.** `GET /health` runs its handler directly: no identity resolution, no configuration read, no credential read — the payload it answers (`status`, `timestamp`, `version`, `uptime`) was already process-local. Wire it to `livenessProbe`. + + **Readiness is unchanged, deliberately.** `GET /ready` keeps the full identity step and its 503 body, so traffic is withheld until the fault is fixed and existing operator dashboards keep the signal they have. Wire it to `readinessProbe`. Nothing else about the 503 moved: every other route, and an environment-scoped `/environments/:id/health`, answers exactly as before. + + Which routes count as liveness is **derived from the dispatcher's own route table** rather than listed anywhere: a route declares `liveness: true` on its registry entry, and `DomainHandlerRegistry.resolveLiveness()` answers through the same matcher that picks the handler — so the set cannot drift from the routes that exist. `DomainRoute.liveness` and `resolveLiveness()` are additive public surface on `@objectstack/runtime`; a route that does not declare the flag is untouched. +- cf9bda4: The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) + + `i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one. + + The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key. + + What changed: + + - **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with. + - **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged. + - **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared. + + A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix. + + `getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. +- f2f6684: Package lifecycle state is keyed by the PROJECT as well as the environment id, so two projects on one machine stop sharing which packages an operator has disabled. + + + + **BREAKING** for a machine that relied on one environment id meaning one shared disable list. Shipped as `minor` under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. + + `packages/runtime/src/package-state-store.ts` is the only durable record of which packages an operator has disabled, and `AppPlugin.start()` replays it at boot. It was stored at `/package-state/.json`, and both halves of where that lived were machine-global: `resolveObjectStackHome()` takes no arguments (it reads `OS_HOME`, else `~/.objectstack`), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary `env_local` environment, therefore wrote one file. + + Driven with two real project roots, one home and one environment id, that produced two failures with one cause: + + - project B disabling `com.acme.billing` made project A's **boot read** answer `{ com.acme.billing, com.acme.reporting }` — A had never installed, seen or disabled that package, and the disable takes it out of A's running system; + - project B enabling `com.acme.reporting` erased project A's disable of it, so one project's operator action silently undid another project's operator intent. + + The file is now `/package-state/..json`, where the project component is a sanitised basename plus a short digest of the resolved project root — the same naming convention `os serve`'s runtime state file settled on, rather than a second spelling of one idea. The payload is unchanged. + + **An existing `.json` keeps working and is not deleted.** While a project has no per-project file of its own the runtime still reads the old name, and that project's first write lands under the new one. The old file is never written and never removed, so a machine that rolls back to the previous release still finds its operator's disables where that release looks for them. Disables made after the upgrade live under the new name only. + + **Which project the key is taken from:** the runtime's working directory, the base every path in a boot with no served-app anchor already resolves against. Two boundaries follow, stated rather than fixed. `os serve` anchors host resolution at the config file's own directory when that directory carries a `package.json`, so serving a config from elsewhere keys this file by the working directory while the CLI's supervision file keys by the config's directory; and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key two files, each internally consistent. Two boots of the same project from the same directory still share one file, which is the same-project case and unchanged here. +- 92b5d7f: `POST /api/v1/packages` now answers an install-time namespace collision with `error.code: "NAMESPACE_CONFLICT"`. `NAMESPACE_CONFLICT` is registered in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`) gains one member and a caller can branch on the refusal directly. + + **The wire, before and after** — measured through the shipped door (`HttpDispatcher.handlePackages` over a real `SchemaRegistry`), not derived from the call graph: + + - before: `422` with `error.code: "VALIDATION_ERROR"` and `error.declaredCode: "NAMESPACE_CONFLICT"` + - after: `422` with `error.code: "NAMESPACE_CONFLICT"` and **no** `declaredCode` — with the spelling registered there is nothing left to demote + + The status, the message and the throw are unchanged. `NamespaceConflictError` (`@objectstack/objectql`'s `SchemaRegistry.installPackage`, ADR-0048 Phase 1 / ADR-0130 D1) has carried `code` and `status: 422` since the envelope landed; what changed is that the door's #9106 narrowing no longer demotes the spelling. Until now a caller wanting to tell "your namespace is taken, rename it" from every other `422` had to read `declaredCode` — the channel ADR-0112 declares as the open, not-guaranteed one — because `error.code` carried the generic member `422` derives. + + Scope of the widening: one new accept value on `ApiErrorSchema.code`; no export changes, no schema-shape changes, and nothing narrowed. A consumer that treats `error.code` as a closed set it enumerates locally will see a value it does not know, which is what a vocabulary widening means and why this is a `minor`. + + The now-discharged `pending-registration` row ratchets out of `packages/runtime`'s dispatcher-error-vocabulary table in the same change — registration is what makes that row stale, and `pnpm check:dispatcher-error-vocabulary` fails on a registered code still carrying one. The door's answer is pinned in `packages/runtime/src/package-door-namespace-conflict-code.test.ts`, which drives the real route and asserts the body, so the reachability the removed row asserted is now held by a test rather than by a claim. +- 8a12067: feat(runtime): the platform action route executes the declarative row-level `operation: 'update'` action (#14092) + + The spec half (#15077) made `operation: 'update'` + `patch` parse; nothing performed the + write, so an authored update action reached the action route with no handler and collected + the registry's loud not-registered answer. It now performs the write. + + `POST /api/v1/actions///` — and the MCP `run_action` bridge, through + the same shared executor — performs exactly ONE data-plane update of the current record: + + - **As the caller.** The write carries the caller's own `ExecutionContext`, never the + `isSystem`-elevated context a `type: 'script'` BODY runs under. There is no author body here + to trust, so the data plane's own gate is the only gate — the object's permissions, its hooks + and its validations fire exactly as for a user edit, and their refusals reach the caller with + their own `code` and `status`. This consumes the `runAs: 'user'` direction ruled on #14010; no + `runAs` key is added. + - **A caller who cannot read the row is refused before anything is written** (404 + `RECORD_NOT_FOUND`, the platform's one existence-non-disclosing envelope), by consuming the + caller-scope load's verdict rather than re-deriving it from the stamped `record.id` — the + #14143 class: a swallowed load must never become an implicit grant. + - **The write is `{ ...patch, ...collectedParams }`** — static values under the dialog's, so a + param of the same name wins. Nothing else from the action is merged, and the ADR-0104 D2 param + contract still bounds what the wire can add. + - **No current record ⇒ a located refusal**, never a silent no-op: no `recordId` on the route or + in the body, an action addressed at the object-less key, or an empty write bag each answer 400 + naming the action and the fix. + - **`undoable: true`** returns `undo: { type, objectName, recordId, undoData, redoData }` — the + prior values of exactly the fields written, `null` for a field the row did not carry, so the + existing Undo readers can restore. The three remaining `UndoableOperation` keys (`id`, + `timestamp`, `description`) stay the client's. + - `visible` is deliberately unread here: it is a per-record renderer predicate, and the + authorization is the point above. + + `operation` is read BEFORE `type` at every reader, so the HTTP door and the MCP bridge agree: + `isHeadlessInvokableAction` now accepts a declarative update (it has neither `target` nor `body` + by construction), `headlessActionTypeError` hands it no client-side-type prescription, and + `summarizeAction` reports `operation` and `requiresRecord: true`. + + Unchanged: a handler-less `type: 'script'` action WITHOUT `operation` still gets today's + not-registered 404 — the script path is not widened. +- de75e40: The `/keys` mint gate and the install-wide activation-write gate classify a tenancy resolution failure instead of reading it as "no wall" + + Both gates derived the effective tenancy posture through `DomainHandlerDeps.resolveService`, the dispatcher's capability **probe**: every step of its fallback chain absorbs every rejection and answers `undefined`. So a `tenancy` service that was registered and **failed to build** arrived at both gates as the same value a deployment that never registered one produces, and both read that as "there is no wall". Measured on the pre-fix tree against a real kernel whose `tenancy` is registered through a throwing factory: `POST /keys` answered **201** and minted an organization-less key, echoing the raw secret once, where a walled posture refuses one; and an organization administrator's install-wide activation write answered **200** and wrote the row, where ADR-0126 §5 requires the platform operator. + + The identity step already read this fact through the classified lookup, so one deployment held two readings of its own wall question at once — 503 at the identity step, admitted at the door bodies these gates guard. The gates now read the same classification, taken from the registry's own brand and never from message text: a service that was **never registered** stays quiet and behaves exactly as before (an org-less key is still minted, and a single-organization deployment's own admin can still flip an install-wide switch — with no tenancy service, install-level and org-level are one scope under ADR-0093 D4/D5), while a service that is **registered and unable to answer** raises `AuthzStoreUnavailableError` — 503 `SERVICE_UNAVAILABLE` — instead of degrading to "no posture". Nothing is minted and nothing is permitted on a posture that was never read. The activation gate is one body behind **two** routes, so three routes change: `POST /keys`, `POST /actions/_activation/:object/:action` and `POST /automation/:name/toggle`. Every gate reads the posture in the request's own environment scope, as the identity step does, so a `tenancy` registered `ServiceLifecycle.SCOPED` is resolved rather than reported as an outage. + + `resolveService` keeps its probe contract for every other name and every other domain: the classified read is a second, opted-into member — `DomainHandlerDeps.resolveServiceOrLoud` — that a gate calls one site at a time, so no gate outside the three routes above changes behaviour. **Minor** rather than patch: this grows the exported `DomainHandlerDeps` interface with a required member, which is a published-surface addition — the same shape the three `DomainHandlerDeps` growths in 17.0.0 shipped as minor changes. +- b31ebfe: A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names. + + An `ai.exposed` action whose target is a **screen flow** could be started over MCP and never finished. `run_action` seeded the flow's `isInput` variables from the caller's `params` — correctly — and the screen node suspended anyway, because the only inputs to that decision were "does the node declare fields" and the author's `waitForInput` flag. The MCP tool set has no verb to resume a parked run, so `ai.exposed` meant "the agent can invoke this", not "the agent can complete this". The fallback an agent took instead — re-implementing the flow's tail with `create_record` + `update_record` — bypasses whatever business rules the flow encapsulated. + + Two independent halves: + + - **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`). + - **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing. + + **Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen. + + **Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `Id`, or when its value equals what the bag carries under `recordId`, `Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too. + + ⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause. + + ⚠️ This does **not** make every screen flow completable over MCP. A call that omits the inputs still parks, and nothing on that surface can resume it; that half is a resume verb and is not this change. +- 1ecee3e: fix(spec): every `defineStack` refusal carries an ADR-0112 envelope — six new `STACK_*` codes beside `STACK_CROSS_REFERENCE_INVALID` (#15963) + + `defineStack` has seven refusal sites. After #14552 one of them — the cross-reference refusal — carried `code` / `status`; the other six still threw `new Error(message)` with both `undefined`. A consumer that had learned to branch on `error.code` from the cross-reference refusal read `undefined` from its six neighbours, which reads as "not a validation refusal" rather than "a refusal with no code yet" — the silent-tolerance shape ADR-0112's envelope exists to remove. Every site now throws an envelope, `status: 422`, one code per refusal, the findings the site collected on `issues`: + + | Refusal (header text, unchanged) | Raiser | `code` | + |---|---|---| + | `defineStack validation failed` | `ObjectStackDefinitionSchema.safeParse` | `STACK_SCHEMA_INVALID` | + | `defineStack capability validation failed` | `validateKnownCapabilities` | `STACK_CAPABILITY_UNKNOWN` | + | `defineStack cross-reference validation failed` | `validateCrossReferences` | `STACK_CROSS_REFERENCE_INVALID` (#14552, unchanged) | + | `defineStack namespace-prefix validation failed` | `validateNamespacePrefix` | `STACK_NAMESPACE_PREFIX_INVALID` | + | `defineStack single-app validation failed` | `validateSingleApp` | `STACK_SINGLE_APP_VIOLATION` | + | `defineStack hierarchy-scope capability validation failed` | `validateHierarchyScopeCapability` | `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` | + | `defineStack trigger capability validation failed` | `validateTriggerCapability` | `STACK_TRIGGER_CAPABILITY_REQUIRED` | + + Message text is byte-for-byte unchanged at every site — this adds the machine-readable half, it does not reword a sentence; the message pins across the tree still read the prose they always did. One code per site rather than one shared `STACK_VALIDATION_FAILED`: the dispatcher vocabulary's `boot-refusal` class was already at one-row-per-refusal granularity (14 rows), and `STACK_CROSS_REFERENCE_INVALID` is an instance of that granularity, not an exception to it. + + The schema arm was judged separately rather than copied from the five semantic cross-checks, because it is an aggregate of zod issues against the schema the stack declares, not a rule evaluated on a parsed stack. The reading: `@objectstack/spec` has no zod-failure envelope to reuse (`formatZodError` / `safeParsePretty` return prose); the two zod-shaped refusals the ledger already carries are both spelled `*_SCHEMA_INVALID` — `METADATA_SCHEMA_INVALID` (metadata-core's `SchemaValidationError`, the `issues`-carrying precedent; nothing in the tree assigns it a status) and `FLOW_INPUT_SCHEMA_INVALID` (answered 422 by the runtime's flow-dispatch table) — and the zod-shaped refusal `metadata-protocol` actually stamps at 422 is `INVALID_METADATA`; the two other channels a zod failure travels on — `400 VALIDATION_ERROR` (request syntax) and `VALIDATION_FAILED` + `fields[]` (record validation, duck-typed on `name === 'ValidationError'`) — would each file an authored stack as something it is not. So it is its own code at 422, and its `issues` carries the zod issues structurally (path, code, message per entry) rather than the formatted lines the message already renders. `issues` is therefore heterogeneous across the seven: strings for the six semantic refusals, zod issue objects for the schema arm; a reader branches on `code` first. + + Not narrowed: `defineStack` accepts and refuses exactly the inputs it did before, and no export changes — the error classes stay module-local, as `StackCrossReferenceError` did, because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112 contract is the `code` / `status` pair read structurally. All seven are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec` — the six new codes and the `STACK_CROSS_REFERENCE_INVALID` precedent alike — not by this change but by #16449 (PR #16652), which landed after this changeset was written and registered them under the #16404 ruling that every code shipped in `dist` is registered, door or no door. That registration records the same reachability reading this change measured rather than contradicting it: no wire door raises them — `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it (re-measured: every non-test `defineStack` occurrence under `packages/runtime/src` and `packages/rest/src` is a docstring, a comment or the vocabulary table's own prose). + + **Why `minor`, not the `patch` the #14552 precedent took.** Six new `STACK_*` spellings ship in `packages/spec/dist/index.js` and `dist/index.mjs`; nothing reaches `.d.ts`, but once shipped a consumer's `catch (e) { switch (e.code) … }` depends on them and they cannot be renamed without breaking it. That is a purely additive widening of a published package's public surface, and the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) requires at least `minor` for it: the commit type may raise a bump but never lower it below what the act requires, so a `fix(` that widens the surface is `minor`. The `patch` precedent is pre-rule; this level is required by that ruling, not chosen by taste. + + `@objectstack/runtime` carried one classification row per new code in the dispatcher error-code vocabulary (`door: 'none'`, `verdict: 'boot-refusal'` — the measured verdict) when this changeset was written, and carries none now: the same #16449 (PR #16652) landing that registered the seven codes ratcheted all seven rows back out of `packages/runtime/src/dispatcher-error-vocabulary.ts`, because a registered code drops out of that gate's scan and the reachability each row recorded now lives on its ledger row. `pnpm check:dispatcher-error-vocabulary` still reconciles sites and rows in both directions, and under the same ruling it now refuses any verdict but `foreign-vocabulary` / `runtime-pinned` for a stamp site under `packages/spec/src/**` — so these seven cannot take a `boot-refusal` row again. + +### Patch Changes + +- 429ec1e: An action whose caller-scope record load was DENIED is now refused at every action door, not at one of the three. + + `loadActionSubjectRecord` computes one verdict — `recordLoadDenied` — for every door, and exactly one door consumed it as a refusal: the declarative update. The flow door and the script/body door spread the same verdict into the context as a field and proceeded. So MCP `run_action` on a `type: 'flow'` action answered `ok: true` and started a persisted run for a `recordId` the caller cannot read — and, identically, for an id that names nothing at all — while `get_record` answered "not found" and `update_record` answered "no access" for that same id in the same session. Nothing in the response told the calling agent the row had not been delivered. + + Both remaining doors now consume the verdict, on both surfaces (the REST `/actions` route and the MCP `run_action` bridge), through one shared refusal: + + - **What is refused.** A row-scoped invocation whose caller-scope load was attempted and did not deliver the row. The refusal lands before the automation run is created and before a trusted, RLS/FLS-bypassing action body is entered — not after, which would answer an error with the run already persisted. + - **The envelope is the shared not-found one** — `RECORD_NOT_FOUND`, 404, the same `recordNotFoundError` the read path and the declarative door already answer. Not a 403 and not a new "denied" code: the read path collapses "filtered out by row-level security" and "this id names nothing" on purpose, so answering the two differently would make this door disclose existence where every other door declines to. + - **Record-less and new-record actions are unchanged.** The verdict can only be `true` when a load was actually attempted — a `recordId` was supplied and the action key is not object-less — so an object-less ("global") action and an invocation with no `recordId` never reach the refusal, and both still receive the `recordId` stamp on `ctx.record` exactly as before. The predicate is the load's own verdict, deliberately not the `locations`-derived `requiresRecord` of an action listing, which an author may omit entirely. + + `AutomationContext.recordLoadDenied` and the handler-side `ctx.recordLoadDenied` are untouched and still populated by the same producer; an author guard written against either keeps working. What changed is that the platform no longer depends on that guard being written. +- e9fcd6b: feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478) + + + + **BREAKING** — twelve published `api/` duration keys are renamed and tombstoned. + Shipped as `minor` under the repo's launch-window convention for breaking + changes; the hand-migration prescriptions are registered under protocol major + 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions; this card clears the `api/` directory against it. Measured with the + gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree + count falls **48 → 36**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | + | `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds | + | `DeviceRequestResponse.interval` | `intervalSeconds` | seconds | + | `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds | + | `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds | + | `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | + | `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds | + | `RouteDefinition.timeout` | `timeoutMs` | milliseconds | + | `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds | + | `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds | + | `WebSocketConfig.timeout` | `timeoutMs` | milliseconds | + | `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds | + + **Every value is unchanged** — only key names move. Every old spelling is a + `retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type + `never`) and fails the parse with the rename prescription rather than a bare + unrecognized-key error. + + ## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch + + Ruling B put this key explicitly in scope with its own BREAKING note: the + runtime-emitted measurements are read by humans and agents even though nobody + authors them. A consumer meets two retry-after values on one 429 — this + ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After` + header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date. + Spelled identically they read as one value in two places. + + **The HTTP `Retry-After` response header is a separate, unchanged surface.** Its + name is fixed outside this repo and nothing here touches it. Do not "fix" the + header to match the envelope, and do not read a surviving `retry-after` in + transport code as leftover work. + + ## Dispositions — one D2 conversion, five semantic entries + + Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only + one of the twelve that gets an ADR-0087 D2 conversion** + (`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack + collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered + metadata kind stored as a row, so the conversion chain has a seam that sees it. + `os migrate meta --from 17` lists the mechanical edits. + + The other eleven are wire payloads and construction arguments — a device-flow + response body, an error envelope, REST-plugin route registration, a batch-loader + config, a router registration, WebSocket client/server configuration. None is + ever a stack collection member or a `sys_metadata` row, so no conversion seam + runs on them and each carries a **semantic** entry instead: this is the + disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these + very shapes, and what ruling B prescribes for a runtime-emitted key. + + ## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror + + Attributed to RFC 8628 by the campaign card; the attribution fails against the + schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as + a set — `code` is not `device_code`, `verificationUrl` is not + `verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a + different type, an ISO-8601 instant where the RFC carries a relative lifetime). + A schema that already renames every RFC field it carries into house style cannot + claim the standard fixes the one name it left bare. Renamed rather than marked + deliberately: a wrongly marked key is exempted permanently and silently, while a + wrongly renamed one is visible. + + ## Readers moved in the same PR, at the same magnitude + + `@objectstack/runtime`'s policy chain (`computeCacheControl` now reads + `endpoint.cacheTtlSeconds`), the publish gate's issue path + (`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase + example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead` + tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is + retargeted onto the live key — an alias must point at a key the schema really + accepts, and `cacheTtl` now accepts nothing. +- 98191d2: fix(runtime): a flat-manifest bundle no longer collects every seed dataset twice + + `AppPlugin.start()` collects seed data from two locations — the top-level + `data` field, then the legacy `manifest.data` for backward compatibility. The + legacy read resolves its base as `this.bundle.manifest || this.bundle`, so on a + FLAT bundle — manifest fields written directly on the bundle rather than nested + under `manifest:`, a shape `AppPlugin` supports by design and this repo's own + tests construct — it re-read the very array the top-level read had just + contributed. Every dataset landed in the collection twice. + + `mergeSeedDatasets` is a plain `push` with no de-duplication, so both copies + reached the shared `seed-datasets` registry, the inline boot seed, and every + later per-org replay. For an `upsert` dataset with an `externalId` the second + pass is idempotent and the cost is doubled work; for a `mode: 'insert'` dataset + it is the dataset APPLIED TWICE per boot — measured here as two `insert` calls + for one record. + + The legacy read now carries the same reference guard its sibling collector has + always carried: `loadTranslations()` performs the identical two-location read + and skips the legacy half when `manifest.translations` IS the array the top + level already contributed. That asymmetry between the two collectors was the + whole defect, so the repair is the sibling's guard rather than a third spelling + of the same idea. + + ⛔ Not a removal of the legacy read: a bundle whose `manifest.data` is a + genuinely different array from its top-level `data` still contributes both, and + a bundle that nests its manifest is unaffected either way. Nothing is added to + or removed from any published surface. +- f1a1028: fix(runtime): a multi-package artifact's collections are read from `packages[]`, not only from the flattened top level + + A release artifact composed with `manifest: 'preserve'` carries every + definition twice — flattened at its top level, and again under + `packages[]` (ADR-0130 D4). Only two readers had ever learned the second + half: `ObjectQLPlugin`'s manifest service and the metadata artifact door. + Every other reader said `artifact.` and nothing else, so an + artifact that carried a collection under `packages[]` alone reached them + EMPTY — and nothing threw. The app booted clean having lost its + declarative actions, its scheduled jobs, its seed data, its object routing + or its default permission set. + + `resolveArtifactCollections` — new, and PACKAGE-PRIVATE to + `@objectstack/runtime` — is now the one way this package reads a top-level + collection out of an artifact in either shape. It takes the artifact's own + top-level value first and whole, then adds from each package body — in + `resolveArtifactPackageOrder`'s dependency order — the items the top level + did not already claim. A bundle that carries no `packages[]` is returned + unchanged, by identity: every single-package artifact and every + `defineStack()` config reads exactly as before. Nothing is added to any + package's published surface: `@objectstack/core` is untouched by this + change, and the new module is not named by + `packages/runtime/src/index.ts`. + + Where one collection key is spelled two ways inside one artifact — + `functions` is `z.union([z.record(…), z.array(…)])`, so two packages can + each be schema-valid and disagree — the read is REFUSED with an ADR-0112 + envelope (`MIXED_ARTIFACT_COLLECTION_SHAPE`, 422) rather than one spelling + being skipped. `composeStacks` already refuses the same mix at compose + time for the same reason. + + Taught to use it, in `@objectstack/runtime`: + + - `AppPlugin` — declared datasources and their auto-connect, the + `datasourceMapping` object routing, the objects handed to the connection + service and to the hot-reload seeder, scheduled jobs, seed datasets, + translation bundles, and the ADR-0057 security collections + (`positions` / `permissions` / `capabilities` / `sharingRules`). A job + handler's `ctx.bundle` is now the resolved view too, so + `ctx.bundle.objects` answers on a multi-package artifact. + - `collectBundleActions`, `collectBundleHooks` and + `collectBundleFunctionEntries` — including the object-EMBEDDED actions + that ride on `objects[]` and disappeared with it. + - `mergeRuntimeModule` — the declaration half. The sibling ESM module + re-supplies every callable regardless of shape, so `functions` was not + absent: a function declared `effect: 'writes'` simply came back as a bare + callable and defaulted to `'pure'`. It registered, it ran, and its writes + were counted as none. + - `createStandaloneStack`'s surfaced `requires` / `objects` / + `permissions` / `positions`, which drive the CLI's tier resolution, its + engine and storage-driver auto-registration, and the ADR-0056 D7 default + permission set. + - `resolve-project-database`'s project-database tier, which opens the + artifact itself and runs before any stack exists (`os dev`, `os start`, + `os db clean`). Without this a multi-package project silently fell + through to the unified default database instead of the datasource it + declared. + + Nothing about what the platform EMITS changes: `composeStacks` and the + artifact format are untouched, and the flattened top level is still + written. This is the reader half of the option-B program (#14512). +- c1eafe6: The `/auth` dispatcher domain no longer claims sibling namespaces such as `/authx` and `/authentication/foo`. + + `createAuthDomain` registered `{ prefix: '/auth' }` without a `match`, and `DomainRoute.match` defaults to `'prefix'` — a bare `path.startsWith('/auth')` with no segment boundary. Every path whose first segment merely *began* with the five characters `auth` was therefore claimed by the auth domain and forwarded to the auth service, instead of falling through to the dispatcher's `ROUTE_NOT_FOUND`. Measured on a real boot (a real kernel with `AuthPlugin`, served through `createHonoApp({ kernel, prefix: '/api/v1' })`), `GET /api/v1/authx`, `/api/v1/authx/foo` and `/api/v1/authentication/foo` were all claimed; `/api/v1/aut/foo` and `/api/v1/zzz/foo` were not, which is what located the boundary at the `auth` prefix. + + The route now declares `match: 'segment'` — the spelling the registry's other boundary-correct domains (`/keys`, `/mcp`, `/mcp/skill`) already use. It claims `/auth` exactly and everything under `/auth/`, and nothing else. + + **What does not change.** `/auth/me/permissions` and `/auth/me/localization` still reach `dispatch()`. Neither is a better-auth endpoint, so the adapter's `/auth/*` mount disclaims them and they arrive at this domain; `'segment'` keeps claiming them, which the accompanying test pins as an overshoot control alongside the three narrowed rows. + + **If you mounted a namespace under `/authx`, `/authentication`, or any other first segment starting with `auth`,** it was previously shadowed by the auth domain and answered by the auth service. It is now reachable — register a domain handler for it, or expect `ROUTE_NOT_FOUND`. +- 68f8f77: A scoped service that has no instance for your environment no longer answers as if you had forgotten to name one. + + `HttpDispatcher`'s classified service lookup — the read behind the identity step, the `POST /keys` mint gate, the install-wide activation write and `POST /automation/:name/toggle` — took the scope it was handed, missed on it, and then re-resolved on the request's own kernel **without** that scope. A service registered `ServiceLifecycle.SCOPED` and resolved without a scope id is rejected by the plugin loader with `Scope ID required for scoped service ''`, and that rejection is not the branded "never registered" the lookup absorbs — so it was re-raised, and each of those four doors answered `503 SERVICE_UNAVAILABLE` on a deployment where nothing was unwell. A caller that passed its environment correctly was told it had passed nothing. + + Concretely: a `tenancy` factory that serves one environment and legitimately returns `undefined` for another made every one of those four doors fail for the second environment — no API key could be minted, no activation switch flipped, and the identity step itself raised the outage. + + - **The scope now travels with every leg of the chain**, which is what the leg before it and the fallback tail already did. Nothing else about the resolution order changes: which registry answers is unchanged, only whether it is asked the question the caller actually asked. + - **The lookup tells its three answers apart.** "Nothing was ever registered under this name" and "this name is registered and produced no instance in the scope you passed" are two different facts. They still license the same quiet `undefined` at the door — a factory that returns `undefined` for a scope has *answered*, so it is an absent fact rather than an unread one, and ADR-0093 D4/D5 reads a scope with no tenancy service the way it reads a deployment with none — but they are no longer the same answer inside the lookup. + - **The loader's message is untouched, and so is the caller it is about.** A door that really resolves a scoped service without a scope still receives `Scope ID required for scoped service ''` and still answers 503. That direction is pinned explicitly, because an implementation that answered every scoped miss with `undefined` would fix the misattribution by deleting a correct diagnostic. + + No exported type changes: `DomainHandlerDeps.resolveServiceOrLoud` keeps its signature and keeps answering the service or `undefined`. +- da1cffb: An environment-scoped URL now reaches a dispatcher domain instead of answering 404. + + `HttpDispatcher.dispatch()` reads the scoped-URL prefix in three places — the environment-id hint parser, the OAuth-on-MCP gate, and the scope strip that lets `DomainHandlerRegistry` match the remainder. Only the first had been moved to the ADR-0006 `/environments/` spelling; the other two still matched the retired `/projects/` one. The strip therefore never fired on a real scoped URL, and since the registry matches from the head of the path, every environment-scoped request arriving through the `@objectstack/hono` catch-all — the entry cloud hosts mount, and the only one that hands `dispatch()` a still-scoped path — matched no domain at all: + + ``` + GET /api/v1/environments//data/task -> 404 ROUTE_NOT_FOUND (now: reaches /data) + GET /api/v1/environments//health -> 404 ROUTE_NOT_FOUND (now: 200) + GET /api/v1/data/task (control) -> reaches /data, unchanged + ``` + + The dispatcher-plugin's own scoped mounts were never affected: they pass a pre-stripped subpath (`${prefix}/environments/:environmentId/automation` dispatches the literal `/automation`), which is why the standalone server showed nothing. + + The OAuth 2.1 gate moved with it. An access token is honoured only on the MCP surface, and that test runs against the still-scoped path — so `/api/v1/environments//mcp` would have reached the MCP domain with its token refused had the strip been repaired alone. + + **If you still emit the old spelling**: replace `/api/v1/projects/:projectId/...` with `/api/v1/environments/:environmentId/...`, as `content/docs/api/environment-routing.mdx` has instructed since ADR-0006 D2. That prefix is no longer stripped, and it was never a working alias in the first place: nothing parses `/projects/`, so stripping it discarded the only place the request named an environment and served it from the host default instead. ADR-0006 D2 retired `project` on the API surface with no aliases, so the repair is one spelling in all three readings rather than a two-prefix alternation. +- ac6213e: Four server-side authorization sites stop deriving platform-operator authority from a NAME in `ExecutionContext.positions`, and read the ADR-0095 posture rung instead. + + `positions[]` is the security axis, so it carries ADR-0057 D4 `sys_user_position` names alongside the built-ins. `sys_user_position` is `apiEnabled` and its `position` values are unconstrained, so a tenant could mint a row spelling `platform_admin` for one of their own users: `resolveUserAuthzGrants` pushed that name straight onto `grants.positions`, while `grants.posture` — derived from the unscoped `admin_full_access` grant and nothing else — correctly stayed `MEMBER`. Every reader of the name therefore answered `true` for a principal enforcement treats as an ordinary member. `resolve-authz-context.ts` states the rule at `hasPlatformAdminStanding` ("read the RUNG — never `positions.includes(...)`"), but a comment is not a gate and these four had not followed it. + + Each site now tests `posture === 'PLATFORM_ADMIN'`, byte-for-byte what `hasPlatformAdminStanding` returns: + + - **`plugin-sharing`** — `hasPlatformAuthority`. The minted row satisfied `assertResolvableAdminScope`, so an org-less caller holding only the ORG-scoped `manage_sharing` capability was answered with **every tenant's** sharing rules, and could delete platform-global rules. The `manage_platform_settings` capability spelling is unchanged. + - **`plugin-approvals`** — `isOverrideActor`. This predicate already read the rung and then ORed the name onto it, which is no protection: an OR is only as strong as its weakest arm. Because the platform arm deliberately crosses the tenant wall, the minted row let a member of one organization approve, reject or recall a **different organization's** pending request while holding no slot in its slate. The `ADMIN_FULL_ACCESS` capability arm and both TENANT_ADMIN arms are unchanged. + - **`runtime`** — the ADR-0126 §5 activation gate. Under a `group` or `isolated` posture this gate is the only thing between a tenant org admin and the **install-wide** `sys_metadata_activation` row, so the minted row reopened #10243 with a durable row behind it. + - **`plugin-security`** — `derivePosture` in the explain engine. Narrower than the other three, and stated precisely rather than overclaimed: the name-read sat behind an early `ctx.posture` return that `buildContextForUser` always populates, so the shipping path was already gated and a D4 row never moved it. What the read did reach was a posture-less hand-built context, where it made the panel **report** `PLATFORM_ADMIN` for a principal enforcement treats as a MEMBER — a misreport rather than an admission, but in the one tool an administrator opens to check exactly this. + + No behaviour changes for a genuine platform operator: their resolved context carries the rung, and the built-in position is still projected onto `positions[]` for display and predicate use. What changes is that the name alone no longer answers the authorization question. + + Graded `patch` on the surface it moves: no exported type, signature or contract changes, and no authorable metadata is added, removed or renamed. The only observable difference is that a principal who never held the capability grant stops being admitted — which is the defect, not a feature anyone could have depended on. +- 4db3c61: `publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. + + `isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. + + Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. + + Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. +- e9fcd6b: fix(runtime): `AppPlugin` threads the authored `job.timeoutMs` to the scheduler as `timeoutMs` (#14478) + + The declarative job door passes `{ retryPolicy, timeoutMs }` to + `IJobService.schedule`, following the `@objectstack/spec` rename of the + authored key and of the `JobScheduleOptions` contract key that carries it. Same + value, same per-attempt limit. +- 401e50a: The runtime dispatcher door no longer admits a request on a tenancy posture it could not read. + + `resolveExecutionContext` reads the effective tenancy posture from the kernel's `tenancy` service, and both posture-conditional API-key refusals (`organization_required`, `organization_membership_ended`) run only when that posture is present. The read used to swallow every failure into "no posture", so a `tenancy` service that was **registered and failed to build** answered exactly like a deployment with no tenancy at all: the wall was skipped, and an API key stamped with an organization its owner had left — or carrying no organization — was admitted with full grants. + + The seam now carries the same discrimination the REST door already applies (#13906 decision 1, option A), by the registry's own brand rather than by message text: + + - **never registered** — the supported no-tenancy composition. Absorbed as before: no posture, no posture-conditional refusal, nothing changes for single-organization embedders. + - **registered and failed to build** — re-raised as `AuthzStoreUnavailableError`, so the door answers `503 SERVICE_UNAVAILABLE` ("the authorization store could not be read"), which is an existing member of the closed error vocabulary. A posture that could not be read is not a posture that is absent. + + Two nets between the resolver and the transport envelope are told the same thing, in the one shape `@objectstack/core` already prescribes for such seams (`rethrowAuthzStoreUnavailable`): the dispatcher's service facade hands the resolver the classified rejection for `tenancy` instead of collapsing it to `undefined`, and the identity step's catch re-raises only the branded outage while every other fault still degrades to an anonymous request. A consequence worth knowing: an authorization-store read failure (`AuthzStoreUnavailableError` from the permission tables) now also reaches this door as 503 instead of being served as an anonymous request. +- ee32e1c: fix(runtime): a sandboxed hook body no longer launders an untouched `readonly` field onto the row + + A `beforeUpdate`/`beforeInsert` body running in the sandbox made the engine believe it had + written payload keys it never named, and a `readonly` field the caller supplied then survived + the readonly strip and landed. Measured end to end: with `locked_at` declared + `{ type: 'datetime', readonly: true }` and seeded to `2020-01-01`, a caller sending + `locked_at: new Date('2099-12-31…')` alongside a body whose whole source is + `ctx.input.touched_by = 'hook'` stored the caller's 2099 value — while the same object's + readonly `text` field was correctly stripped in the same request. + + The cause was a comparison of unlike things. The write-back decides whether a body wrote + *through* an object-valued key by comparing the host payload value against the VM's exit dump, + and the dump has been through `JSON.stringify`/`JSON.parse` while the host value has not. A + `Date` therefore never compared equal to its own ISO projection, took the documented + "cannot prove equal ⇒ carry it back" path, and was re-asserted onto the proxy that records + which keys a hook wrote. The class was every object-valued value a JSON round-trip cannot + prove equal — an object carrying an `undefined` member included, a `Date` being only its most + reachable member. + + The entry value is now normalised through the same round-trip the VM saw before it is + compared. The same change ends a fidelity loss on non-readonly fields: an untouched key is no + longer carried at all, so a host `Date` is no longer replaced by an ISO string on its way to + the driver. + + Fail-open behaviour is unchanged for values the round-trip genuinely cannot evaluate: a cyclic + or bigint-bearing payload value is still reported as changed and still carried, per key. +- 4b0508e: docs(runtime,metadata-protocol): correct the `writable` verdict's illustration — the scope-less booted row is a marketplace / offline import, never a multi-package artifact's module (#14803) + + Comment and prose only. No predicate, no assertion and no served shape changes; + every pin behind the `writable` verdict stays green as written. + + The `writable` verdict shipped in 17.3.0 with a **false attribution** in its own + explanation, and this corrects it at every site that repeated it. The claim was + that the scope-less booted row `isWritablePackage` answers `false` for is *the + `type: module` sub-package a multi-package artifact carries*. It is not, and it + never was: + + - `defineStack` parses every `packages[]` entry through `ManifestSchema` + (`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), whose `scope` is + `.default('project')` (`spec/src/kernel/manifest.zod.ts`), so **no** package of + a compiled artifact is ever scope-less — `dist/objectstack.json` and both + served rows carry `scope: "project"`. + - A genuinely scope-less row arises only where a manifest reaches the registry + **without** that parse, because `installPackage` stores a key-by-key copy that + applies no defaults: a marketplace install / offline file import + (`manifestService.register(rawBody)` to `ql.registerApp`) for the **booted, + read-only** half, and `POST /api/v1/packages` (`body.manifest || body` to + `installPackage`) for the **database base, writable** half. + + Measured: `ManifestSchema.parse` of the `app-multi-package` orders body turns an + unauthored `scope` into `scope: "project"`, while `SchemaRegistry.installPackage` + of the same unparsed body yields a record with no `scope` key at all. + + What stays, because it is true and load-bearing: a scope-less **booted** package + is read-only while a scope-less **database base** is writable, and only + `engine.manifests` tells them apart — which is why the server owns the verdict. +- 4c0b22b: The package-publish door's route-level seed apply can consume the platform's own read-back envelope again. + + `POST /packages/:id/publish-drafts` reads each just-published `seed` body back through `protocol.getMetaItem` before handing it to the seed loader. That read exits through `decorateMetadataItem`, which stamps `_diagnostics` on every body whose metadata type has a registered schema — `seed` has one — and `SeedSchema` has been closed since protocol 17. So the door refused the document it had just been served: `unrecognized_keys: ["_diagnostics"]`, minted as a 422 and delivered on a **200** as `seedApplied.error`. Zero rows loaded, and the author was told their seed body failed spec validation when nothing about it was wrong. + + The read-back is now passed through `stripReadDecorations` at the unwrap — the same helper, for the same reason, that the dataset query, the cold-boot flow bind and `saveMetaItem`'s verbatim persist already call. `METADATA_READ_DECORATIONS` is the declared list of keys the read path derives from a document and attaches to the *response*, so removing them restores the document the author actually wrote. + + Nothing is widened to accept them: `SeedLoaderRequestSchema` stays closed, and the publish response keeps its declared shape. The strip is deliberately **not** a blanket `startsWith('_')` sweep — the ADR-0010 protection envelope (`_packageId`, `_provenance`, …) is not a read decoration, and the metadata schemas allowlist it precisely so a served document keeps its provenance when it is parsed again. + + Only protocols that do not self-apply seeds inside `publishPackageDrafts` reach this path; the shipping protocol self-applies and was never affected. +- 8744de9: The package-publish seed read-back no longer runs a two-attempt org-then-env ladder whose rungs resolve the same row. + + `applyPublishedSeeds` — the route-level seed apply behind `POST /packages/:id/publish-drafts`, which runs for protocols that do not self-apply seeds inside `publishPackageDrafts` — read each just-published `seed` body twice when the session had an active organization: once naming the organization, then once env-wide. The comment above it said the first attempt tried the active org and the second fell back, "and resolving the wrong scope here is what silently produced `0 rows loaded`". + + That was true when it was written and is not true now. `seed` declares `allowOrgOverride: false`, and `getMetaItem` resolves `organizationIdForMetaRead(request.type, request.organizationId)` once at its top and spends that binding — never the raw argument — on every read beneath it. The predicate answers `undefined` for every non-overridable type, so both rungs asked the engine the same predicates and served the same answer. Measured rather than reasoned: against the shipping protocol over one store, the two requests produce byte-identical engine reads and byte-identical answers on both the hit and the miss branch, and neutering the second rung reddens nothing on a pinned publish-then-read path (a `view` control confirms the same comparison does separate the two rungs for an org-overridable type). + + The read is now a single call naming no organization, and the comment states that the scope is decided by the registry flag and the gate inside `getMetaItem` rather than by this call site — matching the sentence the `app` flip in the same file already carries. + + One observable changes, and only on the failure branch: `getMetaItem` answers a wrapper rather than a falsy value for a name it cannot resolve, so the second rung was in practice reached only when the read *threw* — where it repeated the identical failing read and appended the same sentence to the client-facing `seedApplied.errors[]` twice. A failed read-back is now reported once. Nothing about which row a publish resolves, or whether its rows load, moves. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- f7db8f4: fix(spec): `defineStack`'s cross-reference refusal carries an ADR-0112 envelope, so the five REFUSED ADR-0130 item classes are machine-readable (#14552) + + `validateCrossReferences` — reached through `defineStack` — refuses a stack whose items name an object the stack does not define. That refusal was `new Error(message)` with `code` and `status` both `undefined`, so all five REFUSED item classes of the ADR-0130 matrix (action `objectName`, view `data.object`, permission-set `objects`, seed dataset `object`, import mapping `targetObject`) plus the `hooks[].object` rule (#14122 §4 rule R4) were distinguishable only by MESSAGE TEXT. It now throws `StackCrossReferenceError`, carrying `code: 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, and one entry per finding in `issues`. The message text is byte-for-byte unchanged: this adds fields rather than rewriting a sentence, and five message-substring pins in the tree read that prose. + + ADR-0112 makes `code` / `status` the machine-readable half of every refusal. Without them `os validate`, `os build` and any AI author reading the refusal could only pattern-match prose — the fragile shape the envelope exists to remove, made worse here because the message had already become load-bearing for those pins. + + Why ONE code rather than five: there is exactly one raise site. `validateCrossReferences` returns every finding as a `string[]` and `defineStack` throws the collected set at once, so a single refusal can carry findings from several classes together and a per-class code would have to pick one of several true answers. The classes stay machine-readable in `issues`. The family is also wider than "undefined object" — the same aggregate carries the duplicate-action-key, global-`update`-action and mapping `javascript`-transform findings — so a `…_UNDEFINED_OBJECT` spelling would have been false for those. + + Not narrowed, not widened: no accept-set changes and no export changes. `defineStack` accepts and refuses exactly the inputs it did before, and `StackCrossReferenceError` is deliberately module-local — `packages/spec/src/index.ts` re-exports that module with `export *`, so exporting the class would widen the published api-surface of the contract package, and the ADR-0112 contract is the `code` / `status` fields, which every reader reads structurally rather than by `instanceof`. No ledger registration either, for the same reason its two precedents (`ObjectOwnershipConflictError` #14367, `NamespaceConflictError` #14474) carry none: no wire door raises it. `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it. + + `@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `boot-refusal`, door `none` — the measured verdict, not the expected one). +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [65846bc] +- Updated dependencies [2e6a2ea] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [fb447b4] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [10d05bb] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [001a83b] +- Updated dependencies [6d4d5d3] +- Updated dependencies [45cfa1b] +- Updated dependencies [7862fb7] +- Updated dependencies [1ca95df] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [7862fb7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ed5d557] +- Updated dependencies [7862fb7] +- Updated dependencies [a646120] +- Updated dependencies [5071310] +- Updated dependencies [2200f8e] +- Updated dependencies [0145680] +- Updated dependencies [bc0ac1d] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [2025b1f] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [8f2ecb3] +- Updated dependencies [0c5d035] +- Updated dependencies [281bf0d] +- Updated dependencies [61b4eb3] +- Updated dependencies [cfb64a6] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [3e7ef9c] +- Updated dependencies [3e7ef9c] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [e1d4f9e] +- Updated dependencies [41cbc54] +- Updated dependencies [f502898] +- Updated dependencies [c383352] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [9f39897] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [142c01c] +- Updated dependencies [4ca358d] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [c1d274d] +- Updated dependencies [e9fcd6b] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [8647c87] +- Updated dependencies [6f23f0e] +- Updated dependencies [b4b37e5] +- Updated dependencies [ba426b0] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [61821e5] +- Updated dependencies [26144c2] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [ac6213e] +- Updated dependencies [f4e6adf] +- Updated dependencies [d5c4022] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [efc5447] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [1e43386] +- Updated dependencies [7370989] +- Updated dependencies [289bb43] +- Updated dependencies [094b8fd] +- Updated dependencies [46803fa] +- Updated dependencies [c7aca0d] +- Updated dependencies [1d73d45] +- Updated dependencies [618f70d] +- Updated dependencies [33e939f] +- Updated dependencies [4b0508e] +- Updated dependencies [460d4b8] +- Updated dependencies [c1d8f98] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [b72226f] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [615fac3] +- Updated dependencies [ec0a6e7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [ce478db] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [7d711c9] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] +- Updated dependencies [78bc4ad] + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/metadata-protocol@17.4.0 + - @objectstack/metadata@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/driver-turso@17.4.0 + - @objectstack/service-datasource@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/driver-memory@17.4.0 + - @objectstack/driver-sqlite-wasm@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/service-i18n@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/service-cluster@17.4.0 + - @objectstack/metadata-core@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index ca06288792..298d40646e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.3.0", + "version": "17.4.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 f9f2b0755c..8b049eb7eb 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/sdui-parser +## 17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 734ec9c716..06edc5cf09 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.3.0", + "version": "17.4.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 6f4d7ca671..b1f048755a 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,293 @@ # Changelog — @objectstack/service-analytics +## 17.4.0 + +### Minor Changes + +- 6136293: A `min`/`max` over a string-valued field is described as `string`, not `number` (#16098) + + The sibling population of the temporal fix. `min` and `max` return a value **of the aggregated field's own type**, so a `min` over a `text` / `select` / `lookup` / `autonumber` column carries a string — and `POST /api/v1/analytics/dataset/query` described every one of those columns as `type: "number"`, exactly as it did for the temporal family before the temporal half landed. + + What changed: + + - **`measureResultType` now answers `string` for the string-valued field types too**, in the same one table it already answered `time` from. No second mechanism and no new call site: the rule still answers `undefined` for "no correction", and `queryDataset`'s ADR-0021 result-column enrichment still applies it once, downstream of all four producers of the shape. + - **The corrected spelling is `string`**, the `DimensionType` word a `lookup` or `string` DIMENSION column in the same response already carries (`dataset-compiler.dimensionType`). A textual measure spelled `text` would have been a sixth word in a five-word wire vocabulary, leaving every existing consumer branch unreached — the same argument that chose `time` over `datetime`. + - **Membership is composed from `@objectstack/spec`'s own value classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, `REFERENCE_VALUE_TYPES`) rather than re-listed, so what the platform says a field type STORES and what this rule says a `min` over it RETURNS cannot drift. + + Corrected: `text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`, `select`, `radio`, `lookup`, `master_detail`, `tree`, `user`, `autonumber` — twenty-one members, each verdict read off the two shipped statements of what the type stores (the spec value contract and `driver-sql`'s DDL column switch). + + Deliberately NOT corrected, with the measurement recorded rather than a guess shipped as a declaration: + + - **`boolean` / `toggle`** — Postgres has no `min(boolean)` at all, SQLite answers `0`/`1` as numbers, and the driver seam has been recorded answering `false`/`true`. Three readings that disagree about whether a value exists and what kind it is. `DimensionType` does carry a `boolean` word, so the correction is spellable; it is not made. + - **The JSON-column classes** (`multiselect` / `checkboxes` / `tags`, `composite` / `repeater` / `record` / `location` / `address` / `vector`, `json`) — no `min` over `jsonb` on Postgres, serialized TEXT on SQLite. + - **The file types** (`image` / `file` / `avatar` / `video` / `audio`) — their stored form is mid-migration under ADR-0104 D3: the value contract already says an opaque `sys_file` id while the DDL still gives them a JSON column. + - **`formula`** — its result type IS declared, on `FieldSchema.returnType`, but that key is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and is itself optional. + - **`summary`** — measured NUMERIC on both shipped statements (the spec's `NUMERIC_VALUE_TYPES`, and `driver-sql`'s `table.float` column), so the `number` it already carried is correct rather than merely unexamined. + + Every member of `FieldType` now carries an explicit verdict, pinned by a test that walks the enum: a field type added to the spec fails that pin instead of silently inheriting the flat `number`. +- 07f40e5: A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768) + + `POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource): + + ```json + {"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}], + "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]} + ``` + + `min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch. + + What changed: + + - **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies. + - **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached. + - **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path. + - **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it. + + `AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string. +- 6573af9: A draft-preview `min`/`max` answers the operand's own type instead of `0`, and a preview dimension column is described by its own type + + `POST /api/v1/analytics/dataset/query` has two producers of one response: the engine, and — when the request renders the as-if-published world over a pending seed draft (ADR-0037 P3) — `evaluateAnalyticsQueryOverRows`. The second one coerced every aggregate operand with `Number()` and dropped the non-finite ones, so a `min` / `max` over a non-numeric field answered `0`. Measured on one dataset and one row set, with two services differing only in whether a pending seed draft exists: + + ``` + live {"category":"travel","latest_spend":"2026-05-12"} + preview {"category":"travel","latest_spend":0} + ``` + + That is not a mislabelled column: it is a different, wrong answer to the same query, with no refusal and no warning, on the path an author is looking at *while* authoring the dataset. + + What changed, per member of the closed `AggregationFunction` vocabulary: + + - **`min` / `max` return the winning operand in its own type.** Ordering goes through this file's shared `compare` — so an ISO date orders as a date, a BSON `Date` orders as its instant against wire text, and text orders the way `MIN(text_col)` does on a SQL face — with a numeric arm so a numeric column written as text (`'800'`) still orders numerically. `cross-object-rebucket.ts` settled the identical question for the recombination path: the value these two pick is a value OF the column, so it must come back in the shape the row carried. + - **A group whose operand is null throughout answers `null`, not `0`** — `emptyGroupValueFor` (`@objectstack/spec/data`) rules `min` / `max` over nothing unanswerable, and `0` reads as a measurement nobody made. + - **`count_distinct` answers a cardinality again.** Its arm was spelled `countDistinct`, a word no producer mints (`dataset-compiler` copies the spec's `count_distinct` through), so it was unreachable and the measure fell to the numeric default — answering a row count under the author's `count_distinct` name (measured: `3` where the live path says `2`). + - **`count` stays a row count and `sum` / `avg` stay arithmetic.** Counting dates is still counting. + - **`sum` / `avg` over a TEMPORAL operand is deliberately unchanged.** There is no defined answer — the SQL faces do not agree on one either — and refusing an incoherent aggregate/field-type pair is an open decision, not this fix's to invent. + - **A dimension column is typed from the cube dimension**, the same expression both live producers use (`d?.type || 'string'`), so a `date` dataset dimension is `time` on the preview path as it already was on the live one. A MEASURE column keeps the `number` every producer mints; correcting that is the ADR-0021 descriptor pass's one rule, not a second copy here. + + Derived measures are untouched: `computeDerived` still coerces with `Number()` and answers `null` for a non-finite operand — but a derived ratio over a temporal `min` / `max` now sees a date instead of the spurious `0`, so it answers `null` on the preview path exactly as it already did on the live one. +- 54bb2f1: The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684) + + `$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`. + + On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518. + + What changed: + + - **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess. + - **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only. + - **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL. + + `$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted. +- a646120: The three SQL compilers in this package — the RLS read-scope lowering (`compileScopedFilterToSql`), `NativeSQLStrategy`'s own `where` and the `ObjectQLStrategy` SQL echo — compile a text operator over a column whose declared type stores no text to the contract's declared answer. + + `compileScopedFilterToSql(filter, alias, options?)` takes a new optional `nonTextColumn(field)` predicate; when it answers `true`, a positive text operator compiles to `1 = 0` and `$notContains` to `1 = 1` instead of a `LIKE` that coerces on SQLite (`5` renders `'5.0'`) and is refused at query time on Postgres (SQLSTATE 42883 — a 500 on a read scope the platform accepted). The service answers the predicate from the field metadata hook it already holds (`sourceFieldMeta`), exposed to strategies as `DatasetScopedStrategyContext.declaredFieldType`, and the two strategies pass it for the read scope and for the query's own text filters, so a query and its RLS scope answer one cell one way and the echo prints the statement that ran (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05). A host that wires no field metadata keeps the `LIKE` it always got, and every comparand refusal still runs ahead of the constant. + +### Patch Changes + +- dcad825: Analytics `$icontains` no longer compiles a `translate()` call on the `sqlite` and `mysql` dialects. On **SQLite** that function does not exist and the statement failed to parse — measured on the engine, not inferred. On **MySQL** the same construct was emitted and its arm is repaired the same way, but nothing was ever executed there: the MySQL arm is asserted as emitted TEXT only, on this face and on `driver-sql`'s alike, so no MySQL parse failure is claimed as measured. + + `$icontains` folds ASCII case on both sides of the comparison (#4706 Q1 = A). All three of this package's SQL compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo of that statement — spelled that fold as `translate(col, 'ABC…', 'abc…')` on all four dialect values a compiler can see: `sqlite`, `mysql`, `postgres` and `unknown`, onto which `normalizeSqlDialect` maps everything else, an unset hook and `'oracle'` included. `translate()` is PostgreSQL/Oracle; SQLite has none. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate` — so this was not a filter that returned the wrong rows, it was a statement the engine refused. On a SQLite datasource, an analytics `where` carrying `$icontains` and an **RLS read scope** carrying it were both unusable. + + The fold is now chosen per dialect, on the same construct table the case-exact text family already used, reached through one `fold` flag: + + - **SQLite** — `lower(col) GLOB lower(?)`. SQLite's `lower()` is ASCII-only (measured: `lower('CAFÉ')` is `cafÉ`), so this is the ruled fold rather than an approximation of it, and it runs. + - **PostgreSQL** and the `unknown` residue — `translate()`, byte-for-byte what those two arms emitted before. Measured set for that word: this package's own suite pins six cells verbatim — `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {dialect unset, 'postgres'}` for `{name: {$icontains: 'acme'}}`, full emitted SQL and the exact bound params — and the round-1 contract review widened it to **2,721 cells** (2,720 = `{undefined, 'postgres', 'unknown', 'oracle'} × 5 compiler paths × 8 filter shapes × 17 comparands`, plus the bare `{dialect: undefined}` cell), emitted at the merge-base blobs (all five hash-verified) and again at this head: **0 changed cells, 0 error cells**. Outside that set nothing is claimed — no PostgreSQL server was contacted, and on `sqlite` and `mysql` the bytes deliberately changed (340 of 680 cells each, all inside the four `$icontains` shapes). + - **MySQL** — the nested-`REPLACE` fold over `CAST(… AS BINARY)`, matching what `driver-sql` emits for the same operator; the review measured the two faces byte-equal on 60 of 60 MySQL cells. Asserted as text only — no MySQL server is provisionable in the container that wrote this, so that cell is a declared skip, not a claimed pass. + + ⚠️ Carve-out, stated because it is the surviving half of the defect and not an aside: an `unknown` dialect that is really SQLite is **not** fixed by this change. The residue is reached by four constructions the round-1 contract review drove rather than reasoned — a `SqlDriver` given a **class** client or an unrecognised spelling (`'libsql'`), a host hook answering knex's own `'sqlite3'`, a directly-constructed public `AnalyticsService` with the optional `sqlDialect` omitted, and a `data` service without `getDriverForObject`. For each of them `translate()` still reaches the engine and still fails to parse, on the `where` path, the read scope and the echo alike. No in-repo SQLite driver lands there — `SqliteWasmDriver` and `TursoDriver` both answer `"sqlite"`, measured — so this is an embedder-composition population, not a shipped-driver one. Tracked as #16028. + + `$icontains` and the case-sensitive `$contains` family remain two separate constructs on every dialect the compilers accept — collapsing them would give `$contains` back the case fold #4706 Q2 = A took away from it. Measured set for that word: 510 cells (six dialect names — the four values above plus `'oracle'` and an unset hook, which both normalize to `unknown` — × 5 compiler paths × 17 comparands), 0 of them identical between the two families and no `$contains` cell carrying a fold. + + ⚠️ One deliberate divergence from `driver-sql`, recorded here rather than only in this package's source: `driver-sql`'s own `unknown` arm folds with `LOWER()`, this one keeps `translate()`. Each face keeps the residue it already had, and adopting `LOWER()` here would silently restore on PostgreSQL the Unicode fold #4706 Q1 = A rules out. The pointer exists on this side only; `driver-sql` carries no cross-reference back. +- fd014b1: Analytics `$icontains` no longer compiles a `translate()` call on the `unknown` dialect arm, so a datasource whose dialect nothing answered — which includes SQLite — gets a statement its engine can parse. **Graded `patch`:** no exported type, signature or option changes; the package's own contract for the operator (#4706 Q1 = A, an ASCII-only fold on both sides) is unchanged, and this repairs an arm that could not run rather than adding or retiring behaviour. What moves is emitted SQL text on one arm, measured and enumerated below. + + `normalizeSqlDialect` maps **everything it cannot name** onto `unknown`: an unset `sqlDialect` hook, `'oracle'`, `'libsql'`, a `SqlDriver` handed a knex Client **class** rather than a spelling. #15780 left that arm folding with `translate()` and recorded it as "never broken", which was true of the dialects the arm was *pictured* as — mssql and oracle, which have `translate()` — and false of the ones actually routed there. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate`, so on all three of this package's compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo — the statement failed to **parse**. It reached the client as a 500, not an ADR-0112 refusal. One of the four constructions that land there is a directly-constructed public `AnalyticsService` with its **optional** `sqlDialect` omitted: leaving out an optional field turned a documented operator into a 500. + + The `unknown` arm now folds with one nested `REPLACE` per ASCII letter — the chain the MySQL arm already used, minus its `CAST(… AS BINARY)`, so there is one builder and the two arms cannot fold different alphabets. `REPLACE` is the one string function every SQL dialect has, and the domain is the same 26-letter constant, so the fold is ASCII-only **by construction**: + + - **PostgreSQL / Oracle-like** — same result set as `translate()`. The chain equals the simultaneous `A`-`Z` map because no step can feed a later one: every replacement writes a lower-case letter and every later step matches an upper-case one. Measured on the engine over **every ASCII code point** plus accented, Greek, Cyrillic and dotted-I probes, required equal to the ASCII-only map exactly. + - **SQLite-like** — it runs. Executed over the shared `FILTER_TEXT_CASES` `$icontains` rows through all three compilers on sql.js: the same row sets the `sqlite` arm is required to answer, including the `CAFÉ`/`café` pair that separates an ASCII fold from a Unicode one. + - ⛔ **Not `LOWER()`**, which is what `driver-sql`'s own `unknown` arm folds with. `LOWER()` follows the collation, so adopting it would trade this parse failure for **silently wrong rows** on PostgreSQL — the Unicode fold #4706 Q1 = A rules out. ⚠️ Measuring `LOWER()` in this container proves nothing about that: SQLite's `lower()` is ASCII-only and passes the same fixture, which is exactly the trap of letting a green SQLite reading stand in for a PostgreSQL one. No PostgreSQL server was contacted. + + **Which cells moved.** The emitted SQL and bound params of `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {undefined, 'unknown', 'oracle', 'libsql', 'postgres', 'sqlite', 'mysql'} × 5 text operators × 17 comparands` = **1,785 cells**, generated at this head and again with the emitter reverted to its merge-base blob (both legs hash-verified on disk and rebuilt, the marker's presence and absence checked in `dist/`): **204 moved, 1,581 byte-identical, 0 error cells either side.** Every moved cell is `$icontains` on one of the four dialect inputs that normalize to `unknown` (51 each = 17 comparands × 3 compilers). **0 of the 204 changed their bound params** — only the fold's spelling moved, never the escaping or the `ESCAPE` binding. Nothing moved on `postgres`, `sqlite` or `mysql`, and no case-exact operator moved on any dialect input. + + ⚠️ **The cost, stated rather than left to be found:** the predicate grows from 168 to 1,014 characters on the read scope (233 → 1,079 on the other two). Both constructs are non-sargable scalar expressions over the column, so the plan class is unchanged — what grows is statement text and per-row work, on the arm where the alternative was a statement that did not run. + + ⚠️ **The residue that remains**, because this arm is a residue and not a dialect: the fold is exact everywhere, but the comparison is `LIKE`, which on a case- or accent-insensitive collation (MySQL/MariaDB arriving here through the `'mariadb'` spelling #11756 deliberately leaves unrecognised; SQL Server) over-matches beyond ASCII. That is the **same** residue this arm's case-exact neighbour already carries and names — not a new one — and on those engines `translate()` did not run at all, so nothing that answered correctly before stops answering. + + `SqliteWasmDriver.dialectName` gains a direct pin. It answers `"sqlite"` only through an `isSqlite` override (the base class string-matches `config.client`, and this transport passes a class), that override had **0 direct test hits**, and it is the sole reason no in-repo SQLite driver reaches the arm above. The new pin includes the control: the base class answers `'unknown'` for that very config. +- d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. + + Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. + + No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. + + Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. +- 81919a7: `CubeRegistry`'s documentation now describes what the class actually does. Four claims it shipped were measured false against the built package; no behaviour changes, and the corrected text ships in `dist/index.d.ts`, where consumers read it. + + The class docblock said cubes reach the registry "from two sources: manifest definitions, and object schema inference". Neither half held. Two sources were missing — a compiled dataset's Cube (ADR-0021), registered under the dataset's name by `queryDataset`, and the ad-hoc Cube `ensureCube` / `inferCubeFromQuery` mints from the members a query references. And object schema inference is `inferFromObject`, which no path in this repository calls: its only in-tree caller is a unit test. The list now names the three sources that do write to the registry, and points at the method for the fourth door instead of advertising it as delivered. + + `inferFromObject`'s own "heuristic rules" list was wrong in three of five bullets. Driving the built package: + + - `number` / `currency` / `percent` fields mint one `sum` and one `avg` measure each — not the documented `sum`, `avg`, `min`, `max`. No `min` or `max` measure exists. + - `boolean` fields become a `boolean` dimension and nothing else. The documented "`count` measure (count where true)" is not minted. + - Every field becomes a dimension. The documented "all non-computed fields" implies an exclusion the code does not have, on a parameter that carries no such flag. + + The two accurate bullets (a default `count` measure, and `date` / `datetime` fields becoming `time` dimensions granulated day/week/month/quarter/year) are kept and stated in the form the run produced. + + The method's docblock now also records what it is: a published method with no in-repo caller, still callable by consumers through the package entry (`CubeRegistry`) or `AnalyticsService.cubeRegistry`, whose output does reach the wire because `getMeta()` serves its labels as `CubeMeta` titles. +- d770b3e: Analytics: a draft-preview dataset response now describes its columns like the live one + + `AnalyticsService.queryDataset`'s ADR-0037 P3 draft-preview branch returned before the + ADR-0021 result-column enrichment ever ran, so a dataset queried while the base object had a + pending seed draft came back with none of its column metadata: `fields[].label`, `format`, + `currency`, `percentScale`, `builtinAggregate`, and the temporal `type` correction were all + absent, on measure and dimension columns alike. A renderer then fell back to humanizing the + raw measure name and guessing a percent scale from magnitude — so the same dataset in the + same widget described its columns differently depending only on whether a pending seed draft + existed, which is the surface an author is looking at while authoring the dataset. + + Every one of those keys is read off the authored dataset and the source object's field + metadata, never off the rows, so the enrichment is now one method both paths call. Dimension + VALUE label resolution (resolving a lookup id to a display name) stays skipped on the preview + path deliberately: drafted seed rows reference lookups by name, so there is no id to resolve. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 1f5cab0c7d..9423f7b794 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.3.0", + "version": "17.4.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 34ee3022a9..96b98600d1 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,645 @@ # @objectstack/service-automation +## 17.4.0 + +### Minor Changes + +- 954cb0b: feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention; the level also follows the 2026-09-04 bump ruling — + this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an + additive widening is at least `minor`). No ADR-0087 conversion: no authorable key + is renamed or retired, and the shape this refuses was never a shape any surface + offered. + + The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be + a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size` + …) is finally reachable from metadata — until now CEL was only ever asked for a + boolean. The spec half landed the contract (PR #15113); this is the half that + makes it do something. + + ```yaml + # before: written into the variable verbatim, and rendered by `notify` as + # {"dialect":"cel","source":"joinNonEmpty(...)"} + # now: evaluated — digest is "Renewal due\nInvoice overdue" + assignments: + digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' } + ``` + + - **Evaluated at run time.** The built-in `assignment` executor evaluates a + `value`-role envelope with the expression engine and assigns the result, in the + same CEL scope a flow predicate is evaluated in (one shared scope builder, so a + predicate and a value expression cannot disagree about what `rows` means). A + plain string keeps today's `{token}` interpolation, and every other literal is + still assigned as data. + - **Refused at three doors.** A malformed envelope now stops the flow registering + (`registerFlow` throws, the severity a malformed predicate gets) and surfaces as + a located `error` finding naming the node and the author's own variable — + `config.assignments.digest` — both at `objectstack validate` and at the runtime + publish gate a Studio / REST / MCP flow write goes through + (`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes: + ['flow']`). Malformed is a composition, not a fixed list: whatever + `AssignmentValueSchema` refuses in the envelope's shape — among them a missing, + empty or non-string `source`, a dialect other than `cel`, a non-object `meta` — + and then CEL that does not parse. All three doors derive that set from the same + two published validators, so none refuses a shape the executor would have run, + and a registered flow never faults for a shape those validators judge malformed. + Two shapes sit outside what either validator can judge — an `ast`-only envelope + and a whitespace-only `source` (it passes `min(1)` and reads as "not authored" + to the validator, while the CEL engine parses it untrimmed) — and those fault + loudly at run time rather than assigning a value. Both are pinned and tracked in + #15430. + - **Only the canonical map.** The ledger declares `assignment.assignments.*` and + nothing else, so the two legacy shapes the executor still normalizes — the + `assignments: [{ variable, value }]` array and the bare `{ : }` + config — keep every meaning they had, envelope-shaped values included. + `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the + array form: refusing it would break flows that register today, and that refusal + is a maintainer ruling rather than a lane's call (#15137 ask 3). + + **What changes silently, and how far it reaches.** A flow that today authors an + envelope-shaped object *as data* in the canonical `assignments` map now evaluates + it — no error on either side, a different value. The discriminator is the spec's + own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`, + in the declared map only. Data that names no `dialect`, names a non-string one, + nests the envelope one level down, or sits in either legacy shape is untouched + and byte-identical. The remaining overlap — a well-formed + `{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly + the spelling the ruling reinterprets; every near-miss the two validators can + judge now refuses loudly at registration instead of changing value in silence. +- d30ccb9: A contained per-iteration failure is now visible at run level, attributed to its iteration, and bound to its row. + + `loop { body: [ try_catch { try, catch } ] }` is the containment spelling for a per-iteration failure that must not end the sweep (there is deliberately no `loop.config.onIterationError` key). Containment already worked — the failure was caught, the loop went on and the run completed — but nothing said what it had contained: a sweep that lost two rows out of five reported `status=completed selected=5 acted=9 skipped=0` and was indistinguishable from one that lost none. The failure was in the step log and in `nodes[].failures`; no run-level number carried it, the failing step named no row, and `$error` bound no row identity. + + Four changes populate the contract `@objectstack/spec` already declares: + + - **`FlowRunSummary.failed`** — `summarizeRun` now folds `failed = Σ nodes[].failures` over the per-node array it publishes, so the run-level count can never disagree with the breakdown it summarizes. It counts every node execution that failed, contained or fatal; on a run that completed, all of them were contained. + - **`failed=N` on the run summary line** — `formatRunSummaryLine` prints the token whenever the count is present, `failed=0` included. That is the opposite of the `unmeasured` rule beside it and deliberate: `unmeasured` qualifies `acted`, while `failed` answers a question a completed run's line otherwise cannot be asked at all. Read `failed=0` precisely: **no node execution of this run failed**. It is the node fold and only that, so a `subflow` child's own contained failures stay on the child's summary rather than rolling up the way `acted` does — see #15617, where the declaration's two paragraphs are being reconciled. + - **Iteration through `try_catch`** — a step that ran in a `try` or `catch` region inside a loop body now carries the enclosing loop's `iteration`, with `regionKind` still `try` / `catch`. The step says which region ran it *and* which row it ran for. (`parallel` branch tagging was unchanged by *this* change; the entry below retires the `iteration` overload it left standing.) + - **`$error` binds the row** — the value bound to `errorVariable` (default `$error`) is the declared `TryCatchErrorValue`: `nodeId` and `message` as before, plus `iteration` and the loop's current `item` when the failure happened inside a loop body. A `subflow` / `map` child run has its own variable scope and therefore binds neither, so a parent's row identity never leaks into a child's `$error`. + + **`failed` absent means "not tracked", never `0`.** Runs recorded before this change keep it absent — no migration and no default, the same convention `unmeasured` carries. Defaulting it to zero would tell an operator "nothing failed" about a run nobody measured. Absent, the summary line prints no `failed=` token at all; present-and-zero prints `failed=0`. The count rides in the persisted `summary_json`, including on a summary compacted past the size cap, where the per-node `failures` it folds are exactly what gets dropped. +- 56fe8c2: A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. + + A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate. + + - `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge. + - `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`. + + **String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved. + + An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map. +- 5a91387: `evaluateCondition` now refuses a malformed condition shape with the same `STRUCTURAL_CONDITION_SHAPE_REFUSAL` registration already raises — evaluation and registration share one refusal, so a shape that slipped past registration can never surface as a raw `TypeError` or as a silent `false`. + + #15662 closed the reject set at the producer: `registerFlow` refuses a structural condition (`config.condition` on a node, `edge.condition`) that is neither CEL text nor an expression envelope. The evaluator was left saying the opposite thing in a different vocabulary, and that half matters because `evaluateCondition` is a **public method on an exported class** — a plugin reaches it directly regardless of what `registerFlow` admits, and a flow stored before that gate landed replays through it. + + The unguarded read had three arms, all of them now refused by the shared `structuralConditionRefusal` — the same call `registerFlow` makes, not a second hand-written envelope that could drift from it: + + - an envelope whose `source` is present and **not a string** (`{ source: 1 }`, `{ dialect: 'cel', source: 1 }`) reached `.trim()` and threw `TypeError: exprStr.trim is not a function`, naming no flow, no node and no expression; + - a value that is neither text nor envelope-shaped (`42`, `true`, `['a']`, `{}`, `{ dialect: 'cel' }`) was read as an **empty condition** and answered `false` — the "an unauthored branch must not open" rule applied to a value that was very much authored, on the same key a start node's **trigger gate** is read from; + - a malformed envelope carrying a non-predicate dialect (`{ dialect: 'cron', source: 1 }`) answered `false` one statement earlier still, at the dialect check, never reaching the source derivation at all. + + **What still evaluates is unchanged, and is pinned as controls.** Bare CEL text and both envelope spellings evaluate exactly as before; an `ast`-only envelope still answers `false`; a well-formed non-predicate dialect (`{ dialect: 'cron', source: '0 0 * * *' }`) still answers `false` rather than being refused; absent, `null`, empty and whitespace-only conditions are still "not authored", not malformed. A malformed **string** still earns its own verdict — the brace trap or the ADR-0032 §1c CEL fault — never the shape refusal. + + An app whose stored flow carries one of the refused shapes in a node or edge `condition` now fails that run loudly with a message carrying the rule, instead of skipping a branch in silence or faulting unattributed; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. +- 65ec530: The run step log tells a `parallel` branch apart from a `loop` row: `iteration` is the enclosing loop's iteration, always, and the branch index moves to `branch`. + + The engine half of the ruling `@objectstack/spec` already declares (`ExecutionStepLogSchema.branch`). One field used to hold both meanings, told apart only by reading `regionKind` first, and `runRegion`'s tagger let the innermost region win outright — it skipped any step a nested region had already tagged. Together those two facts made `loop { body: [ parallel { branches } ] }` unreadable: every branch step recorded its branch index and **no** step of that branch recorded the row it ran for, so a per-row failure inside a branch was attributable to a branch and never to a row. That is the shape a fan-out inside a sweep has, and the one an operator most needs to read. + + - **`branch` is written, and only inside a parallel branch.** `parallel` tags its branch regions with `branch: i` instead of `iteration: i`. A step outside a parallel branch carries no `branch` at all. + - **`iteration` is single-valued and carried through nesting.** `runRegion`'s tagger now splits what "innermost wins" governs. The IDENTITY fields — `parentNodeId`, `regionKind`, `retryAttempt` — answer *which region ran this step* and still belong to the innermost region outright; an enclosing region never relabels them. The INDEX fields — `iteration` and `branch` — answer *which pass of which region*, and nested regions contribute different ones that are both true of the same step, so an enclosing region now fills the index the inner region left undefined instead of being turned away at the door. A branch step inside a loop body therefore carries **both**: the row on `iteration`, the branch on `branch`. + - **`try` / `catch` inside a loop is unchanged**, deliberately. Such a region has no index of its own, so its steps keep carrying the enclosing loop's `iteration` with `regionKind` still naming the region, and gain no `branch`. It is the control arm of this change, not a subject of it. + - **Nested loops are unchanged too.** "Fill only what is undefined" still holds in both halves, so for `loop { loop }` the inner loop's `iteration` stands. + + `StepLogEntry` (exported) gains `branch?: number`. It is not derived from the spec type, and is now held equal to it by a type-level pin rather than by a comment claiming they agree. + + **Reading a run recorded before this change.** `iteration` on a `regionKind: 'parallel-branch'` step written by an older engine is a BRANCH index, not a row — the same absent-versus-zero care the run summary's other counters need. Nothing is migrated and nothing is defaulted: a step with no `branch` key is either a pre-change record or a step that ran outside a parallel branch, and `regionKind` is what tells those apart. Bumped `minor` rather than `major` to match the contract half of the same ruling, which shipped its declaration change that way. +- 8341ed2: A scheduled (cron) flow is now delivered once per tick window, and replaying a window that was already delivered is refused instead of silently sent again. + + A `time_relative` flow has taken a persisted dispatch claim per `(flow, window, record)` since #10220, so per-record once-only delivery is free for it. A `schedule` flow runs once per tick with no record and had no claim surface at all, so "this batch already went out" fell back to whatever each app remembered for itself. A scheduled digest that was replayed by an operator, or whose process restarted inside its window, delivered twice. + + Scheduled flows now claim `(flow, tick-window)` in the same `sys_flow_dispatch` ledger, and settle that claim with what the run turned into: + + - **A second fire inside one window does nothing.** The window key is a pure function of the schedule descriptor and the clock — the previous occurrence of the very same cron expression in the very same timezone, computed with the same library the job adapter schedules with — so a restart inside the window computes the same key and hits the same claim. + - **`IJobService.replay()` refuses a delivered window**, with the ADR-0112 envelope its contract declares: `code: 'RESOURCE_CONFLICT'`, `status: 409`, and a message naming the window and the claim that refused it. The promise rejects — an operator who presses replay and sees nothing happen is exactly the outcome this replaces. + - **`replay(name, data, { force: true })` sends anyway.** The duplicate is the operator's, taken knowingly. + - **A window whose claim is absent, failed or unsettled re-runs** on a plain `replay()`, with no force needed. A job that takes no claim at all — every job that is not a scheduled flow — is the absent row and behaves exactly as before. + - **`succeeded` is absorbing.** A replay that repairs a failed window records `succeeded`, so the next unforced replay is refused. A *forced* replay that throws leaves the window recorded delivered rather than rewriting it to `failed` — otherwise a failed re-send would silently reopen the unforced re-delivery door. An operator whose forced replay failed forces again. + - **A `once` schedule now has a tick window too** — the single instant it is due, which is one window for the job's whole life. The visible consequence is on replay: an operator who replays a one-shot job *before* its due instant claims that single window, so the real fire then finds the claim and does nothing. Previously both ran. + + The error-isolation `catch` that keeps a throwing flow from crashing the ticker is unchanged and still swallows. What it no longer does is leave the run indistinguishable from a delivered one: the throw settles the window's claim as `failed`, so a replay repairs it. + + `sys_flow_dispatch` gains two optional columns, `outcome` and `settled_at`. Rows written before this release read as unsettled, which reads as not delivered — the safe direction, since a replay of one re-runs rather than being refused. Only `schedule:` claims are ever settled; a `time_relative` sweep's rows stay `null` by design. + + ⚠️ **If you manage this table's DDL out of band** — anything other than letting the platform sync `sys_flow_dispatch` from its object definition — add `outcome` (text) and `settled_at` (datetime) yourself before upgrading. Without them every `settle()` throws against the driver. Dispatch dedup still works and no flow fails (the settle is best-effort and logged), but no claim ever records an outcome, so the replay refusal never fires and this release's headline change is silently absent. + + Interface changes for hosts that implement the ledger themselves: + + - `FlowDispatchStore` gains **optional** `settle()` and `read()`. A store without them still deduplicates; it announces once that the refusal cannot fire. + - `FlowDispatchStoreEngine` — the narrow ObjectQL slice the bundled store demands — now **requires** `update` alongside `find` and `insert`. A custom engine adapter typed against it must add the method. + - New exported types: `FlowDispatchClaim` and `FlowDispatchOutcome` from `@objectstack/service-automation`; `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/service-job` (the parameter type of `DbJobAdapter.setReplayGuard`, exported so it can be named); `ScheduleDispatchLedger`, `ScheduleDispatchClaim`, `ScheduleDispatchOutcome`, `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/trigger-schedule`. + - `croner` moves from a devDependency to a **dependency** of `@objectstack/trigger-schedule`, which now imports it at runtime to compute the cron tick window. It is already a runtime dependency of `@objectstack/service-job` at the same range, so the platform's dependency set does not grow. +- b31ebfe: A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names. + + An `ai.exposed` action whose target is a **screen flow** could be started over MCP and never finished. `run_action` seeded the flow's `isInput` variables from the caller's `params` — correctly — and the screen node suspended anyway, because the only inputs to that decision were "does the node declare fields" and the author's `waitForInput` flag. The MCP tool set has no verb to resume a parked run, so `ai.exposed` meant "the agent can invoke this", not "the agent can complete this". The fallback an agent took instead — re-implementing the flow's tail with `create_record` + `update_record` — bypasses whatever business rules the flow encapsulated. + + Two independent halves: + + - **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`). + - **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing. + + **Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen. + + **Accepted cost, precisely:** a field is never treated as caller-supplied when it is named `recordId` or `Id`, or when its value equals what the bag carries under `recordId`, `Id`, or `record.id` (normally the launched row's id); a required such field is therefore always collected interactively, an optional one simply does not count as answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too. + + ⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause. + + ⚠️ This does **not** make every screen flow completable over MCP. A call that omits the inputs still parks, and nothing on that surface can resume it; that half is a resume verb and is not this change. +- 4c31f02: The stranded-request inspection tells a repairable strand from a cascade-failed run — through a dedicated read-only engine member, not through the wire (#15358, ruling B′). + + `ApprovalService.inspectStrandedRequests` keyed on `run.status === 'failed'`, which over-reports in one direction: a **cascade-failed** run — an ancestor `failAncestors` failed while it was parked at its `subflow` node, whose pause `failSuspendedRun` consumed and journalled nothing — has the same terminal `failed` row as the #13909 strand, so both came back `runState: 'failed'`, and `restoreConsumedSuspension` re-arms one and refuses the other (`NO_CONSUMED_SUSPENSION`). The engine's discriminator (the consumed-suspension snapshot on the durable `RunRecord`) is deliberately NOT on the `ExecutionLogEntry` that `getRun` answers, because `GET /automation/:name/runs/:runId` serves that object verbatim — so the plugin could not read it, and reading its absence as "not a strand" would have called the repairable row dead. + + **`@objectstack/service-automation` — additive, `minor`.** `AutomationEngine.inspectConsumedSuspension(runId)` answers whether `restoreConsumedSuspension` would have a consumed suspension to put back, from the SAME two witnesses that verb reads (this process's hot journal and the durable row, reconciled by the same `rowSupersedesJournal` / `persisted` / drop-notice rules — the read is now one private method both call), and re-arms nothing. Four answers, none folded: `repairable: true` (with the pause it would re-arm and which witness answered); `SNAPSHOT_DROPPED` (the strand happened, the store could not persist the snapshot, and this process holds no hot copy — repairable only by the replica that stranded it, while it lives); `NO_CONSUMED_SUSPENSION` (cascade-failed or never paused); `RUN_SUSPENDED` (already resumable). It REJECTS when a store cannot be read — an outage is unknown, not "nothing to restore". The result type is exported as `ConsumedSuspensionInspection`. `restoreConsumedSuspension` behaves exactly as before; nothing on `ExecutionLogEntry`, the run-detail route, or `@objectstack/spec` changes. + + **`@objectstack/plugin-approvals` — additive on two published types, `minor`.** + + - `ApprovalResumeSurface` gains the optional `inspectConsumedSuspension?(runId)`, declared the way `listSuspendedRunsDurable` is: a method `AutomationEngine` already implements, widening no engine surface. + - `StrandedRunState` splits `'failed'` three ways and keeps `'missing'` untouched: `'repairable'` (the #13909 strand — restore, then `continueRestoredRun`), `'snapshot_dropped'` (its own class: as `repairable` it over-reports, as `unrepairable` it is a false negative), and `'unrepairable'` (the cascade-failed / never-paused run, #15222's shape — nothing re-arms it). **`'failed'` stays a member, on purpose**: it is what a `failed` row is reported as when the attached surface has no `inspectConsumedSuspension` (an engine build older than this plugin, or a host double). Absence of the discriminator is fail-closed for a report — the row is reported, undifferentiated, never labelled `unrepairable` and never dropped. A thrown read counts `undetermined`, as the other two oracles' do. + + A consumer switching exhaustively over `StrandedRunState` gains three arms; nothing it matched before stops arriving. The inspection's summary log adds `runRepairable` / `runSnapshotDropped` / `runUnrepairable` beside the existing counts. +- 5964124: feat(automation): a resume that consumed the pause and then failed downstream answers `status: 'stranded'` (#13937) + + The services half of the #13937 shape-4 ruling (maintainer 2026-09-01): + `resumeInternal`'s consumption order is kept — the suspension is consumed + before downstream nodes run, which is what buys exactly-once across a crash — + and the state that order leaves behind when a downstream node throws now + carries the platform-level name #14384 put on the contract. + + `AutomationEngine.resume()` (and every engine continuation that reaches the + same catch arm) returns `{ success: false, status: 'stranded', … }` where it + returned no `status` at all. Stamped on that one exit only: the pause a + durable decision was waiting on is gone, the run is recorded `failed`, and it + can be re-armed only by the explicit operator verb + `restoreConsumedSuspension` (#13909 slice 2, already published) — never by + `resume` (which answers `RUN_NOT_FOUND`) and never automatically. Distinct + from `'failed'` on purpose: that one says the run ran and was rejected; this + one says a recorded continuation stopped mid-flight and an operator has + something to repair. The result's verdict and the restore verb are held to + agree by test: a stranded result is exactly a restorable run. + + Not changed: the run's RECORDED status (the run log, `getRun`, `listRuns`, the + durable `sys_automation_run` history row) stays `failed` — that vocabulary is + `ExecutionStatus` in `@objectstack/spec`, which the ruling did not widen; the + durable discriminator for the condition remains the snapshot the terminal row + carries. No resume semantics move for any pausing node type; shapes 2 and 3 + of the decision stay excluded. + + Also in this change, under the same ruling's exactly-once guarantee, two + repairs to how `restoreConsumedSuspension` finds a stranded run's snapshot: + + - The durable run-history row of a stranded run now records the PAUSE node in + `node_id`. It recorded the node that threw — the run's last step — and the + object store read that column back as the snapshot's node, so a restore + from the row (after a restart, or on another replica) re-armed the run at + the failed node and the next resume skipped it while reporting the run + completed. The throwing node stays in the row's step log and `error`. + Visible on the Runs surface: `sys_automation_run`'s row title and highlight + set are built from `node_id` (`titleFormat '{flow_name} · {node_id}'`), so a + stranded run's row now names the PAUSED node — the one an operator can + re-arm — where it named the node that threw; ordinary completed / failed + rows are unchanged. The `node_id` and `variables_json` field descriptions + carry this carve-out, the way `node_type`'s already did. + - The verb reads the durable row and its own per-process journal as two + witnesses of one strand instead of trusting either alone. The hot copy is + preferred when both describe the same pause (it is the verbatim object the + failure was journalled from). A row that carries no snapshot is read as + "the run moved on" only when this process's own history write landed — + the replica that stranded a run used to keep a hot copy that could re-arm + the run after another replica had restored, resumed and finished it, and + the next resume re-ran every node after the pause. A snapshot the object + store could not persist (over its 256 KiB row budget) is now recorded in + the row as dropped, with the pause it belonged to, so the replica holding + the hot copy still restores and any other replica is refused with a reason + that names the budget and the remedy. + + In-memory and store-less deployments observe no behaviour difference. On the + object store, same-replica restores re-arm the pause node on every path, and + restores from the row alone do too; restores across replicas of a run that + finished elsewhere are refused. +- 9408b7f: A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. + + `evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author. + + - The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`. + + **This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too. + + **Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved. + + An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. + +### Patch Changes + +- a775510: A run whose nodes all succeeded is no longer reported `stranded`, journalled for repair, or re-armed because its terminal run-history write threw — which made the "repair" re-run every node after the pause. + + `AutomationEngine.resumeInternal` 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. The arm journalled a repair snapshot, stamped `status: 'stranded'`, and answered `success: false`; `restoreConsumedSuspension` then correctly honoured that snapshot and put the pause back, so the next resume drove the downstream nodes a **second** time. The defence that normally prevents a re-armed run from becoming double-runnable reads the durable terminal row first, and on this path the durable terminal row is precisely what failed to land — so it could not fire. + + Two statements inside `recordLog` reach that `catch`, and both are host-supplied surfaces rather than in-repo ones. `store.recordTerminal` escapes when it throws **synchronously**: the `void write.catch(...)` beneath the call only ever sees a returned promise's rejection, and a store returning a non-thenable makes `write.catch` itself a synchronous `TypeError`. Both stores shipped in this package are `async` methods and so cannot reach it, but `SuspendedRunStore` is an exported, optional-method interface a host may implement. The second statement is the run-summary line `logger.info(...)`, which is on by default (`runSummaryLog: 'info'`) and calls a host-injected `Logger`, so it needs no store at all. + + - **The completion-path history write is now guarded at its own site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. `resume` answers the truthful `success: true`, no snapshot is journalled, `restoreConsumedSuspension` refuses with `RUN_COMPLETED`, and the downstream node runs exactly once. + - **The lost history row is still reported**, at `error`, naming in its first line that the run completed, that its terminal row never landed and nothing retries it, that the run must not be repaired or re-run, and that the driver's own failure is in the record's structured slot. + - **`restoreConsumedSuspension` is unchanged.** It judged correctly on the evidence it was handed; the evidence was what was wrong, and a completed run now journals none. A genuine node failure still journals, still reports `stranded`, and is still repairable. +- 60c0f61: A suspended run whose durable save fails is now kept resumable in this process even when a concurrent read landed mid-park — and the error record that reports the failure says how to check that. + + `AutomationEngine.persistSuspendedRun` writes its map entry BEFORE it awaits `store.save()`, and marks the run cache-only only once that save settles. For the whole of that await the entry is live but unqualified, so a concurrent per-id `hasSuspendedRun` / `resume` reads a store that truthfully has no row yet, finds no qualifier, and evicts a run that is being parked right now. That window is bounded and stays as it was: the strict load is store-first, so once the save lands the run is resumable from the store, and only the cache-only listing under-reports. + + Compounded with the save then **failing**, it was not bounded. The catch marked the run cache-only, but the map entry that marking qualifies had already been evicted, so the run had neither a durable row nor an in-memory copy: `hasSuspendedRun` answered `false` and `resume` answered `RUN_NOT_FOUND`. The run was lost **in this process**, not merely un-durable — for example a paused approval that no decision can ever advance. Reaching it needs a store that rejects the write while still answering reads with "no row" rather than throwing: a healthy read replica behind a broken write path, a missing `INSERT` grant, a full disk. + + - **The failure path now re-seats the map entry** alongside the cache-only marking, so the marking qualifies something again and the documented degradation — a failed save costs cross-restart durability, not in-process resumability — holds in this interleaving too. The cache-only marking is not widened, no lock is added, and the save is not reordered, so a run is still never readable out of the map while the store is authoritative for it. + - **The error record for a failed save is corrected.** It kept telling the operator the run was "kept in memory only" and that they had until the next restart to act, which in this interleaving pointed away from the loss: the run was already gone, and the restart would take the blame. It now names the two reads that must still answer for the run (`hasSuspendedRun()` and `listSuspendedRuns()`), so the promise can be checked rather than trusted. It still reports the same cause in the same structured slot, at the same `error` level. +- 8b67272: docs(automation): `sys_automation_run` says why `failed` has no column of its own, and `summary_json` names it (#15606) + + `FlowRunSummary` carries five run-level totals. Four of them — + `selected_count`, `acted_count`, `skipped_count`, `unmeasured_count` — have a + column on `sys_automation_run`; `failed` rides inside the `summary_json` blob. + That asymmetry was filed as a finding and ruled on (decision batch #76, + 2026-09-07) rather than closed by adding a fifth column, and this change is the + ruling: the reasoning now ships in the schema instead of living only on the + card. + + The four are columns because ONE filter expression needs them in ONE row — + `selected_count > 0 AND acted_count = 0`, qualified by `unmeasured_count` — and + a `WHERE` clause cannot reach into a JSON blob for an operand, so every operand + of that expression has to be a column or the expression cannot be written at + all. `failed` is not one of its operands: it would be its own predicate + (`failed_count > 0`), nobody alerts on it today, and a caller that wants it has + already fetched `summary_json`. + + What a consumer sees change: + + - `summary_json`'s `description` now names `failed` as the field to read + lost-row counts from, states that the run-level totals live in the blob + alongside the per-node breakdown, and repeats the `unmeasured`/`failed` + convention that an absent count means "not tracked", never zero. ⚠️ This is + why the change carries a changeset and NOT `skip-changeset`, and it was + MEASURED rather than assumed from "it's only prose": `SysAutomationRun` is + re-exported from `src/index.ts`, `package.json` publishes `files: ["dist"]`, + and after `pnpm --filter @objectstack/service-automation build` the new + description text is present in BOTH published entry points — one hit each in + `dist/index.js` and `dist/index.cjs`. `skip-changeset` is for a diff that + publishes nothing from any released package; this one changes bytes inside a + released package's shipped bundle, so it does not qualify. (`description` is + also what the authorable `help` / `helpText` keys alias onto in + `packages/spec/src/data/object.zod.ts` — documentation a consumer surface can + render, not an internal note.) + - The comment above `selected_count` — the paragraph that explains why the + four are columns, and therefore the paragraph a reader is in when they + notice the fifth is not — now carries the verdict for `failed` and the one + condition that re-opens it: the first real need to ALERT on "which runs lost + rows this week" is the card that adds `failed_count`, mirroring + `unmeasured_count` (null on rows written before the column existed, never + `0`) — one column on an ADR-0103 engine-owned object, a human-floor change. + - `ObjectStoreSuspendedRunStore`'s terminal-row write, where a fifth + `record.summary?.failed ?? null` line would go, points at that verdict so the + question is not re-derived from the write site either. + + No schema shape moves: no field is added, removed or renamed, no type or + `required` flag changes, and the accepted set of every object and payload is + byte-for-byte what it was. `sys-automation-run-failed-count-verdict.test.ts` + pins both halves — that there is still no `failed_count` (or any other + `fail`-named) column, and that `summary_json`'s description still names + `failed` — so the explanation cannot rot into a claim the schema no longer + supports. +- 4e090ec: `ObjectStoreSuspendedRunStore` no longer announces "no cross-replica advance guarantee is offered" *after* it has issued the guarded delete. + + `claimSuspension` is the cross-replica half of the resume idempotency guard: it removes the `sys_automation_run` row only if the run is still parked where this replica read it, and the affected-row count names the winner. The refusal for an engine that does not resolve such a count was decided on the SHAPE of the return value — one line after the compare-and-set had already gone out. On such an engine that made the refusal a statement about a write that had already landed: the conditional delete was performed against the shared row and its verdict discarded, `AutomationEngine.claimAdvance` read `'unsupported'` as `unguarded`, and a replica that **actually lost** the claim (0 rows affected) resumed anyway — running every downstream side effect a second time, on the one composition that declares itself unable to prevent that. + + The capability question is now settled before anything is claimed, and `'unsupported'` is retired as an answer once the row has been touched: + + - **A one-time capability probe, before the compare-and-set.** Once per store instance, `claimSuspension` issues one delete down the very route the claim takes (`multi: true` with a `where` carrying keys besides `id`, which is what dispatches to `driver.deleteMany`) against a sentinel predicate that matches no row — the same value in `id`, `node_id` and `correlation` at once. An engine that resolves something other than a count is refused with **nothing consumed**, so `claimAdvance`'s `unguarded` reading is true when it is taken. Concurrent first claims share one probe, and a probe that *throws* is deliberately not memoized: a store that was unreachable for one second must not answer for the life of the process. + - **After the write, an unreadable verdict is `STORE_UNAVAILABLE`, not `unguarded`.** If a probed-counting engine still resolves a non-count for a real claim, the compare-and-set is committed and its verdict is unrecoverable — a winner and a loser both find the row gone, so no follow-up read can tell them apart. The store throws instead of answering `'unsupported'`; `claimAdvance` already maps that to `STORE_UNAVAILABLE`, whose text is written for exactly this fact ("a failure can arrive after a committed delete"), and the resume is **refused** rather than continued. A claim that in fact won is then stranded until an operator retries — the deliberate direction, since a doubled side effect is the worse outcome. + + **What this does not do, stated so it is not read into it.** It does not give an uncounted engine the guarantee. `ObjectQL.delete` declares `Promise`, so "does a multi-delete return a count" has no contractual answer to look up and no read-only instrument to measure — a probe can observe the route once, never promise what the next call resolves to. Closing that gap belongs to the engine boundary, where the count is contracted one layer down (`IDataDriver.deleteMany`, `Promise`) and erased to `any` on the way up. On such a composition the store still degrades to an unguarded resume; what changed is that it says so before consuming anything, and the run's durable row is still removed by the consumption choke point exactly as before. + + Every measured shipped composition already resolves a count (memory, sql/better-sqlite3, sqlite-wasm, turso local and remote transport, sql with the security plugin composed), so the observable cost there is one extra `DELETE … WHERE` that matches nothing, once per process. It emits no hook dispatch, no realtime event and no row change: the per-row before phase is "zero matched rows is zero dispatches", the after phase iterates the same empty set, and `publishBulkDataEvent` returns at `matched === 0` by design. +- 7bf96cf: A `map` node inside a `loop` body now runs its collection on every iteration, not just the first. + + `map` tracks its progress through the collection in the flow variable `.$mapState`, and wrote it into the flow's **shared** variable scope without ever removing it. A `loop` body region runs in that same scope by construction — that is what makes the iterator variable and the body's mutations visible to the rest of the flow — so the state written by iteration 1 was still there when iteration 2 entered the map. It read back `started === collection.length`, correctly concluded there was nothing left to start, and returned. + + The result was silent partial work reported as success: measured on the engine, **5 iterations x 2 items produced 2 child runs instead of 10**, the map step reported `success` on all five iterations, and the run finished `completed`. Nothing threw and nothing was caught, so `FlowRunSummary.failed` — the run-level counter that exists to expose contained failures — reported `failed = 0` over it. An operator reading that counter was told the run was clean while it had done a fifth of its work. + + The fix is a lifetime correction, not a new key: `$mapState` is now removed once the collection is exhausted, so its lifetime is one execution of the collection rather than the enclosing scope's. + + **The durable-pause path is deliberately unchanged.** A `map` whose per-item subflow pauses still writes its progress before suspending, and still reads it back when the engine re-enters the node — that write is the mechanism resume depends on, because a resume rebuilds the variable scope from the snapshot taken at the suspend and so can never see any later write. Only the node's terminal path clears the key. A `map` resumed mid-collection continues where it left off, exactly as before, and no item is re-run. +- 4177ed3: A wizard screen is no longer skipped after a durable pause because a record column happens to be an array. + + `judgeHeadlessScreen` decides a screen was already answered by proving the negative: a field is **not** caller-supplied when the subject record carries that key and `params` holds the same value — necessary because the params bag a flow action arrives with is `{ ...record, recordId, Id, ...params }`, so every column of the launched row is in there whether the caller named it or not. + + That comparison was reference identity (`Object.is`), which is real in memory and does not survive persistence. A suspended run stores its context as JSON and resumes from the parsed copy — and the store is preferred over the in-process cache whenever one is wired, so no restart is needed. After that round trip an **array or object** column is equal but no longer identical: the record leg could not disprove it, the field read as caller-supplied, and a later screen with no required fields of its own was **skipped on a run that had supplied nothing**. An interactive user pressed a button and never saw a form they should have been shown; the run completed carrying the row's own value as if they had typed it. Reproduced end to end against a wired store, not inferred. + + The record leg now compares by value (`isDeepStrictEqual`), which survives serialisation. That predicate compares primitives with `Object.is` itself, so this is a strict widening of the "not caller-supplied" set — every pair the old check called equal it still calls equal, plus the structurally identical non-primitives. More screens render, never fewer, which is the direction this module resolves every ambiguity in. + + **Accepted cost, precisely.** A caller that genuinely re-sends a value structurally identical to the row's column is no longer distinguishable from the dispatcher's seed, so it now gets the screen rendered instead of skipped — a lost skip on a headless call, never a lost run, and the same trade the module's other legs already make. Scalar columns behave exactly as before, on both sides of a pause. The row-id leg keeps identity comparison deliberately: a row id is a scalar by construction, so serialisation cannot defeat it and there is nothing there to widen. Measured overhead is a deep compare per declared screen field at screen entry: ~1.5 µs added for a deliberately maximal screen that declares a field for every one of a ten-column row, which is about 38% of one `JSON.stringify` of the run context — a cost the durable store already pays on every suspend. + + This closes the gap the same release's screen-flow headless-satisfaction note records as known. +- 0cf0867: Keep a resume's `status: 'stranded'` verdict when the bookkeeping after the repair journal throws. + + `resumeInternal`'s catch arm journals the consumed suspension — the snapshot `restoreConsumedSuspension` puts back — and only then stamps `status: 'stranded'`. Two statements sat between them and could throw out of the whole arm: `recordLog`'s terminal run-summary line, and a store whose `recordTerminal` throws synchronously (the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection). `failAncestors` follows them. + + A throw in that window left the run genuinely repairable while the verdict never shipped, and every consumer derives repairability from the verdict — `plugin-approvals` computes its operator-facing `repairable` as `status === 'stranded'` — so the approvals decision door reported `repairable: false` about a run that `restoreConsumedSuspension` answers `restored: true` for. That is a false negative on a repair instruction: it tells an operator not to attempt a repair that works. + + The window is now guarded. The bookkeeping may still fail — and says so loudly, at `error`, naming the run, what did not land, and the verb that repairs the strand — while the verdict still ships. Measured: with a store whose terminal write throws, `resume` now returns `{ success: false, status: 'stranded' }` instead of throwing, the door reports `repairable: true`, and the repair verb succeeds on that same run. + + The guard opens **after** the journal, so only a run that demonstrably has a snapshot can reach the stamp: a throw from the journal itself still propagates, every exit above the consumption point still carries no status at all, and cascade-failed ancestors — which journal nothing — are untouched and still correctly non-repairable. + + ⚠️ This change also makes a pre-existing fault **visible** rather than creating it. The completion path's history write sits inside the same `try` as the node-failure arm, so a run that **completed** — every node succeeded — is journalled and reported `stranded` when its `completed` history row throws, and repairing such a run **re-runs the flow**. That phantom, its repair snapshot and the double run were all measurable before this change; what changes here is only that more store failures now report the verdict instead of throwing over it, so an operator can now be told to repair a completed run. Filed as #15944, with the measurement on both trees. + + ⚠️ `repairable` remains a point-in-time fact, and this change does not make it durable: the run in the case above has no terminal history row (that write is what failed), so the repair rides on the in-memory journal and a restart loses it. The verdict reports what an operator can do now, which is exactly what was being denied. +- 1375344: automation: a subflow parent left STRANDED by a failed up-bubble is reported at `error`, not `warn` + + When an approval (or any pause) sits inside a subflow child, resuming the child + bubbles up to the parent. If the parent's own continuation then fails on the + engine's stranded exit — its suspension consumed, a repair snapshot journalled, + the run recorded `failed` — nothing but a `warn` said so, while the child's + resumer (an approvals decision door, a wait timer) was told the resume + succeeded. Persisted state and runtime state disagree and nothing looks broken + from the outside, which is the durability class. + + `bubbleToParent` now grades that record by the engine's own + `AutomationResult.status` discriminator: `'stranded'` is reported at `error`, + naming the parent run and the `restoreConsumedSuspension` verb that repairs it. + Every other parent-resume failure — a concurrent resume, an unreachable store, + a thrown resume — stays at `warn` unchanged, on a narrower ground: those exits + carry no `'stranded'` discriminator. `'stranded'` is the one exit that journals + a repair snapshot, so it is the one an operator can act on, and grading by the + engine's own verdict is what keeps `error` readable. + + ⚠️ That is a statement about what this seam can KNOW, not a guarantee that + every other exit left the parent healthy. Two exits are known not to be: + + - a **thrown** parent resume carries no discriminator at all, and #15555 + documents a window in which a throw between the journal and the stamp hides a + parent that IS stranded. Left at `warn` deliberately, for that card; + - the **claim-path** store failure reports, in its own envelope text, that + whether the suspension was consumed is UNKNOWN — it relies on a retry to + settle it, and an up-bubble has no retrier. ("Not consumed" is the guarantee + of the strict-load store failure only, not of every store failure.) + + ⚠️ This is the log half only. What the child's resumer is told is unchanged. +- 3890244: A restored suspension now carries the state the run was paused with, including nested values. + + `restoreConsumedSuspension` is the operator exit from a run whose resume consumed the pause and then failed downstream: it puts the suspension back so the run is resumable again. What it put back was documented as the pause "verbatim", and was — for the top-level variables only. + + The flow scope a resume hands the downstream nodes was rebuilt as `new Map(Object.entries(run.variables))`: that copies the keys and shares every value object with the parked snapshot. An executor that keeps state in the scope and updates it **in place** — `map` tracks its progress in `.$mapState` — therefore wrote straight through into the snapshot, and the journal recorded the result as the pause. An operator repairing a stranded `map` run got a snapshot claiming progress made by the attempt that failed, not the progress the run actually had when it paused. + + Measured, not inferred: the durable row held `started: 1` at the pause and the restore put back `started: 99`. + + The pause's variables are now copied before the failed attempt runs, on the line that already captures the pause's step count for the same reason. No later placement works — the node mutates and then throws, so a copy taken when the journal is written copies the mutation. Nothing else changes: the running flow still sees exactly the scope it saw before, the resume ordering is untouched, and a value that cannot be copied falls back to the previous behaviour with a warning rather than costing the operator the repair. +- 1157e7b: fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms (#15832) + + `AutomationEngine` had exactly one eviction site for its `suspendedRuns` + map, inside `forgetSuspendedRun` — and that runs in whichever process + **consumes** the suspension. In a multi-replica deployment that is routinely + not the process that parked it: replica A parks a run, replica B resumes it, + and nothing ever removes A's entry. There is no invalidation channel from B + to A. + + The card that found this located the leak on `resumeInternal`'s + `claim.kind === 'lost'` branch, which returns before that choke point. That + branch does leak, but it is not the common shape: the **no-race** variant + leaks identically — A parks, only B ever resumes, A never attempts a claim + and there is no `'lost'` anywhere in the sequence — so an eviction hung on + `'lost'` alone would have left the ordinary deployment untouched. + + The retained snapshot was **not only memory**. Two readers handed it back: + `listSuspendedRuns()` (synchronous, cache-only, and the one listing on the + `AutomationService` spec contract) and `listSuspendedRunsDurable()` (which + deliberately appends map entries the durable list lacks). Once the other + replica **completed** the run, both reported a phantom — a finished run + listed as suspended, whose `getSuspendedScreen()` answers `null`, so a + consumer that listed and then opened got an entry it could not act on. + + An entry is now dropped whenever this process holds a store-authoritative, + per-id "no row" answer for it: the strict loader's store miss (which reaches + `resume`, `hasSuspendedRun`, `cancelRun` and `getSuspendedScreen`), a lost + advance claim, and a bounded per-id reconcile for the map-only entries of + `listSuspendedRunsDurable()`. + + **Nothing here moves the cache-only listing's contract.** The fix only ever + *removes* entries. The spec says `listSuspendedRuns()` lists "the currently + suspended (paused) runs awaiting a resume"; the engine's own docblock adds + only that it may OMIT runs (those parked in a previous process lifetime), + because it reads the cache alone. Under-reporting is therefore already + inside the declared latitude, and over-reporting was never inside the + promise. Neither listing becomes store-backed, and `listSuspendedRuns()` + stays synchronous. + + Three shapes are deliberately **never** evicted, each pinned by a control: + no store attached (the map IS the authority); a run whose durable save + failed (`cacheOnlySuspensions` — the store was never handed the row, so its + silence says nothing about it); and a store read that THROWS (an outage + means the run's existence is unknown, not gone). A failed `list()` + enumeration likewise triggers no per-id reconcile — during an outage that + would ask about every live run in the process. + + **Residual, stated rather than implied.** Eviction is demand-driven: a + phantom is cleared when this process next obtains the per-id answer for that + run — any `resume` / `hasSuspendedRun` / `getSuspendedScreen`, or a + `listSuspendedRunsDurable()` reconcile. A process that never looks at the + run again keeps the entry until it does. With no invalidation channel + between replicas, closing that last gap needs either a background sweep or a + store-backed listing, and both are decisions above this change; the boundary + is pinned by a `RESIDUAL` test rather than left to be discovered. + + Note 2 of the same card — the `'unsupported'` branch deciding on the shape of + a value the conditional delete has **already** been issued to obtain — is + **not** addressed here: its honest fix is a declared return contract for the + engine's multi-row delete, which lands in another package. +- 3e9065c: `$error` now names the most recent failure in a flow run, whichever way that failure arrived. + + The automation engine has two failure arms. When a node FAILS BY RETURNING `{ success: false }`, the engine rewrote the run-wide `$error` (and `.error`) and then decided whether a `fault` edge could route it. When a node FAILED BY THROWING — a `timeoutMs` firing, a dying nested container, a thrown guard — it did both **inside** the `fault`-edge branch, so a thrown failure with no `fault` edge of its own left `$error` holding an earlier, unrelated failure's value. + + A node inside a structured region never has a `fault` edge of its own: the region's synthetic sub-flow carries only the region's own edges. So every thrown failure inside a `try_catch`, `loop` body or other region hit this. The result was not a crash but a plausible-looking wrong value: **the message and the code came from two different failures** — `{ code: 'DUPLICATE_RECORD', message: "Node 'mk' timed out after 20ms" }` — and a catch region branching on `{$error.code}` swallowed a store failure as "the row is already there" while the run reported success. + + The throw arm now publishes `$error` and `.error` before deciding whether the failure routes, exactly as the returned-failure arm does. What a thrown failure publishes is `{ nodeId, message }`: there is no node result on that path, so no `output` and no classified `code` exist to carry — and that absence is the right answer for a throw rather than a reason to leave a stale `code` standing. + + Routing is unchanged. A guard refusal that throws (ADR-0049's unscoped-run refusal, for one) is still un-routable, still fatal, and still reports its own message; the thrown value itself is rethrown exactly as caught. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- 6c439f2: Flow templates: `{TODAY() + n}` and `{TODAY() - n}` now do their day arithmetic on the same calendar they render on (UTC), so the resolved date no longer lands a day off across a DST transition. + + The offset branch of the template resolver shifted the day on the **local** calendar (`getDate` / `setDate`) and then rendered the result on the **UTC** one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the underlying instant by exactly n x 24 hours only while every local day in the window is 24 hours long. Across a spring-forward the window is 23 hours and across a fall-back 25, and when that one hour of slack crosses a UTC midnight the rendered date comes out a day early (spring-forward) or a day late (fall-back). + + The window is narrow — roughly one hour per DST-observing zone, twice a year — but the values written through it persist: a quote expiration, a follow-up date, a close date. Measured across 34 zones at every 30 minutes of 2026 for offsets `+1` and `-1` (1,191,360 instant-offset pairs), the old spelling disagreed with the UTC day in 190 of them, spread over 24 DST-observing zones; the new spelling disagrees in none. + + The same branch serves `{NOW() + n}`, which likewise now moves the instant by exactly n x 24 hours instead of preserving a wall-clock time across the transition. + + Nothing else moves. The bare `{TODAY()}` and `{NOW()}` forms never entered this branch and are byte-for-byte unchanged — they already resolved on UTC, and the offset forms now agree with them. This is not a timezone feature: these tokens remain timezone-unaware by design, and whether they should be is a separate question. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [098cbb7] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [86c75f4] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/formula@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index dbca46d152..6800e28dba 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.3.0", + "version": "17.4.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 a296879af8..3d0ada770c 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,144 @@ # @objectstack/service-cache +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 24975c034d..f92c47d252 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.3.0", + "version": "17.4.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 11f8eb3ae6..6b97c74c05 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,138 @@ # @objectstack/service-cluster-redis +## 17.4.0 + +### Patch Changes + +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/service-cluster@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index e906029708..eb03afe5bc 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.3.0", + "version": "17.4.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 15e0461343..80ff3f88af 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,180 @@ # @objectstack/service-cluster +## 17.4.0 + +### Patch Changes + +- c677cda: Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an + Apache-2.0 package in this repository (ADR-0132). + + Single-database, row-level organization isolation was already open — the tenant Layer 0 wall, + the three tenancy postures, the organization and invitation objects, better-auth's organization + plugin, and the `requiresService: 'org-scoping'` Setup gates. What was closed was the one + registrar of the `org-scoping` service, so an install that set `OS_TENANCY_POSTURE=isolated` + could not enforce it: `serve` refused the boot, and the only way past was + `OS_ALLOW_DEGRADED_TENANCY=1` — the wall configured but not enforced. This package is that + missing registrar. + + It provides: + + - **`organization_id` auto-stamp on insert**, from the caller's active organization. A supplied + — possibly forged — value is overwritten, never trusted. + - **Per-organization seed replay** on `sys_organization` insert, from the app's own seed + definitions. Never another organization's rows. + - **Default-organization bootstrap** for the platform admin, idempotent. + - **The walled-posture membership-policy gate**: a deployment that raises the wall must declare + what a new user joins, or the boot is refused. + + Only the commercial **entitlement** stays closed. The open class carries no licence check of any + kind and offers no hook for one; an enterprise deployment resolves the same package name to a + private, licence-gated subclass through its own `workspace:*` declaration, so which class is + mounted is decided by the manifest that declares the name. + + ⚠️ Shipping the registrar is not yet the same as an open install raising the wall: `objectstack + serve` still resolves the runtime from the served app's own declaration and is not yet wired to + mount this package off `OS_TENANCY_POSTURE`. That, and the isolation matrix run against a real + registrar rather than a posture stub, are tracked separately. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index b9494cc0a4..befd8fb6df 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.3.0", + "version": "17.4.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 7332dd4768..597e263038 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,243 @@ # @objectstack/service-external-datasource +## 17.4.0 + +### Patch Changes + +- fb447b4: The datasource admin routes derive the tenancy posture before resolving the caller + + `requireDatasourceAdmin` resolved the request with `resolveAuthzContext({ ql, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran on this family, and an API key stamped with an organization its owner had left was admitted; the routes then gated it on `authz.systemPermissions` alone. Because this family gates on system capabilities rather than on organization-scoped rows, the consequence was an admitted principal rather than a cross-organization row read. + + The posture is now read off the kernel's `tenancy` service and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Patch rather than minor: no accept set widens, and a declared guard returns to enforced. +- e9fcd6b: fix(service-datasource): the shared libSQL config builder reads the canonical `config.timeoutMs` (#16023, follow-up on #15680) + + `buildTursoDriverConfig` — the ONE seam both libSQL loaders go through (#7314) — + still consulted `config.timeout` after #15680 renamed that authored key to + `timeoutMs` and tombstoned the old spelling. A turso datasource authored the + canonical way therefore reached the seam, matched nothing, and had its timeout + **silently dropped**: no diagnostic in any channel. + + The reader now consults `config.timeoutMs`. The DRIVER key it lands on is + unchanged and still spelled `timeout` — `TursoDriverConfig.timeout` is + published-but-inert (#16024), and renaming an inert key would ratify it as real, + which is what ADR-0049 exists to prevent. So this seam is the one place the + authored and driver spellings differ, and it now says so. + + ## No fallback arm for the retired spelling — the seam's own precedent + + Both sibling arms in `default-datasource-driver-factory.ts` already answer this + in the same words: sqlite's "`filename` is the whole contract … so no `??` + tolerance survives here", mongo's "`url` is the one spelling". A renamed + datasource config key reaches a reader already canonical from two directions — + authoring refuses the retired spelling at the door (`retiredKey()`: `tsc` + `never` plus a parse-time prescription), and a stored `sys_metadata` row replays + the full ADR-0087 chain including `retiredFromLoadPath` entries at + `loadDatasourceRows` / `loadDatasourceRow`, so the D2 conversion + `turso-config-timeout-to-timeout-ms` has rewritten the key before this table + sees it. A `??` arm would be a consumer-side dialect (Prime Directive #12) for a + spelling both doors have closed. + + `authToken`'s legacy arm is not a counter-precedent: it is kept for a LIVE route + (host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it constructs + itself, which never meets the authoring schema), not for a retired spelling. + + ## Why the covering test did not catch it, and what replaces it + + `TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a + rename through it — the tombstone's type channel, which caught the alias tables + elsewhere in this stack, does not reach here. And the covering test authored the + **retired** spelling at all three of its turso `config` sites, so it was green + for exactly the behaviour that had become wrong. A test that pins the retired + spelling cannot notice this class of bug. + + The three sites now author the canonical spelling, and the file gains cases + DERIVED from the authoring contract rather than written against today's key + list: they read `TursoConfigSchema`'s own `retiredKey()` tombstones and assert + that (a) every canonical replacement is consulted by some reader, and (b) no + retired spelling is — probed at every JS type a reader could type-test, with a + vacuity guard so a mis-derived empty list fails instead of passing. They hold + for the next rename without being edited. + + The two sibling pins that author the same spec — `packages/cli`'s driver + correspondence check and `packages/runtime`'s cross-loader convergence check — + move to the canonical spelling with it; their assertions read driver keys and + are unchanged. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [54bb2f1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [10d05bb] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2003259] +- Updated dependencies [a646120] +- Updated dependencies [a06faeb] +- Updated dependencies [001a83b] +- Updated dependencies [6d4d5d3] +- Updated dependencies [45cfa1b] +- Updated dependencies [7862fb7] +- Updated dependencies [1ca95df] +- Updated dependencies [a646120] +- Updated dependencies [2200f8e] +- Updated dependencies [7862fb7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ed5d557] +- Updated dependencies [7862fb7] +- Updated dependencies [a646120] +- Updated dependencies [5071310] +- Updated dependencies [2200f8e] +- Updated dependencies [0145680] +- Updated dependencies [bc0ac1d] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [8f2ecb3] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [1cf7392] +- Updated dependencies [5f4f1f6] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [61821e5] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [33e939f] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [b72226f] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [78bc4ad] + - @objectstack/spec@17.4.0 + - @objectstack/driver-sql@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/driver-turso@17.4.0 + - @objectstack/driver-memory@17.4.0 + - @objectstack/driver-mongodb@17.4.0 + - @objectstack/driver-sqlite-wasm@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 9ca907fce8..4194f355e4 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.3.0", + "version": "17.4.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 76f67d400f..bfbcfccae9 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,156 @@ # @objectstack/service-i18n +## 17.4.0 + +### Minor Changes + +- a84e1ce: feat(service-i18n): `FileI18nAdapter.getFallbackLocale()` reports the `fallbackLocale` the adapter was constructed with (#14882) + + Implements the new optional `II18nService.getFallbackLocale()`. `I18nServicePlugin` + already receives `fallbackLocale || defaultLocale || 'en'` from the stack's `i18n` + config on both boot paths (`os serve`, the dev plugin); this makes that declaration + readable, so the REST metadata reads pass the document translators the same fallback + locale `t()` itself consults. Returns `undefined` when no `fallbackLocale` was given. + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 870371fc27..c118fb2f22 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.3.0", + "version": "17.4.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 857d07754e..c4a0ce376b 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,193 @@ # @objectstack/service-job +## 17.4.0 + +### Minor Changes + +- 8341ed2: A scheduled (cron) flow is now delivered once per tick window, and replaying a window that was already delivered is refused instead of silently sent again. + + A `time_relative` flow has taken a persisted dispatch claim per `(flow, window, record)` since #10220, so per-record once-only delivery is free for it. A `schedule` flow runs once per tick with no record and had no claim surface at all, so "this batch already went out" fell back to whatever each app remembered for itself. A scheduled digest that was replayed by an operator, or whose process restarted inside its window, delivered twice. + + Scheduled flows now claim `(flow, tick-window)` in the same `sys_flow_dispatch` ledger, and settle that claim with what the run turned into: + + - **A second fire inside one window does nothing.** The window key is a pure function of the schedule descriptor and the clock — the previous occurrence of the very same cron expression in the very same timezone, computed with the same library the job adapter schedules with — so a restart inside the window computes the same key and hits the same claim. + - **`IJobService.replay()` refuses a delivered window**, with the ADR-0112 envelope its contract declares: `code: 'RESOURCE_CONFLICT'`, `status: 409`, and a message naming the window and the claim that refused it. The promise rejects — an operator who presses replay and sees nothing happen is exactly the outcome this replaces. + - **`replay(name, data, { force: true })` sends anyway.** The duplicate is the operator's, taken knowingly. + - **A window whose claim is absent, failed or unsettled re-runs** on a plain `replay()`, with no force needed. A job that takes no claim at all — every job that is not a scheduled flow — is the absent row and behaves exactly as before. + - **`succeeded` is absorbing.** A replay that repairs a failed window records `succeeded`, so the next unforced replay is refused. A *forced* replay that throws leaves the window recorded delivered rather than rewriting it to `failed` — otherwise a failed re-send would silently reopen the unforced re-delivery door. An operator whose forced replay failed forces again. + - **A `once` schedule now has a tick window too** — the single instant it is due, which is one window for the job's whole life. The visible consequence is on replay: an operator who replays a one-shot job *before* its due instant claims that single window, so the real fire then finds the claim and does nothing. Previously both ran. + + The error-isolation `catch` that keeps a throwing flow from crashing the ticker is unchanged and still swallows. What it no longer does is leave the run indistinguishable from a delivered one: the throw settles the window's claim as `failed`, so a replay repairs it. + + `sys_flow_dispatch` gains two optional columns, `outcome` and `settled_at`. Rows written before this release read as unsettled, which reads as not delivered — the safe direction, since a replay of one re-runs rather than being refused. Only `schedule:` claims are ever settled; a `time_relative` sweep's rows stay `null` by design. + + ⚠️ **If you manage this table's DDL out of band** — anything other than letting the platform sync `sys_flow_dispatch` from its object definition — add `outcome` (text) and `settled_at` (datetime) yourself before upgrading. Without them every `settle()` throws against the driver. Dispatch dedup still works and no flow fails (the settle is best-effort and logged), but no claim ever records an outcome, so the replay refusal never fires and this release's headline change is silently absent. + + Interface changes for hosts that implement the ledger themselves: + + - `FlowDispatchStore` gains **optional** `settle()` and `read()`. A store without them still deduplicates; it announces once that the refusal cannot fire. + - `FlowDispatchStoreEngine` — the narrow ObjectQL slice the bundled store demands — now **requires** `update` alongside `find` and `insert`. A custom engine adapter typed against it must add the method. + - New exported types: `FlowDispatchClaim` and `FlowDispatchOutcome` from `@objectstack/service-automation`; `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/service-job` (the parameter type of `DbJobAdapter.setReplayGuard`, exported so it can be named); `ScheduleDispatchLedger`, `ScheduleDispatchClaim`, `ScheduleDispatchOutcome`, `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/trigger-schedule`. + - `croner` moves from a devDependency to a **dependency** of `@objectstack/trigger-schedule`, which now imports it at runtime to compute the cron tick window. It is already a runtime dependency of `@objectstack/service-job` at the same range, so the platform's dependency set does not grow. + +### Patch Changes + +- e9fcd6b: fix(service-job): `runWithPolicy` and the DB job adapter read `JobScheduleOptions.timeoutMs` (#14478) + + The per-attempt time limit is read from `options.timeoutMs`, following the + `@objectstack/spec` rename of both the authored `job.timeoutMs` and the + `JobScheduleOptions` contract key that carries it. Same value, same per-attempt + race, same `JobTimeoutError`; `withoutPolicy` strips the renamed key so the + timer adapter downstream never runs a second budget. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 5aa0972418..440dc2ab4e 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.3.0", + "version": "17.4.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 0356f7dbd9..460bf54dd8 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,143 @@ # @objectstack/service-knowledge +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 2c946d4056..0d00598724 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.3.0", + "version": "17.4.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 0836d1f2d7..f2e64f3803 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,161 @@ # @objectstack/service-messaging +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 6cb7ae7101..928d1655d0 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.3.0", + "version": "17.4.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 356f030132..6b94d43d59 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,144 @@ # @objectstack/service-package +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/metadata-core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 62734b900e..b64894c8da 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.3.0", + "version": "17.4.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 4eb39e12fe..7ec35ae66e 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,158 @@ # @objectstack/service-queue +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 7915c9cca5..56061c80e0 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.3.0", + "version": "17.4.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 225ff38c88..56ca9d24e5 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,158 @@ # @objectstack/service-realtime +## 17.4.0 + +### Patch Changes + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index d1f8de464e..c9af30dc07 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.3.0", + "version": "17.4.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 df51666809..81ff1191ad 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,264 @@ # @objectstack/service-settings +## 17.4.0 + +### Minor Changes + +- 6b8c677: fix(service-settings): the settings door answers from the ONE shared value-domain predicate, and refuses a non-member with `value_domain` (#15162) + + + + **BREAKING** for a client that branches on the refusal code. Landing inside + the launch window, so it ships as `minor` (the lockstep convention forbids + `major`); the banner is the carrier, not the bump. + + The services half of the maintainer's ruling of 2026-09-02: **one closed + vocabulary and one membership predicate shared by settings specifiers and + object fields**. The spec half declared them in `@objectstack/spec/shared`; + this package had been carrying a second copy of all three definitions since + `Specifier.valueDomain` shipped. The copies are deleted and the door now asks + `isValueDomainMember` — the call the record write path will make when the + engine half of the same ruling lands (PR #15316, still open). + + **The wire change**, measured on `PUT /api/settings/localization` with + `{"timezone": "Mars/Olympus"}`, base `a56baa2bd` vs this branch: + + | | before | after | + |:--|:--|:--| + | `fields[0].code` | `invalid_value` | `value_domain` | + | `fields[0].message` | `Default timezone must be a valid IANA time zone identifier (e.g. 'Europe/Zurich'). Received 'Mars/Olympus'.` | `Default timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")` | + + Everything else is byte-identical: HTTP 400, the envelope code + `SETTINGS_VALIDATION`, `field`, `label`, `constraint: { valueDomain: … }` and + the echoed `value`. A client that reads `constraint.valueDomain` — the + machine-readable half ADR-0114 asks it to read — is unaffected. A client that + branches on `code === 'invalid_value'` for a domain breach must move to + `value_domain`. + + Why the code moved: ADR-0114's rule is that the code is the **constraint's own + name**, the way `max_length` names the bound it breached. This branch took + `invalid_value` — the catalog's slot for "rejected for a reason no other + member names" — only while no member named a standard-domain breach. The + field-level card's spec half added one, so the slot no longer applies. The + message now renders the published catalog template + `value_domain_` in `en` — the catalog the record write path will render + from once PR #15316 lands, so the two doors under one ruling will describe one + domain in one set of words instead of each composing its own sentence. For an `encrypted` specifier the offending value is still never + echoed: the template's value placeholder takes the same mask the REST boundary + uses (`fields[0].value` stays absent, as before). + + **No value changes verdict.** The accept sets were measured, not assumed, on + the repo's Node 22 baseline (v22.22.2): + + - `iso_3166_alpha2` — the two 249-code lists diffed mechanically before either + was deleted: identical, including order; symmetric difference 0. + - `iso_4217_currency` — this one changes DEFINITION: a run-time + `Intl.supportedValuesOf('currency')` probe becomes the key set of the + checked-in CLDR snapshot `CURRENCY_FRACTION_DIGITS`. 162 codes vs 162, + symmetric difference 0 in both directions (`CHF` in both, `XYZ` in neither). + The behaviour that changes is that the verdict no longer varies with the + host's ICU build — the direction the shared module argues for. A door-level + test now re-measures it: every code the run-time probe admits must still be + admitted. + - `iana_time_zone` — the identical `Intl.DateTimeFormat` probe on both sides, + unmoved. + + A ratchet pin (`value-domains.shared-predicate.pin.test.ts`) reddens if any + non-test source in this package re-acquires a membership table, an `Intl` + enumeration probe, or a second caller of the predicate. + +### Patch Changes + +- ac9376a: An authorization-store OUTAGE now reaches the caller as the `503 SERVICE_UNAVAILABLE` it declares, on the storage download doors and on all four settings routes. + + `AuthzStoreUnavailableError` exists so an outage is distinguishable from a capability denial on the wire: it declares `status: 503` and `code: SERVICE_UNAVAILABLE`, and every producer in this family already re-raises it rather than laundering it into a verdict. Two consumers then flattened it back, each in its own way, so the declared envelope never arrived. + + **What changes on the wire.** Only on the path where the authorization store could not be READ — never when it legitimately returned no rows, and never for any other fault. + + | door | before | after | + | --- | --- | --- | + | `GET /api/v1/storage/files/:fileId/url` | `403 FILE_DOWNLOAD_DENIED` / `403 ATTACHMENT_DOWNLOAD_DENIED` | `503 SERVICE_UNAVAILABLE` | + | `GET /api/v1/storage/files/:fileId` | same 403, and no redirect | `503 SERVICE_UNAVAILABLE`, still no `Location` | + | `GET /api/settings` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + | `GET /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + | `PUT /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + | `POST /api/settings/:namespace/:actionId` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + + The storage row is the one worth reading twice: an outage was answered as a **permission denial**, byte-indistinguishable from a genuine refusal, which is the precise confusion the loud-outage discipline exists to prevent. The message now names the object whose read failed and says in words that this is not a permission denial. + + **What does NOT change.** The security posture is identical — these doors were already fail-CLOSED and still are, and the storage gate still mints no capability on an outage. Every other refusal keeps its status and code: `deny` is still `403`, `unauthenticated` still `401`, an unknown namespace still `404`, a forbidden settings context still `403`, and any fault that is not this branded outage still lands on the same untyped `500 INTERNAL_ERROR` tail it did before. The repair is scoped to the brand, not to "anything carrying a status". + + **Why `patch` and not `minor`.** No API is added, removed or renamed; no exported signature moves; no authorable key changes. This is a released package delivering an envelope it already declared — a bug fix, which this repo bumps `patch`. The change *is* observable, which is why the FROM → TO table above is in the changeset body rather than encoded in the bump: a version number carries no mapping, and this text is what an upgrading consumer greps in `CHANGELOG.md`. + + **If you branch on these statuses.** A client that treated the storage `403` as "this user may not have this file" was, during an outage, retrying or re-authenticating against a fault that no credential could fix; it should now treat `503` as retryable and leave the caller's permissions alone. A client that treated the settings `500` as an unrecoverable server error can now distinguish a transient store outage from a genuine internal fault. +- 2024eca: Fix: the settings REST doors now supply the effective tenancy posture to the shared authorization resolver, so both posture-conditional API-key refusals apply here — and the tenant this seam hands onward is a vetted one. + + Under a wall-enforcing posture (`isolated`), an API key stamped with an organization its owner has left is refused, as is a key carrying no organization at all. Previously neither guard ran at this door, because both are conditional on a posture the caller supplies and this seam supplied none — the key's tenant was its own stored `active_organization_id`, never checked against current membership. This gate does not merely admit the principal: it returns that tenant onward as the resolved settings tenant, so an unvetted claim became the verdict the read/write path acted on. A browser session whose stored active organization is no longer backed by a membership now has that claim dropped here too, rather than passed through. + + The posture is read from the kernel's `tenancy` service, so it is the posture in force rather than the one requested through `OS_TENANCY_POSTURE`. A deployment that registers no `tenancy` service is unchanged: there is no wall there, and no posture-conditional refusal applies. A `tenancy` service that is registered and fails to build is an outage rather than a quiet admission. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index 072020d7d3..e0b0225e62 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.3.0", + "version": "17.4.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 84e7dd7d4e..5741e70031 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,161 @@ # @objectstack/service-sms +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [d4c2cb1] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [2e6a2ea] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [cfb64a6] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [41cbc54] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [9f39897] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [142c01c] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/plugin-auth@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 6ffaea3c93..e42af86a0b 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.3.0", + "version": "17.4.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 c0b48485bb..b41b5821d3 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,247 @@ # @objectstack/service-storage +## 17.4.0 + +### Minor Changes + +- dd2184a: feat(storage): `mountStorageRoutes` — mount the storage routes on a host-owned HTTP surface, composed from a kernel that has no `http-server` service (#15169) + + `StorageServicePlugin` mounts `/api/v1/storage/*` itself, at `kernel:ready`, on the kernel's `http-server` service. A hosted per-environment tenant kernel registers no such service, so the storage service, `sys_file`, the lifecycle hooks and the reap guards were all present while every `/api/v1/storage/*` request answered 404 — an app with an attachment field could not upload. The settings service already had a working host bridge because `registerSettingsRoutes` and everything it needs are public; storage could not be bridged the same way because `registerStorageRoutes` needs three package-internal seams: the upload session resolver, the ADR-0104 D3 download authorization gate, and the tombstone holder predicate. + + **New export: `mountStorageRoutes(http, kernel, options?)`** (with `MountStorageRoutesOptions`, `StorageRouteKernel`, `StorageRoutesMountReport`). One entry point that takes the host's `IHttpServer`-shaped surface and the environment kernel, binds the three seams from that kernel's own `auth` service and data engine, and registers the full route table — the composition the plugin's own mount now calls too, so a host's storage door and the plugin's are one code path. The options carry wire knobs only (`basePath`, `presignedTtl`, `sessionTtl`, `downloadTtl`, `logger`): the three gate seams are not accepted in any form, so a consumer cannot substitute, omit or bypass the download gate, and the platform keeps exactly one definition of it. The return value reports which gates bound, as booleans. A kernel with no `storage` service throws naming the remedy; a kernel with no `auth` service or no data engine mounts with the matching gate off and warns — the plugin's existing bare-kernel behaviour, said out loud. + + Deliberately NOT published: `buildAuthSessionResolver`, `buildFileReadAuthorizer` and `findFileHolder` stay package-internal. The narrower surface serves the one consumer that exists (a host mounting the door) and is easier to walk back than three loose functions. + + Nothing existing changes shape or behaviour: `registerStorageRoutes` and `StorageRoutesOptions` are untouched, and `StorageServicePlugin` mounts exactly what it mounted before. + +### Patch Changes + +- ac9376a: An authorization-store OUTAGE now reaches the caller as the `503 SERVICE_UNAVAILABLE` it declares, on the storage download doors and on all four settings routes. + + `AuthzStoreUnavailableError` exists so an outage is distinguishable from a capability denial on the wire: it declares `status: 503` and `code: SERVICE_UNAVAILABLE`, and every producer in this family already re-raises it rather than laundering it into a verdict. Two consumers then flattened it back, each in its own way, so the declared envelope never arrived. + + **What changes on the wire.** Only on the path where the authorization store could not be READ — never when it legitimately returned no rows, and never for any other fault. + + | door | before | after | + | --- | --- | --- | + | `GET /api/v1/storage/files/:fileId/url` | `403 FILE_DOWNLOAD_DENIED` / `403 ATTACHMENT_DOWNLOAD_DENIED` | `503 SERVICE_UNAVAILABLE` | + | `GET /api/v1/storage/files/:fileId` | same 403, and no redirect | `503 SERVICE_UNAVAILABLE`, still no `Location` | + | `GET /api/settings` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + | `GET /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + | `PUT /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + | `POST /api/settings/:namespace/:actionId` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` | + + The storage row is the one worth reading twice: an outage was answered as a **permission denial**, byte-indistinguishable from a genuine refusal, which is the precise confusion the loud-outage discipline exists to prevent. The message now names the object whose read failed and says in words that this is not a permission denial. + + **What does NOT change.** The security posture is identical — these doors were already fail-CLOSED and still are, and the storage gate still mints no capability on an outage. Every other refusal keeps its status and code: `deny` is still `403`, `unauthenticated` still `401`, an unknown namespace still `404`, a forbidden settings context still `403`, and any fault that is not this branded outage still lands on the same untyped `500 INTERNAL_ERROR` tail it did before. The repair is scoped to the brand, not to "anything carrying a status". + + **Why `patch` and not `minor`.** No API is added, removed or renamed; no exported signature moves; no authorable key changes. This is a released package delivering an envelope it already declared — a bug fix, which this repo bumps `patch`. The change *is* observable, which is why the FROM → TO table above is in the changeset body rather than encoded in the bump: a version number carries no mapping, and this text is what an upgrading consumer greps in `CHANGELOG.md`. + + **If you branch on these statuses.** A client that treated the storage `403` as "this user may not have this file" was, during an outage, retrying or re-authenticating against a fault that no credential could fix; it should now treat `503` as retryable and leave the caller's permissions alone. A client that treated the settings `500` as an unrecoverable server error can now distinguish a transient store outage from a genuine internal fault. +- ebb5550: fix(service-storage): put the test layer in front of tsc, and repair what it was hiding (#15050) + + `packages/services/service-storage` had **no `typecheck` script at all** — its + scripts were `build` and `test` — so no tsc program anywhere read this + package's test layer, and its errors were carried instead as a 51-error DEBT + entry in `scripts/check-type-check-coverage.mjs`. Gives it the #14062 / + #14181 "checked test zone" shape: a sibling `tsconfig.test.json` (module + semantics only — `esnext` / `bundler` / `lib: ES2022` — matching how vitest + actually executes these files; strictness inherited and untouched) plus a + `tsconfig.scripts.json` for `scripts/i18n-extract.config.ts` (the ninth + instance of #11351, previously excluded from that ledger only because this + package had no `typecheck` script to hang it on), both named by a new + `typecheck` script. + + Measured before repair: 51 errors under BUILD semantics (`tsc --noEmit -p + tsconfig.json`, which already includes the tests — matching the DEBT entry's + recorded number exactly), 10 under the split. Unlike `service-cluster` + (#14181), this package's BUILD reading was *not* already clean, so both + programs needed genuine repair, not just the test-only split: 23 `TS2835` + (relative imports missing their `.js` extension, required under BUILD's + NodeNext resolution) were fixed by *adding* the extension — which resolves + correctly under both NodeNext and the split's bundler mode — and clearing + that also cleared all 15 `TS7006` "implicitly any" as a downstream cascade + from the same unresolved imports (the shape `@objectstack/core` reported at + 98 → 4). The remaining 3 `TS2550` (`Array.prototype.at` needing `lib` + es2022) are rewritten to indexed access rather than widening the shared + BUILD `tsconfig.json`. The 8 code-tier errors (`TS2339` × 4 — a test + helper's object-spread dropped its `Record` index + signature, fixed with an explicit return-shape annotation; `TS2347` × 4 — a + fake `ctx: any`'s `getService(...)` calls converted to `getService(...) + as T`, the pattern one call site in the same file had already adopted for + exactly this reason) are genuine test-file fixes. Both readings now agree at + 0/0 — the same result `service-cluster` reported, reached by a longer road. + + The package's DEBT entry (51 errors) is **deleted**, not lowered — the + graduation this ratchet's invariant requires. No `test-typecheck-debt.json` + is added: residue is 0, so none is owed (#5286, maintainer-only to open). + `check:type-source-resolution` went red from onboarding the two new + programs (the documented onboarding-limb case): a registry entry is added + rather than `paths`, measured both ways — `paths` takes this package's test + layer from 0 errors to 306, all in other packages' source. + + No runtime code changes: `src/**` excluding tests is byte-identical, so no + shipped behaviour moves. The `patch` level reflects the published + `package.json` gaining `typecheck` / `check:test-typecheck` scripts and a + `tsx` devDependency. +- b8c82de: The storage download door derives the tenancy posture before resolving the caller + + `buildFileReadAuthorizer` resolved every gated download with `resolveAuthzContext({ ql: engine, headers, getSession })` and supplied no `tenancyPosture`. Both posture-conditional API-key refusals are gated on the caller supplying one — `organization_required` and `organization_membership_ended` — so neither ran at this door. Its headers come from the real request, so `x-api-key` is accepted, and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim: the caller's own stored claim, never vetted against current membership. Under a wall-enforcing posture a key stamped with an organization its owner had left therefore authenticated for downloads and was judged by the ownership and record-reachability checks — checks evaluated for a principal the wall should have refused at the door. + + The posture is now read off the kernel's `tenancy` service, per download, and classified rather than swallowed: a service that was never registered stays quiet (`undefined` — the supported no-tenancy composition, unchanged behaviour), while one that was registered and failed to build raises `AuthzStoreUnavailableError` instead of degrading to "no posture". Under `isolated` and `group` an ex-member's stamped key is now refused and no download capability is minted; an organization-less key is refused under `isolated` and stays admitted under `group`, whose union scope makes it legitimate. Under `single` nothing changes. Patch rather than minor: no accept set widens, and a declared guard returns to enforced. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [159dbad] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/observability@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index a72054e07c..3020f00a8e 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.3.0", + "version": "17.4.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 841d45b307..4af4083cb5 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,3660 @@ # @objectstack/spec +## 17.4.0 + +### Minor Changes + +- fe0d9a4: `action.ai.requiresConfirmation` gets a real contract — a DECLARATION, not yet a live gate: the contract states that an AI-facing call on an action declaring the flag must carry an explicit confirmation and is refused without it, with a refusal that tells the caller how to retry. No door performs that refusal yet; setting the flag still stops nothing until the runtime half lands. + + The flag has always read as a safety gate and has only ever filled one field of the MCP `list_actions` summary. Two of the spec's own passages went further and told authors it "actually stops execution" through an HITL approval queue — a queue the open framework path does not have (the server-side queue is an ObjectOS layer over these same actions). This change defines the gate the flag was always claimed to be. It is additive and defines the contract only; the doors adopt it separately. + + - **The request member.** `AIActionConfirmation` (`contracts/ai-service.ts`) declares the confirmation as a closed boolean member, and `AI_ACTION_CONFIRMATION_MEMBER` fixes its one spelling so every AI-facing action door and every retrying client read the same constant. It rides at the top level of the action request — deliberately not inside `params`, which is strict by default (`enforceActionParams`, ADR-0104 D2) and would REJECT an undeclared `confirm` outright on any action that declares params, and deliberately not a transport header, which the action door — a plain function handed a request object — cannot carry and the tool schema an agent reads cannot show. + - **Which predicate gates the refusal.** A door refuses when, and only when, the action's author DECLARED `ai.requiresConfirmation: true` and the request does not carry the member as `true`. This is narrower than the predicate behind the `requiresConfirmation` field of a listing, which falls back to a destructiveness heuristic (`mode: 'delete'` / `variant: 'danger'`) when the author declared nothing: that field advises a client to ask, and an author who declared nothing has asked for nothing. Gating the refusal on the heuristic would start refusing calls that work today, on a guess the author never made. The member is accepted on every call and required only on the declared-gated ones, so a client that confirms whenever a listing says `requiresConfirmation: true` is always correct. + - **The refusal.** `ACTION_CONFIRMATION_REQUIRED`, registered in `ERROR_CODE_LEDGER` under `@objectstack/runtime`, answered 428 — the request is valid and merely incomplete, and the identical call with the member set succeeds. `error.details` is `ActionConfirmationRequiredDetails`: the action name, its object, and the exact member to set, so an agent builds the retry mechanically instead of re-parsing prose. It is not a re-spelling of the standard catalog's `PRECONDITION_REQUIRED`, which leaves a caller unable to tell a confirmation gate from a missing conditional header. + - **Two passages corrected.** The `tool.requiresConfirmation` retirement guidance and the ADR-0049 semantic migration entry no longer describe an approval queue. They state what the contract DECLARES — a gate, nothing parked, nothing held for an operator to find later — and say plainly that the door which performs the refusal has not landed, so the flag does not stop an unconfirmed call today. They also no longer tell an author to prove the gate by invoking the operation without the confirmation member: until that door ships, such a call is not refused, it RUNS. + + `list_actions` is unchanged and keeps reporting the flag exactly as it does today. +- ed7243d: feat(spec): `AGGREGATE_FIELD_TYPE_COMPATIBILITY` accepts `boolean` / `toggle` for `sum` / `avg` / `min` / `max` — ruling #11152 (booleans aggregate as numbers on every backend) stands over batch #59's blanket default (#16685) + + The aggregate × field-type table declared by `@objectstack/spec/data` gains the boolean class (`boolean`, `toggle`) on its four arithmetic / order rows. Two maintainer rulings collided on that class: decision batch #59 (2026-09-06) said "every other pair: refused" without ever naming booleans, while ruling #11152 (2026-08-28) pins that booleans aggregate as NUMBERS on every backend with no per-aggregate exception — `sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, enrolled on six backends by the spec's own `AGGREGATION_CASES`, and implemented by `driver-sql`'s Postgres cast (#11635). The director ruling of decision batch #80 (2026-09-08, #16685, maintainer verbatim 「其他同意」, option A) holds that the specific ruling stands over the blanket default: the four rows carry both boolean members, and nothing else moves — `AGGREGATION_CASES` and the driver cast are untouched. + + | Aggregate | Accepted field types | + |---|---| + | `count`, `count_distinct` | every `FieldType` | + | `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary`, **`boolean`, `toggle`** | + | `avg` | the numeric class (`percent` included), **`boolean`, `toggle`** | + | `min`, `max` | the numeric class, `date`, `datetime`, `time`, **`boolean`, `toggle`** | + | every other pair | refused | + + Why it matters: `avg(flag)` is the win-rate / SLA-violation-rate shape (#11065) — the reason the conformance table exists. A compatibility table refusing it would refuse a pair every backend is REQUIRED to answer, and the two consumer legs that execute this table (the compile-time refusal in the dataset compiler, #16099, and the authoring-time lint rule) would have turned a supported measure into an authoring error. + + **Additive, `minor`.** No export is added, removed or renamed — the exported declarations are unchanged (the table's declared type is the same; only its value gains members), the new `BOOLEAN_AGGREGATE_FIELD_TYPES` constant is module-private — absent from `dist/*.d.ts` and from the bundles' export lists — and `api-surface/` and `export-origins/` are untouched — and the accept set only WIDENS: every pair accepted before is still accepted. It rides the same release as the table's own changeset, so the version outcome is the same either way; `minor` is declared because widening a published accept set is the same class of change the table's introduction was (decision batch #35 puts additive widening at `minor`), and because the two consumer legs are the change's real audience: a measure over a boolean field compiles and lints clean. +- 6ba0db4: feat(spec): declare the aggregate × field-type compatibility matrix a dataset measure is judged against — `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` (#16353, spec half of #16099) + + A dataset measure pairs an `aggregate` with a `field`, and nothing between author and driver correlated the two: `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend, where one SQL family averages the column's storage form and another rejects the call — one metadata document, two answers. Which pairs are accepted is a contract, so it is now declared once in `@objectstack/spec/data`: + + | Aggregate | Accepted field types | + |---|---| + | `count`, `count_distinct` | every `FieldType` | + | `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary` — the numeric class EXCEPT `percent` (a rate does not add; `isIncoherentAggregate` already says so) | + | `avg` | the numeric class, `percent` included | + | `min`, `max` | the numeric class plus `date`, `datetime`, `time` — both return a value of the field's own type | + | every other pair | refused | + + The ruling (director, decision batch #59, 2026-09-06) named its buckets by category; the table resolves them against the real `FieldType` membership through the `field-value.zod` semantic classes: "numeric" is `NUMERIC_VALUE_TYPES` (`integer` is a driver-internal column alias, not a `FieldType` — the integer-valued authorable members are `rating` / `slider` / `progress`); "temporal" is the three temporal classes, `time` included because its stored form is a dialect question exactly like `date` / `datetime` (native TIME on Postgres and MySQL, canonical `HH:MM:SS[.fff]` TEXT on SQLite), the canonical form orders chronologically on every dialect, and `AnalyticsResult.fields[].type` already describes `min` / `max` over it as temporal (#15768). `formula` is refused for arithmetic aggregates whatever its declared `returnType`: it is virtual in SQL storage, no column exists to aggregate. + + **Booleans** (`boolean`, `toggle`) are NOT in the "every other pair" default: maintainer ruling #11152 has every backend answer them as numbers (`sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, pinned in the spec's `AGGREGATION_CASES`; `driver-sql` casts the aggregand on Postgres to make it hold), and the director ruling of decision batch #80 (2026-09-08, #16685) holds that specific ruling over batch #59's blanket default — the four arithmetic / order rows carry both boolean members (see that changeset). One refused row overrides an existing opinion and is recorded as such, not presented as agreement: **the string classes** are refused for `min` / `max` here, while `service-analytics` (#15768) already types `min` / `max` over them as a supported `'string'` result; the refusal is defensible (string order is collation-dependent) but it overrides that opinion. + + **The narrowing, stated plainly.** Every pair outside the table — `avg` × `datetime`, `sum` × `text`, `min` × `json`, `sum` × `percent`, and so on — is an authoring shape `DatasetMeasureSchema` accepts today and will be REFUSED once the two consumer legs land: the compile-time refusal in the dataset compiler (#16099) and the authoring-time lint rule (its devx sub-card). A measure whose pair is refused is fixed by changing the aggregate to one the field's type supports (`min` / `max` for a temporal field; `avg` for a `percent`; `count` for anything), never by widening the table. + + **Not breaking in this release, `minor` on purpose.** This changeset ships a table and a predicate that nothing yet enforces: `DatasetMeasureSchema` accepts byte-for-byte what it accepted before, no export is removed or narrowed, and no runtime path reads the table yet. It is an additive widening of the published surface — two new exports in `dist/*.d.ts` — which the maintainer ruling of 2026-09-04 (decision batch #35) puts at `minor`. The refusal itself arrives with the consumer legs, whose changesets carry the breaking declaration, its migration prescription and the ADR-0087 disposition; this one names the narrowing so an upgrading author can read the contract before it is executed. + + `isIncoherentAggregate` is unchanged and stays the semantic opinion beside this table. The two diverge on exactly one pair: `count_distinct` × `percent` is flagged there and accepted here (the ruling reads `count_distinct` as "any type"). That divergence is pinned in the table's test and reported on #16353 rather than resolved silently. +- 625b0c3: `PLATFORM_TOOLS_BY_PACKAGE['service-ai-studio']` lists the five tools the cloud AI runtime registers that it had been omitting: `get_authoring_rules`, `load_tools`, `open_record`, `test_flow` and `toggle_flow`. Added in the list's existing alphabetical order; nothing else in the registry moves. + + The omission was not cosmetic. `PLATFORM_PROVIDED_TOOL_NAMES` is the load-bearing half of `skill.tools[]` reference integrity under ADR-0109 — the default third-party authoring path declares no tool records at all, so a `skill.tools[]` entry resolves against this registry or against the materialised `action_` family and against nothing else. While these five were absent, a skill naming any of them was reported by `validate` / `lint` as a **fictional** tool reference (`ai-skill-tool-unresolved`), which is precisely the failure the registry was created to end. Five previously-refused references are now accepted; a name registered by nobody is still refused. + + The module's own maintenance contract already said why an omission is worse than no registry at all — "an out-of-date registry is worse than no registry, because consumers now trust it" — and a second consumer had already paid for it: `@objectstack/mcp` gives a listed name `openWorldHint: false`, and the Studio's tool-step labels read the same set. + + This is the data half only. Making the owning package's conformance test **derive** the union from what `plugin.ts` actually registers, instead of restating it, is tracked separately in the cloud repository; re-copying the list correctly resets the clock rather than stopping it. +- 233222e: feat(spec)!: `timeDimensions[].dateRange`'s string arm closes to the date-range preset vocabulary; any other string is refused with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16041) + + + + **BREAKING** — an accept-set narrowing on a published analytics contract. + `AnalyticsQuerySchema.timeDimensions[].dateRange` (and with it the + `POST /analytics/query` / `/analytics/sql` bodies, `AnalyticsQueryRequestSchema`, + and the `AnalyticsQuery` type every driver and `AnalyticsService.query` caller is + typed against) used to accept ANY string. It now accepts exactly the thirteen + dashboard date-range preset names, derived from `data/date-range-presets.ts` + (`z.enum(DATE_RANGE_PRESETS)` — the vocabulary's single source of truth since + #4614, so the two cannot drift), or the unchanged `[start, end]` array arm. + Shipped as `minor` under the repo's launch-window convention for breaking + changes; the hand-migration prescription is registered under protocol major 18. + Maintainer ruling on #16041 (2026-09-06, decision batch #57, option A — + contract first, 「同意」): 「本项目以协议为基准。所以开发应该对其协议,协议有问题应该立卡修改协议」. + + ## What was wrong + + The arm was a bare `z.string()` whose only documented example — `"Last 7 days"`, + in the schema's own comment — was a value no driver could parse. `driver-memory` + recognised exactly `today` and a case-sensitive `last N ` and fell every + other string through to a `[range, range]` pseudo-window that (measured through + mingo, 2026-09-05) matched **every `Date`-typed row**, 2099 included, because a + `Date` compares above a `String` under BSON cross-type ordering. The SQL + strategies read the same bare string as a single ISO day. A dashboard asking for + one week silently got all of history on one backend and one day on the other, + at HTTP 200 on both. + + ## What it does now + + - The string arm is `AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS)` + (`today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, + `this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_7_days`, + `last_30_days`, `last_90_days`); the schema example is corrected to + `'last_7_days'`. + - Any other value raises ONE prescriptive issue at `timeDimensions.N.dateRange` + (`analyticsDateRangeRefusalMessage`: the value, the vocabulary, the array + spelling for an explicit window). `@objectstack/spec/data` exports the + structural predicate `isAnalyticsDateRangeRefusalIssue` for doors. + - `POST /analytics/query` and `/analytics/sql` answer the ADR-0112 envelope + **`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`** — a new `ERROR_CODE_LEDGER` member + registered under `@objectstack/runtime` — and the analytics service is never + reached. A body wrong in more places than the `dateRange` stays the generic + `400 VALIDATION_FAILED` + `details.fields[]`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'Last 30 days'` / `'last 30 days'` | `dateRange: 'last_30_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: 'This week'` | `dateRange: 'this_week'` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + Measured in this repository at the ruling: three authored `'Last 7 days'`, all + in `packages/spec` tests (re-spelled here), and no published dashboard authors + the string arm at all — the shipped console lowers presets to the array arm + before querying. The drivers' own refusal of a non-conforming value that reaches + them in-process (past the schema) is the sibling card #16322, blocked by this + one; the fenced `service-analytics` fixture that authors the retired bare-ISO + spelling is that card's to re-triage. +- e9fcd6b: feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478) + + + + **BREAKING** — twelve published `api/` duration keys are renamed and tombstoned. + Shipped as `minor` under the repo's launch-window convention for breaking + changes; the hand-migration prescriptions are registered under protocol major + 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions; this card clears the `api/` directory against it. Measured with the + gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree + count falls **48 → 36**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | + | `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds | + | `DeviceRequestResponse.interval` | `intervalSeconds` | seconds | + | `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds | + | `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds | + | `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | + | `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds | + | `RouteDefinition.timeout` | `timeoutMs` | milliseconds | + | `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds | + | `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds | + | `WebSocketConfig.timeout` | `timeoutMs` | milliseconds | + | `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds | + + **Every value is unchanged** — only key names move. Every old spelling is a + `retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type + `never`) and fails the parse with the rename prescription rather than a bare + unrecognized-key error. + + ## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch + + Ruling B put this key explicitly in scope with its own BREAKING note: the + runtime-emitted measurements are read by humans and agents even though nobody + authors them. A consumer meets two retry-after values on one 429 — this + ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After` + header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date. + Spelled identically they read as one value in two places. + + **The HTTP `Retry-After` response header is a separate, unchanged surface.** Its + name is fixed outside this repo and nothing here touches it. Do not "fix" the + header to match the envelope, and do not read a surviving `retry-after` in + transport code as leftover work. + + ## Dispositions — one D2 conversion, five semantic entries + + Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only + one of the twelve that gets an ADR-0087 D2 conversion** + (`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack + collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered + metadata kind stored as a row, so the conversion chain has a seam that sees it. + `os migrate meta --from 17` lists the mechanical edits. + + The other eleven are wire payloads and construction arguments — a device-flow + response body, an error envelope, REST-plugin route registration, a batch-loader + config, a router registration, WebSocket client/server configuration. None is + ever a stack collection member or a `sys_metadata` row, so no conversion seam + runs on them and each carries a **semantic** entry instead: this is the + disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these + very shapes, and what ruling B prescribes for a runtime-emitted key. + + ## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror + + Attributed to RFC 8628 by the campaign card; the attribution fails against the + schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as + a set — `code` is not `device_code`, `verificationUrl` is not + `verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a + different type, an ISO-8601 instant where the RFC carries a relative lifetime). + A schema that already renames every RFC field it carries into house style cannot + claim the standard fixes the one name it left bare. Renamed rather than marked + deliberately: a wrongly marked key is exempted permanently and silently, while a + wrongly renamed one is visible. + + ## Readers moved in the same PR, at the same magnitude + + `@objectstack/runtime`'s policy chain (`computeCacheControl` now reads + `endpoint.cacheTtlSeconds`), the publish gate's issue path + (`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase + example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead` + tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is + retargeted onto the live key — an alias must point at a key the schema really + accepts, and `cacheTtl` now accepts nothing. +- 8f404a5: feat(spec)!: `plugins` / `devPlugins` are artifact envelope keys — excluded from the assembled package body and refused inside `packages[]` (#15219) + + + + **BREAKING** accept-set narrowing on `AssembledPackageBodySchema` — the body + under `packages[i].manifest` of a release artifact (ADR-0130 D4): a body that + carries `plugins` or `devPlugins` is now **refused** at the manifest's strict + close (`unrecognized_keys`, naming the key), where it used to parse. Shipped as + `minor` under the repo's launch-window convention for breaking changes; the + hand-migration prescription is registered under protocol major 18. Maintainer + ruling 2026-09-04 on #15219 (director decision batch #32, verbatim 「同意」): + option A for both keys. + + `plugins` and `devPlugins` were members of the assembled-body key set by the + same derivation every other collection uses (`COMPOSE_KEY_DISPOSITIONS` gives + both `concat`). They are the only members whose values are **runtime assembly + instructions** rather than serialisable metadata: `plugins` holds what a host + hands to `kernel.use()` — live plugin instances, manifests or package names — + and `devPlugins` is the `os dev` load list. Inside an artifact a package body + is inert JSON, so a plugin under `packages[i].manifest` could never be + constructed by a loader; every reader reads the top level. The classification + is corrected rather than special-cased: an artifact carries metadata, a host + assembles plugins. + + **What changes** (`packages/spec/src/stack.zod.ts`): + + - `plugins` / `devPlugins` are **envelope keys** — top level only, never inside + `packages[]`. `ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS` (`packages`, `plugins`, + `devPlugins`) is declared once and feeds both the `AssembledPackageBodyKey` + derivation and `assembledPackageBodyShape()`. + - Both keys stay `concat`: a live stack still concatenates its plugins to the + top level under `composeStacks`, and `manifest: 'preserve'` no longer folds + them into any package body. + - The two declarations on the stack schema are unchanged. + + **What does NOT change:** `os serve` / `os migrate` / `os dev` keep reading the + top level (now correct by construction); no CLI, core or runtime code moves. + + ## FROM → TO + + ```ts + // before — a package body inside an artifact could carry plugins nobody could load + { packages: [{ manifest: { id: 'com.example.crm', /* … */ plugins: [{ name: 'plugin.x' }] } }] } + + // after — plugins live on the artifact envelope only; the body above is refused: + // packages.0.manifest: unrecognized_keys ['plugins'] + { plugins: [new CrmPlugin()], packages: [{ manifest: { id: 'com.example.crm', /* … */ } }] } + ``` + + **Migration.** Declare `plugins` / `devPlugins` at the stack top level and + delete them from every `packages[i].manifest`. An existing multi-package + artifact that carries `packages[i].manifest.plugins` (if `os build` ever wrote + one — not directly measured) is refused on load after this change and must be + rebuilt from source; a hand-written `packages[]` entry drops the keys. Stacks + that only ever declared the two keys at the top level parse byte-identically. +- 68437d4: The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded. + + `POST /api/v1/automation/:name/runs/:runId/resume` answers a run that consumed its pause and then failed with `400 FLOW_FAILED`, and until now its `error.details` carried the run's two artefacts only (`errorMessage`, `summary`). The engine's own verdict was dropped at the door: `AutomationResult.status: 'stranded'` — a run that is terminally failed *but* repairable by an explicit operator verb, because the pause a durable decision was waiting on is gone with the failure — reached the wire as the same `400` a plain terminal failure does, so an HTTP-only caller could not tell "beyond reach" from "repair waiting". + + - **`@objectstack/spec`** declares `ResumeFailureDetailsSchema` (`@objectstack/spec/api`): `{ runId, status?: 'failed' | 'stranded', repairable }` — the machine-readable shape of a resume failure told to the caller, declared once so every carrier of the family ruling spells the same members. + - **`@objectstack/runtime`**: the resume door's `400 FLOW_FAILED` details now carry that structure beside `errorMessage` / `summary`. `runId` is the run the resume was addressed to; `status` is the engine's own stamp, forwarded verbatim when it set one and never synthesised (the subflow-child-failed exit stamps none today); `repairable` is `status === 'stranded'` and is **always present on this arm** — present-and-false on a plain terminal failure, deliberately, so an absent member reads as an older server rather than as "not repairable". The code stays `FLOW_FAILED` (no `FLOW_STRANDED` sibling is minted), so a client that treats it as terminal keeps working and one that wants to offer a repair branches on `details.repairable`, never on the message text. The trigger door and `/actions` are unchanged: they never resume, so the member is absent there and absent means "not a resume". + - **`@objectstack/client`**: `automation.resume` documents the new members. +- abb140c: feat(spec): declare the two operator run-lifecycle verbs on `IAutomationService` — `cancelRun` and `restoreConsumedSuspension` (#16495, the contract half of #13953) + + `IAutomationService` (`contracts/automation-service.ts`) gains two OPTIONAL + members, typed as the engine already implements them rather than as the + ruling's `verb(runId)` shorthand, so a door calling through the contract can + say who asked and why: + + - `cancelRun?(runId: string, reason?: string): Promise` — end a + suspended run (ADR-0044's run-cancel primitive): `true` only when this call + consumed a suspension, `false` when none exists under the id (idempotent + success — and the answer an unreadable store lands on too, which the + implementation reports at `error`). + - `restoreConsumedSuspension?(runId: string, options?: { requestedBy?: string; reason?: string })` + answering `{ restored: boolean; runId: string; refusal?: string; reason: string }` + — the operator exit from a run a resume left terminally unresumable + (`AutomationResult.status: 'stranded'`, #13909 / #13937): puts the consumed + suspension back verbatim, replays no signal, undoes nothing, never resumes, + never throws. + + Both docblocks carry the #13953 ruling's persistent-face statement (maintainer + 2026-09-05, decision batch #42): "listing and acting go through + `sys_automation_run` (the persistent face), never engine memory" — and its + permission posture: platform-operator verbs gated on the existing + `platform_admin` position, no new permission type, no per-run ownership. + + Additive. Both members are optional, so every existing implementation — + including the `{ execute, listFlows }` minimum the contract's own test pins — + still conforms, and the one non-test implementor (`AutomationEngine` in + `@objectstack/service-automation`) already satisfies both under `implements`. + The result of `restoreConsumedSuspension` is a deliberately NARROWER + structural shape than the engine's `SuspensionRestoreResult`: the engine's + eight-member refusal vocabulary stays with the engine, so `refusal` is typed + `string` on the contract (route (i); a second consumer that needs the + vocabulary is a spec card). No REST route, CLI command, lister or engine + behaviour moves in this change — #13953's services half owns the doors. A + service that does not declare a verb has no operator door for it, and a door + must probe for presence and refuse fail-closed when it is absent. +- 8333a6c: A blueprint nav entry can say WHICH view it opens: `viewName` is added to `BlueprintNavItemSchema` and, in lockstep, to the strict mirror's `StrictNavItem` (required-but-nullable, per the strict convention). + + Without it the shape could only say which OBJECT an entry opens, so a model that had just designed a kanban and wanted it in the menu had one move left: emit a SECOND entry at the same `target` and carry the intent in `label`/`icon` alone. Both entries then opened the object's default view, and the consumer derived both ids from the target, so they collided — the user clicked 「工单看板」 and got the list, with nothing to see anywhere (the target object really exists, so a dangling-target lint has nothing to say). The runtime nav item could always express this — `ObjectNavItemSchema.viewName` is "Default list view to open" — so the gap was the blueprint's alone, and the model's duplicate entry was the reasonable move under the expressiveness it was given. + + `viewName` is deliberately NOT `.regex(SNAKE_CASE)` on either side, unlike `target`. A view answers to two interchangeable spellings — the bare key a blueprint's `views[].name` carries and the qualified `.` a staged view record's `name` carries — and consumers normalize between them. Constraining the leaf would make one spelling legal to GENERATE and illegal to APPLY, the failure mode that once refused an already-approved blueprint wholesale. + + The key-parity pin between the strict mirror and the lenient schema is widened a level further out — fields → objects → NAV ITEMS — so the next nav-level divergence fails a test rather than shipping as "the lenient side accepts a key no proposal can contain". +- 3e3ecb0: The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects. + + `SolutionBlueprintSchema` (what `apply_blueprint` validates against) and `SolutionBlueprintStrictSchema` (the OpenAI-strict structured-output contract the design model generates against) are two declarations of one shape. Their KEYS were pinned by an existing parity test; their VALUES had never been. Every identifier in the lenient schema carried `.regex(/^[a-z_][a-z0-9_]*$/)` and not one identifier in the strict mirror carried it — 20 leaves apart, measured. + + The consequence was a build whose approval did nothing. Asked for a CRM, the design model emitted a `company_size` select whose option values came straight off the labels — `1_49` for 「1-49人」. Generating that was legal. Applying it was not: on the turn the user clicked 「确认,开始搭建」 the deterministic confirm replay handed that exact blueprint to `apply_blueprint`, which refused it wholesale (`objects.0.fields.2.options.0.value: Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/`) and staged nothing. The app appeared only because the model noticed the error card and retried with a repaired blueprint the user had never seen. + + Every identifier leaf in the strict mirror now carries the same `SNAKE_CASE` constraint the lenient schema enforces — object / field / view / dashboard / widget / app / nav names, `reference`, `nameField`, `columns`, `groupBy`, `measure`, roll-up `object` / `field` / `relationshipField`, condition `field`, and select option `value`. The constraint is emitted into the JSON Schema the model is given (`pattern`), so an out-of-pattern identifier is refused at generation instead of after approval. Option `value` additionally spells out the case that produced the incident: it may never start with a digit, so 「1-49人」 is authored as `size_1_49` — the `label` keeps the human wording untouched, and only the stored value is an identifier. + + A new `strict mirror ↔ lenient schema — VALUE parity` test walks both schemas leaf by leaf and fails on any future divergence, the value-side twin of the key-parity gate that already guards this pair. + + Refs cloud#1967. +- e08892d: `cloneData` reports `droppedFields` like every other create face: `CloneDataResponseSchema` (`@objectstack/spec/api`) gains an optional `droppedFields` member of the same shape as `CreateDataResponseSchema`'s, and the `POST /data/:object/:id/clone` 201 body carries it whenever the engine stripped a static `readonly` column from the clone. + + A clone IS a create, and it is the one create shape that can carry a read-only column without the caller typing it: the source row is copied whole (`approval_status: 'approved'` included), `overrides` are applied on top, and the copy is inserted. Since the create-side strip moved into `engine.insert` that column has been stripped and logged at `warn` — but the 201 body said nothing, so a caller that cloned an approved record and read `record.approval_status: 'draft'` back had no field in the response telling it why, while `createData`, `createManyData`, `insertManyData` and every `batchData` row that created already answered on the wire. Maintainer ruling 2026-09-08 (option 1 on #15703): report it, the same way. + + - **`@objectstack/spec`** — `CloneDataResponseSchema.droppedFields`: `DroppedFieldsEvent[]`, optional, omit-when-empty — present ONLY when ≥1 field was dropped, and the clone still succeeded without them (status unchanged). The schema is declared AS PRODUCED, so the member and the producer land in one change. Additive: a client that reads only `object` / `id` / `sourceId` / `record` sees no difference. + - **`@objectstack/metadata-protocol`** — `cloneData` passes the engine the same `onFieldsDropped` listener `createData` wires and spreads the collected events onto its return as `droppedFields`. The strip itself is unchanged and still the engine's (`isSystem`-gated, `defaultValue` re-derived); what is new is that a copied-in or overridden readonly key is now named in the body instead of only in the server log. + - **`@objectstack/client`** — `CloneDataResult` (the declared mirror of `CloneDataResponseSchema`, the return type of `client.data.clone`) gains the same optional `droppedFields?: DroppedFieldsEvent[]`, so a TypeScript caller reads the member without a cast; its docblock no longer states that the clone producer emits no write-observability event. + + Body only, deliberately: the clone route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header (the single-record `POST /data/:object` and `PATCH /data/:object/:id` mounts do); the schema's `.describe()` says so rather than promising a header the route does not send. +- c463d03: feat(spec)!: retire the incident-response, training and change-management families whole — nineteen defs and every name they exported — and the `ESignatureConfig` deadline pair (#15513, #14477, ADR-0049) + + + + **BREAKING** — published exported symbols leave `@objectstack/spec/system`, and + two authorable keys leave `data/ESignatureConfig` — landing after the v17.0.0 + cut (the lockstep launch-window convention ships it as `minor`; the + registrations live under protocol major 18, where `os migrate meta` users will + look). Maintainer ruling 2026-09-05 on #15513 (decision batch #40, ruled A: + retire the three compliance-shaped families whole via `RETIRED_DEFS_BY_MAJOR`, + the `integration/ErrorMappingConfig` precedent; none of the three is + roadmapped) and, in the same stroke, the answer the 2026-09-02 ruling on #14477 + had held open (no roadmapped e-signature consumer ⇒ the pair retires with the + rest). ADR-0049 enforce-or-remove decides it — declared-but-unenforced surface + with zero measured readers comes off. + + ## What leaves the public surface — the three families, whole + + Nineteen defs (the card counted fifteen; the manifest counts nineteen — the + ruling names the families, the number is the files' reading), forty-five + exported names, roughly a hundred declared keys, and the generated reference + pages `references/system/incident-response`, `training` and + `change-management`: + + | file | defs (`json-schema.manifest/system.json` spelling) | + |:--|:--| + | `system/incident-response.zod.ts` | `system/Incident`, `system/IncidentCategory`, `system/IncidentNotificationMatrix`, `system/IncidentNotificationRule`, `system/IncidentResponsePhase`, `system/IncidentResponsePolicy`, `system/IncidentSeverity`, `system/IncidentStatus` | + | `system/training.zod.ts` | `system/TrainingCategory`, `system/TrainingCompletionStatus`, `system/TrainingCourse`, `system/TrainingPlan`, `system/TrainingRecord` | + | `system/change-management.zod.ts` | `system/ChangeImpact`, `system/ChangePriority`, `system/ChangeRequest`, `system/ChangeStatus`, `system/ChangeType`, `system/RollbackPlan` | + + With them: every `*Schema` const, every `z.input` alias (`Incident`, + `IncidentResponsePolicy`, `TrainingCourse`, `ChangeRequest`, …) and the six + `*Parsed` aliases (`IncidentNotificationRuleParsed`, + `IncidentNotificationMatrixParsed`, `IncidentResponsePolicyParsed`, + `TrainingCourseParsed`, `TrainingPlanParsed`, `ChangeRequestParsed`). + + **Why.** The schemas were exported from `@objectstack/spec/system`, mounted by + no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 + liveness ledgers, and **read by nothing**: the reader census over every package + outside `packages/spec` (tests and changelogs excluded), over `examples/**` and + `skills/**`, and over objectui at the pinned sha (`a472b07`) returned zero hits + for every one of the forty-five names, with a lit control on the same pattern + (`ObjectSchema` / `FieldSchema`: 336, 200 and 342 hits per leg). Several keys + were boolean capability claims of exactly the shape ADR-0049 names — + `IncidentNotificationRule.notifyRegulators`, + `IncidentResponsePolicy.requirePostIncidentReview`, `TrainingCourse.mandatory`, + `TrainingPlan.trackCompletion` / `sendReminders`, + `ChangeRequest.approval.required`, + `ChangeRequest.securityImpact.requiresSecurityApproval` — so an author writing + `notifyRegulators: true` held a compliance promise the platform never kept, + with no error and no feedback, and the reference docs advertised a compliance + subsystem that does not exist. Tagging the families + `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is + a human-only signal, and an AI generating from the schema still writes the key + and believes it. + + **What happened to the fourteen #14477 deadline-key tombstones** (PR #15514, + merged 2026-09-04): they leave with their defs' source. Their fourteen + `RETIRED_KEYS_BY_MAJOR[18]` entries and three D3 entries stay as history — gate + (b2) of `build-schemas.ts` accepts an entry naming a key the build no longer + emits, and the 17→18 upgrade guide still owes the reader those prescriptions. + `deadline-keys-retirement.test.ts`, whose every pin needed the schemas to exist, + is replaced by `compliance-families-retirement.test.ts`. + + ## What is refused — the `ESignatureConfig` pair + + Authoring `expirationDays` or `reminderDays` on an `ESignatureConfig`, with any + value, on the base schema and through `Document.eSignature`. The schema is not + `.strict()`, so each key is a `retiredKey()` tombstone rather than a bare + deletion (a deletion would have stripped it in silence): authoring it is a `tsc` + error (`never`) and a parse error carrying the prescription (`invalid_type` at + the path of the key). Both carried defaults (30 days, 7 days) that were + materialized into every parsed configuration without ever being consulted; + parsed configurations no longer carry them. `provider`, `enabled` and `signers` + stay, byte-identical. Census for the pair: zero hits for `expirationDays`, + `reminderDays`, `eSignature` and the `ESignatureConfig` names on all three legs, + control lit inside `packages/spec` (`document.zod.ts` 9, `document.test.ts` 24). + + **Unmeasured, verbatim:** `cloud` and real customer configurations are + UNMEASURED for both the families and the pair — this census covers this repo + and objectui at the pin. + + ## FROM → TO + + ```ts + // before — imported and parsed green; no engine ever read a single key + import { IncidentResponsePolicySchema, type IncidentResponsePolicy } from '@objectstack/spec/system'; + const policy: IncidentResponsePolicy = { + notificationMatrix: { rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], notifyRegulators: true }] }, + defaultResponseTeam: 'security_team', + requirePostIncidentReview: true, + }; + IncidentResponsePolicySchema.parse(policy); + + const signing: ESignatureConfig = { + provider: 'docusign', + signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], + expirationDays: 30, + reminderDays: 7, + }; + + // after — the import is TS2305 and there is no replacement to point at, because + // no incident-response, training-management or change-management engine exists. + // A compliance record the organisation keeps is ordinary object data, declared + // as an object with its own fields and enforced by the object engine; an + // approval that must actually gate something is a flow (ADR-0018) with an + // approval node. + // + // The e-signature pair: delete the keys. `ESignatureConfig` itself stays. + const signing: ESignatureConfig = { + provider: 'docusign', + signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], + }; + ``` + + One-line fix: delete the import (families) or the key (pair) wherever it is + authored. There is no `os migrate meta` edit list — none of the schemas is a + stack collection member and `document` is no metadata type, so the conversion + chain has no seam to walk (the `MetadataPluginConfig.additionalTypes` + precedent); the tombstone prescriptions, the `tsc` refusals and the protocol-18 + upgrade guide are the channels. + + The retirement kit: + + - the three schema files and their tests deleted whole; the survivor notes in + `packages/spec/src/system/index.ts` record what each module declared and why + nothing ever read it + - ADR-0087 registration: nineteen `RETIRED_DEFS_BY_MAJOR[18]` entries + (`entries/retired-defs/18.system__*.ts`) and three D3 semantic entries, one + per family; for the pair, `data/ESignatureConfig:expirationDays` and + `data/ESignatureConfig:reminderDays` in `RETIRED_KEYS_BY_MAJOR[18]` plus the + D3 entry `esignature-config-deadline-keys-retired`; the step-18 `rationale` + extended + - no liveness-ledger row: none of the families and neither `document` nor + `ESignatureConfig` is an enrolled ledger type, so there is no row to keep or + drop + - pin tests: `compliance-families-retirement.test.ts` (zero holders of the + forty-five names on every public entry via `export-origins/`, the deletion + probe, the in-package importer walk, the runtime namespace, the shards' + absence, the ADR-0087 registration, the #15514 history kept, and a + tree-scoped absence leg whose walk radius is DECLARED in + `scripts/cross-package-test-inputs.mjs` / `turbo.json` — the playbook rule + #15566 added after PR #15514); `esignature-deadline-keys-retirement.test.ts` + (refusal pins asserting issue path, code and prescription on the base schema + and through `Document.eSignature`; the tsc `never` channel; no-materialize + pins for the two former defaults; the ADR-0087 registration); the thirteen + isomorphism pins the three modules held leave `type-alias-convention.pin.test.ts` + - generated baselines and docs follow the schema: `json-schema.manifest/` + loses nineteen keys (the manifest-deletion gate adjudicates whole-def + removals against the merge base), `api-surface/`, `declaration-map/`, + `export-origins/`, `authorable-surface/` and `authorable-defaults/` lose the + families' rows, `authorable-surface/data.json` gains two `[RETIRED]` rows and + `authorable-defaults/data.json` loses two, the three system reference pages + are removed and `references/system/index.mdx`, `references/index.mdx` and + `references/data/document.mdx` regenerated, `spec-changes.json` and the + upgrade guide carry the four new registrations at the 18 cut + - hand-written docs: the `Change Management` row leaves + `getting-started/quick-reference.mdx` + - zero authored occurrences in this repo's examples, skills and hand-written + docs beyond that row, and zero hits in objectui at `a472b07`, so no sibling + change and no pin bump ride along +- 64bd6a3: feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses object pairs whose object-level collections cannot be merged (#14848) + + + + **BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })` + — shipped as `minor` under the repo's launch-window convention for breaking + changes. Maintainer ruling 2026-09-04 on #14848 (director decision batch #38 + item 5, verbatim 「同意」): option 4, `'merge'` **refuses** what it cannot merge + instead of dropping it. + + **What changed.** `'merge'` was implemented as + `{ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }`: + `fields` was the only key merged, and every other key the later object carried + — `actions`, `indexes`, `listViews`, `validations`, … — replaced the earlier + package's value wholesale, with nothing at compose, build or boot saying so. + Two packages each embedding an action on one shared object composed to the + later package's array alone; the earlier package's action was gone. + + Now, when both objects declare an object-level **collection** other than + `fields` with different values, `composeStacks` throws — the refusal shape + `'error'` uses — naming the object, the colliding collection and both stacks + by manifest id: + + ``` + composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1). + objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged: the later declaration would replace the earlier one wholesale, silently dropping every entry 'com.example.a' (stack #0) wrote. + Fix: declare 'actions' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack. + ``` + + The refusal set is **derived from `ObjectSchema`'s shape** — every key whose + declared type is an array or a record (through optional/default wrappers and + into a union's members), except `fields` — not hand-listed, so a collection key + added to the object schema joins the refusal without an edit to the composer. + Today that set is `actions`, `activityMilestones`, `fieldGroups`, + `highlightFields`, `indexes`, `listViews`, `requiredPermissions`, + `searchableFields`, `validations`. + + **What did not change.** + + - `fields` keeps its documented shallow merge (later fields win, earlier + fields kept). + - **Identical** declarations on both sides pass through and are carried once + — the same reading `composeStacks` already gives identical top-level values + — so two built stacks that each bind one standalone action to the same + object (identical copies) still reach the cross-stack action-key check + (#14662) and are refused there, by name, as before. + - A scalar or fixed-shape config object the later object declares (`label`, + `sharingModel`, `enable`, `access`, …) still replaces the earlier one: the + ruling narrows collections only, and the docblock now says so. + - The default `'error'` and `'override'` are untouched, message for message. + - An explicit `undefined` on the later object is read as no declaration — it + neither counts as a differing value nor erases what the earlier stack + declared (the bare spread used to let it). + + **Who is affected.** Measured on `origin/main` @ `53cbad9f7`: **zero** non-test + call sites in `packages/**`, `examples/**`, `apps/**` pass `objectConflict` at + all — every real caller takes the default `'error'`. An external author who + opted into `'merge'` and relied on the later package's collection winning + silently now gets the refusal above; the fix is the one it names. + + The `ConflictStrategySchema` docblock for `'merge'` states the rule. +- 13c48c2: feat(spec): retire `connector.errorMapping` — eleven authorable keys nothing ever read, one of them spelled like the live `userMessage` channel (#14676, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + Triage ruling 2026-09-02 on the census card: ADR-0049 enforce-or-remove decides + it — declared-but-unenforced authorable surface with zero measured pull for a + reader comes off. + + `ConnectorSchema.errorMapping` carried `ErrorMappingConfig` (`rules`, + `defaultCategory`, `unmappedBehavior`, `logUnmapped`) and its + `ErrorMappingRule[]` (`sourceCode`, `sourceMessage`, `targetCode`, + `targetCategory`, `severity`, `retryable`, `userMessage`) — eleven keys on the + published authorable surface that **nothing read**: measured on `origin/main`, + the only reference outside the declaring file and its unit test was a + type-identity pin. No provider, dispatcher or materializer ever mapped an + external error through the rules, so `unmappedBehavior` configured nothing and + a rule's `userMessage` was never shown to anyone. That spelling is what made + this worse than ordinary dead surface: it is the name of the **live** + API-error channel (`ApiError.userMessage`, the user-facing refusal text a + thrown HTTP error declares), so an author who had read that documentation and + wrote a connector rule reasonably believed they were marking a refusal for an + end user — and the failure was silent in both directions (it validated, it + published, no message was ever shown). Removal resolves the collision by + deletion; the live channel is untouched. + + **What is refused:** authoring `errorMapping` on a connector, with any value. + `ConnectorSchema` is a non-strict `z.object`, so the key is a `retiredKey()` + tombstone rather than a bare deletion (a deletion would have stripped it in + silence): authoring it is a `tsc` error (`never`) and a parse error carrying + the prescription, on the base schema and — through + `DeclarativeConnectorEntrySchema`, which `superRefine`s the same shape — on + `stack.connectors[]` and the `PUT /api/v1/meta/connector/:name` door. + + **What leaves the public surface:** `ErrorMappingConfigSchema` / + `ErrorMappingConfig` / `ErrorMappingConfigParsed`, `ErrorMappingRuleSchema` / + `ErrorMappingRule`, and `ConnectorErrorCategorySchema` / `ConnectorErrorCategory` + (the enum's only consumers were the two removed shapes; an exported value + schema with no consumer reads as a capability). `api/ErrorCategory` — the + HTTP-response vocabulary — is unaffected. + + **What stays, byte-identical:** every other connector key (`health`, `retry`, + `webhooks`, `fieldMappings`, `syncConfig`, `actions`, `triggers`, `provider`, + `providerConfig`, `auth`, …) with its default and its readers. + + ## FROM → TO + + ```ts + // before — parsed green; nothing ever read the block, no message was ever shown + defineStack({ + connectors: [{ + name: 'payments_api', + label: 'Payments API', + type: 'api', + errorMapping: { + rules: [{ + sourceCode: 429, + targetCode: 'RATE_LIMITED', + targetCategory: 'rate_limit', + severity: 'medium', + retryable: true, + userMessage: 'The payment provider is busy; try again shortly.', + }], + unmappedBehavior: 'generic_error', + }, + }], + }); + + // after — delete the key; there is no replacement because no error-mapping + // engine exists: a connector's failures reach callers as the provider's own + // errors (ADR-0097). A user-facing refusal text is the API error envelope's + // `userMessage`, declared by the code that throws — not connector metadata. + defineStack({ + connectors: [{ name: 'payments_api', label: 'Payments API', type: 'api' }], + }); + ``` + + One-line fix: delete the `errorMapping` block; `os migrate meta --from 17` + lists the mechanical edits for existing sources. + + The retirement kit: + + - `retiredKey()` tombstone on `ConnectorSchema.errorMapping` + (`packages/spec/src/integration/connector.zod.ts`; the section comment + records what the shape was), inherited by `DeclarativeConnectorEntrySchema` + - ADR-0087 registration: `integration/Connector:errorMapping` and + `integration/DeclarativeConnectorEntry:errorMapping` in + `RETIRED_KEYS_BY_MAJOR[18]`; `integration/ErrorMappingConfig`, + `integration/ErrorMappingRule`, `integration/ConnectorErrorCategory` in + `RETIRED_DEFS_BY_MAJOR[18]`; the D2 conversion + `connector-error-mapping-removed` (protocol 18) wired into the step-18 chain + — a pure lossless strip of the block from every `connectors[]` entry, one + notice per connector (the eleven nested keys leave with the block) + - no liveness-ledger row: `connector` is not an enrolled ledger type, so + there is no row to keep or drop + - pin tests (`connector.test.ts`): refusal pins asserting the issue path, + code and prescription on the base schema, the declarative entry, and the + `stack.connectors[]` authoring path; the tsc `never` channel; a + no-materialize pin; the conversion's strip and notice; zero holders of the + seven retired names on every public entry; the ADR-0087 registration + - generated baselines/docs follow the schema (`authorable-surface/`, + `authorable-defaults/`, `api-surface/`, `json-schema.manifest/`, + `declaration-map/`, `export-origins/`, spec-changes, upgrade guide, + reference docs) + - zero authored occurrences in this repo's examples, skills and docs, and + zero hits in objectui at `0d8fd7c`, so no in-repo source changes ride along +- e89fa92: `IDataDriver` now declares `aggregate?` — the one engine-reached driver verb that had no signature to match against. + + The engine has always dispatched native aggregation by presence (`typeof driver.aggregate === 'function'`) and called `driver.aggregate(object, query, options)`, but the interface never spelled the member, so a custom driver's `aggregate` was checked in neither direction: swapped arguments or a non-row result compiled clean and surfaced only after the engine's `having` filter silently matched nothing. The member is declared optional, matching the presence test — a driver without native aggregation omits it and stays conformant, served by the `find()` + in-memory fallback. + + Additive: every in-repo driver already satisfies the declared signature (`(object: string, query: DriverQuery, options?: DriverOptions) => Promise[]>`); a wider parameter union or a looser return type stays assignable. What is newly refused is a wrong argument order or a non-array result. No `DriverCapabilities` bit is added — presence remains the capability test, as `data/driver.zod.ts` rules. +- e9fcd6b: feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478) + + + + **BREAKING** — eight published duration keys are renamed and tombstoned. Shipped + as `minor` under the repo's launch-window convention for breaking changes; the + hand-migration prescriptions are registered under protocol major 18. Maintainer + ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in + the key NAME, never only in its `.describe()` prose, and grandfathers no existing + offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6 + (#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6 + (#15679) cleared `system/`. This card clears the remainder, and is the first + where the gate itself reads **`zero offenders`** and exits `0`. + + ⚠️ That is green **for the gate's currently declared population** + (`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has + already measured an offender outside this subtree, so the gate is expected to go + red again by design. This changeset does not claim #14478 is finished. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds | + | `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds | + | `ConnectorTrigger.interval` | `intervalSeconds` | seconds | + | `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | + | `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | + | `TursoConfig.timeout` | `timeoutMs` | milliseconds | + | `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds | + | `ConversationAnalytics.duration` | `durationSeconds` | seconds | + + **Every value is unchanged** — only key names move. The two keys that carried a + default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to + 60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare + none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything + under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and + `TursoConfig.timeoutMs` still refuse a zero or negative integer, and + `ConversationAnalytics.durationSeconds` still refuses a negative length. Every old + spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site + (input type `never`) and fails the parse with the rename prescription rather than + a bare unrecognized-key error. + + `dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval` + — were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming + the tombstone would have prescribed a key the shape refuses, which is the one + failure this rename could have introduced silently; a pin asserts all three. + + ## ⚠️ `dashboard.refreshInterval` crosses a repository boundary + + This is the only rename in the whole stack whose consumer is in **another + repository**, so its reader could not move in this PR the way every other reader + in this card did. objectui's dashboard renderer reads the key, multiplies by + 1000 to drive a `setInterval`, and republishes it as an authoring input the + console offers. Those sites move in a follow-up objectui card, sequenced behind + a release that actually ships this rename. + + Until that lands the renderer sees an absent key and simply does not start its + refresh timer — a dashboard still renders, and still refreshes when the user + asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards + and `os migrate meta` correct in the meantime. + + ## ⚠️ An eighth key moves that the gate did not list + + `AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()` + named no unit at all, and the predicate judges prose against name. + + It moves anyway because it is not a second key. `persistence: { type: 'auto' }` + resolves to the same Node.js file adapter as `type: 'file'`, and this value is + forwarded to the same `FileSystemPersistenceAdapter` field, in the same + milliseconds, under the same `min(100)` bound. Renaming one arm and not the other + would have left one value with two spellings across sibling arms of one union, + and the driver reading both — the consumer-side dialect Prime Directive #12 + forbids. Its describe now names the unit too, and a pin asserts the refusal on + the arm the gate never listed, so a later reader cannot "restore" the bare + spelling as an over-application of the rule. + + ## Dispositions — four D2 conversions, two semantic entries + + Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and + unlike card 4/6 this card's answer is split. + + **D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:` + are each a stack collection whose members are stored whole as `sys_metadata` + rows, so the conversion chain has a seam that sees them: + `dashboard-refresh-interval-to-refresh-interval-seconds`, + `connector-health-and-trigger-durations-unit-in-key` (both connector keys in one + pass, emitting separately), + `memory-persistence-auto-save-interval-to-ms` (both persistence arms) and + `turso-config-timeout-to-timeout-ms`. The two datasource conversions are + driver-aware for the reason `datasource-config-driver-key-aliases` records: a + bare `config.timeout` under another driver is that driver's own key and must not + be touched. + + **Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime + and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument + reached only through `AggregationPipeline.options`. Neither is a stack collection + member or a stored row, so the chain has no seam — the disposition every + runtime-emitted measurement in this stack has taken. + + All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + + ## A retirement tombstone is no longer read as a secret + + `refusedCredentialKeys` derives a driver's refused inline credentials by finding + `z.never()` keys in its config contract. A `retiredKey()` tombstone is also a + `z.never()`, and until this card no driver contract carried one — so "never ⇒ + credential" held by accident of population rather than by construction. The first + tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a + millisecond budget was a secret: it was redacted off the datasource read path and + dragged a non-credential name into the fallback list every unrecognised driver is + scrubbed by. + + The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()` + itself stamps. The exclusion is deliberately **negative** — skip declared + tombstones — rather than positive (keep only keys marked `format: 'password'`), + even though every credential slot in every builtin contract does carry that + marker today: under-redacting is the dangerous direction, so a future credential + key whose author forgets the marker is still scrubbed, and only a key that has + explicitly declared itself retired may drop out. Both directions are pinned. + + ## Keys deliberately left alone + + `TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs` + already carried their unit — they are the same-shape neighbours that made the + bare `timeout` and `monitoringWindow` collisions visible, and pins assert they + did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every + number on `ConversationAnalytics` other than the duration is a count of messages, + tokens or events: a count has no unit to carry. The turso schema shipped by + `@objectstack/driver-turso` is a separate declaration outside this gate's + declared population and is not touched here; card 6/6 owns it, so the two + declarations disagree by design until that lands. +- 56fe8c2: A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. + + A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate. + + - `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge. + - `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`. + + **String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved. + + An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map. +- 6491463: `/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. + + **A client that keyed on `services.realtime.enabled: true` to subscribe was subscribing to nothing; it now sees `false`.** On a stock boot the document reported that entry as `enabled: true` *and*, in the same entry, "In-process event bus only — no HTTP/WS realtime surface is mounted", with no `routes.realtime`. Both statements were true, because `enabled` meant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" lost its subscription silently: no error, no failed request, no signal at all. The open framework does not mount a realtime transport (maintainer ruling, 2026-09-04), so discovery now says so. + + **The definition, written down once and computed once.** A subscribable channel exists only where discovery reports `handlerReady: true` together with a connectable `route`; `enabled` never means "there is a channel". That sentence is `isSubscribableChannel()` in `@objectstack/spec/api`, and both discovery producers — `HttpDispatcher.getDiscoveryInfo()` and `ObjectStackProtocolImplementation.getDiscovery()` — set `services.realtime.enabled` and `capabilities.websockets` to the value of that call, so the field a consumer reads and the predicate a consumer is told to use are one computation and cannot disagree. `capabilities.websockets` was previously a literal `false` in each producer; two constants that happen to agree are not agreement, they are two places to forget. + + **Nothing else changes meaning.** The predicate is applied per slot, to the slots whose advertised capability *is* a channel (`CHANNEL_SURFACE_SLOTS` — `realtime` alone). `cache`, `queue` and `job` deliver their whole contract in-process, so they stay honestly `enabled: true` with no route; `status`, `message` and every other slot's `enabled` are untouched, and `realtime` keeps `status: 'degraded'` plus its message so a consumer can still tell "registered but no wire" from "not installed". + + What to read instead, per case: + + - deciding whether to open a subscription → `handlerReady === true && typeof route === 'string'`, i.e. `isSubscribableChannel(discovery.services.realtime)`, or the equivalent `capabilities.websockets.enabled`; poll or degrade otherwise; + - asking whether the slot is occupied at all → `status` (`'unavailable'` = nothing registered; `'degraded'` = registered, reduced) — this is what `enabled` answered for `realtime` before. + + Testing note, recorded because it is a real limit rather than an implementation detail: the two producer pins drive a declared in-process-bus stand-in, not the shipped `InMemoryRealtimeAdapter` — `@objectstack/runtime` taking a source-level dependency on `@objectstack/service-realtime` for a test is refused by this repo's type-resolution ratchets. The claim about the shipped occupant is pinned against the real class in `@objectstack/service-realtime`'s own suite instead; a mutation giving that adapter a channel route reddens that pin and leaves the producer pins green, which is the division of labour stated at both sites. + + New in `@objectstack/spec`: `isSubscribableChannel()`, `readChannelRoute()`, `CHANNEL_SURFACE_SLOTS` (`@objectstack/spec/api`) and the optional `IRealtimeService.getChannelRoute()` — the producer half, by which an occupant that really serves a transport names the path a host mounted it at. Additive; no existing member changed shape. `@objectstack/service-realtime` deliberately does not implement it. +- 6d4d5d3: `SqlDriver.aggregate` answers `0` — not `null` — for a `sum` over a group whose aggregand is NULL in every row, matching the engine's in-memory aggregate tier and the identity `emptyGroupValueFor` already declares (#15546; maintainer ruling 2026-09-07, option A: a non-empty group whose aggregand is absent and an empty group are the SAME case for `sum`, and the SQL face is the one that moves). + + SQL `SUM` skips NULLs and answers NULL once it has skipped everything, so on every dialect this driver targets (measured on better-sqlite3, live PostgreSQL 16.13 and live MySQL 8.0.46) a grouped list view with a `sum` summary on a nullable number or currency column rendered a BLANK total for a group whose column was empty in every row — while the same view on a deployment whose query took the engine's in-memory path rendered `0`. Which path answered was decided by a driver capability bit the caller never sees. The fold is part of the driver's aggregate presentation (`foldEmptyAggregateAnswers`): the compiled statement is unchanged (no `COALESCE`), the answer is the JS number `0` on every dialect, and `avg`/`min`/`max` — which have no identity over nothing — still answer `null`. The identity is read from `emptyGroupValueFor` rather than restated, so the two faces cannot drift apart on it again. + + `@objectstack/driver-turso`: the REMOTE transport's `aggregate` carries the same fold (`RemoteTransport.foldEmptyAggregateAnswers`). `TursoDriver` picks the remote compiler or the local `SqlDriver` one from `url`, so without it the same driver would have answered the all-NULL `sum` as `0` locally and `null` remotely — one query, two answers, decided by a connection string, the seam the shared conformance table exists to close. Measured `null` on the enrolled remote face before the fold. + + `@objectstack/spec`: the aggregate-vocabulary conformance fixture gains a NULLABLE numeric column. `AggregationRow.amount` (`number | null`) is NULL in every row of the `east` group and in two of the four `west` rows, and `AGGREGATION_CASES` gains the three cases that pin the ruled answer on every enrolled face — `sum(amount)` grouped by region (`east` 0 / `west` 40), its `count(amount)` reachability control (`east` 0 / `west` 2, which is what proves the nulls were stored as nulls), and the ungrouped partial-null control (40). A harness that runs the table MUST declare `amount` as a nullable numeric column and seed its nulls AS nulls, exactly as it already must for `stage`; a `0` written in place of a null turns the cell green for the wrong reason. +- ed5d557: feat(driver-turso)!: `timeout` bounds remote operations; `localPath` and `wasm` leave the published config schema (#16024, ADR-0049 enforce-or-remove) + + + + Three keys on this package's published Turso configuration were declared with a + describe promising behaviour that no code delivered — ADR-0049's + declared-but-unenforced shape, sitting beside `concurrency`, which was declared + the same way and IS forwarded. The maintainer ruled per key: forward `timeout`; + remove `localPath` and `wasm`. Not a rename for any of the three — an inert key + with a better name is what ADR-0049 exists to prevent. + + **`TursoDriverConfig.timeout` now does what its docblock has always said.** It + never reached `@libsql/client`. It still does not reach that client's own + `Config.timeout`, and deliberately: measured against `@libsql/client@0.17.4`, + that option is the busy timeout for lock contention on local `file:` databases + ("remote clients ignore it"), so forwarding to it would have left remote mode + exactly as inert as before. Instead: + + - **Remote mode over HTTP** (`libsql://`, `https://`, `http://`): the driver + hands the client a `fetch` that aborts every request once the window elapses, + and the operation fails as `TIMEOUT` / 504 (the ADR-0112 envelope) instead of + hanging on a stalled endpoint. `wss://` / `ws://` URLs ride the WebSocket + transport, which exposes no such seam in this client version — they are not + bounded, and the docblock says so. + - **Replica mode**: `sync()` — the one remote operation on that arm — rejects + with the same envelope when it has not completed within the window. The native + binding's sync is not cancelled, only no longer awaited. + - `0` or unset means no bound, as the published schema already documented. + + A datasource authors this as `config.timeoutMs`; the datasource seam maps it + onto the driver's `timeout`, so a `timeoutMs` that used to be silently dropped + now bounds the connection it describes. + + **BREAKING** — `TursoConfigSchema` refuses `localPath` and `wasm`. Neither was + read by any code: the replica arm names its local file via `url` (forwarding + `localPath` would have created a second way to say the same thing), and nothing + selects a WASM build of libSQL (forwarding `wasm` would have meant building + one). The shape is a plain `z.object`, so a bare deletion would have stripped + both keys in silence; they stay declared as `z.never()` tombstones instead — + `tsc` refuses them on anything typed `TursoConfig`, and a value reaching the + parse raises the prescription below rather than a generic unrecognised-key + error. The same treatment this package's `timeout` → `timeoutMs` rename took. + + ## Migration + + | Wrote | Write instead | + | --- | --- | + | `localPath: './replica.db'` beside `url: 'file:./replica.db'` | delete `localPath` — `url` names the replica's local file, `syncUrl` the remote primary; a path that differed from `url` belongs in `url` | + | `wasm: true` | delete `wasm` — no WASM build was ever selected; a runtime that cannot load native bindings uses the remote arm (`libsql://` / `https://`), which needs none | + + `@objectstack/spec`'s own turso contract never declared either key, so no stack + source or stored datasource row that passed the spec door can carry them; the + ADR-0087 ledger records the removal as the D3 entry + `driver-turso-config-local-path-wasm-retired` (no D2 conversion — there is no + lossless rewrite for a value that never did anything), which is the + `@objectstack/spec` `minor` here — the entry is a new member of the published migration + registry (`packages/spec/src/migrations/registry.ts`), an additive widening of that package's + surface, and the act sets the floor. +- bca21f7: `POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy. + + Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why. + + `copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.* + + - **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use. + - **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead. + + ⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing. + + Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question. +- e9fcd6b: feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout` → `timeoutMs`, `MetadataManagerConfig.cache.databaseLoader.ttl` → `ttlMs` (the outer `cache.ttl` leaves outright under #15624 — nothing read it), tenant `idleTimeout` / `sessionTimeout` → `*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519) + + + + **BREAKING** rename of seven published authorable keys, shipped as `minor` under + the repo's launch-window convention for breaking changes; every rename is + registered under protocol major 18. Maintainer ruling 2026-09-02 on #14478 + (director decision batch #14, verbatim 「14461 你不处理,其他同意」): **ruled B** — + a spec-source gate for duration-shaped number keys **with no grandfathered + baseline**, plus an ADR-0087 conversion of every offender the ruling named, in + one PR, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」. + ⛔ No alias, no transition window: each old spelling is a `retiredKey()` + tombstone whose rejection names the new key. + + ## The defect + + `kernel/metadata-loader.zod.ts` carried two keys spelled `ttl` fourteen lines + apart: `cache.ttl` in **seconds** (default 3600) and `cache.databaseLoader.ttl` + in **milliseconds** (default 60000). Both descriptions named their unit; the + key names did not. An author who copied the outer number into the inner block + got a 3.6-second cache and no error anywhere — the number was valid, the type + was right, the cache was simply cold. `hook.timeout`, `job.timeout` and + `DriverOptions.timeout` had the same shape (milliseconds, said only in prose) + beside siblings that spell theirs (`backoffMs`, `intervalMs`, the body-level + `timeoutMs`). The two tenant keys were worse for the reader who matters most: + `.describe()` is what `content/docs/references/**` publishes and the JSDoc above + a key is not, so `idleTimeout` / `sessionTimeout` said "in seconds" in a source + comment and published a bare `300` / `3600` to the reference page (#14519). + + ## FROM → TO + + | schema | before | after | value | + |:--|:--|:--|:--| + | `HookSchema` (`hooks[]`) | `timeout` | `timeoutMs` | unchanged (ms) | + | `JobSchema` (`jobs[]`) | `timeout` | `timeoutMs` | unchanged (ms) | + | `DriverOptionsSchema` | `timeout` | `timeoutMs` | unchanged (ms) | + | `MetadataManagerConfigSchema` | `cache.ttl` | *(deleted — its respelling `ttlSeconds` was retired before it shipped, #15624; the outer `cache` block was read by nothing, and the live TTL is `cache.databaseLoader.ttlMs`)* | — | + | `MetadataManagerConfigSchema` | `cache.databaseLoader.ttl` | `cache.databaseLoader.ttlMs` | unchanged (ms, default 60000) | + | `DatabaseLevelIsolationStrategySchema` | `connectionPool.idleTimeout` | `connectionPool.idleTimeoutSeconds` | unchanged (s, default 300) | + | `TenantSecurityPolicySchema` | `accessControl.sessionTimeout` | `accessControl.sessionTimeoutSeconds` | unchanged (s, default 3600) | + + ```ts + // before + defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeout: 5000 }); + defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeout: 300000 }); + new MetadataManager({ cache: { ttl: 3600, databaseLoader: { ttl: 60_000 } } }); + + // after — rename the key; the number is unchanged + defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeoutMs: 5000 }); + defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeoutMs: 300000 }); + new MetadataManager({ cache: { databaseLoader: { ttlMs: 60_000 } } }); // the outer `ttl` is deleted, not renamed (#15624) + ``` + + **Migration.** Rename each key; no value changes — with one exception: the outer + `MetadataManagerConfig.cache.ttl` is DELETED, not renamed (its respelling `ttlSeconds` + was retired before it shipped, #15624; nothing ever read the outer `cache` block, and + the nested `cache.databaseLoader.ttl → ttlMs` rename above is unchanged). Authoring an old spelling + fails to compile (`tsc`: the input type is `never`) and fails to parse with a + prescription naming the new key. For `hooks[]` / `jobs[]` the rename is a + mechanical D2 conversion (`hook-timeout-to-timeout-ms`, + `job-timeout-to-timeout-ms`, retired from the load path): run + `os migrate meta --from 17` to list the edits for existing sources and apply + them by hand; stored `sys_metadata` rows are rehydrated through the same chain. + The other five keys have no stack seam (runtime config, a per-call options + argument, cloud tenancy config) and carry a semantic entry each. The + `JobScheduleOptions` contract key that carries `job.timeoutMs` to the scheduler + is renamed in lockstep (`timeout` → `timeoutMs`), as is `DatabaseLoaderOptions.cache.ttl` → `ttlMs` in `@objectstack/metadata`. + + ## The gate + + `pnpm --filter @objectstack/spec check:duration-unit-keys` + (`packages/spec/scripts/check-duration-unit-keys.ts`, wired into `lint.yml`): + a property whose value is a `z.number()` / `z.int()` / `z.coerce.number()` + chain and whose `.describe()` names a time unit must carry that unit as a token + of its key name (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`, and the + knex-inherited `Millis`), and the token must agree with the prose — `ttlMs` + described "in seconds" is refused too. A `{ value, unit }` pair is recognised + by its sibling `unit` key; duration literals are strings and outside the + population. Calendar positions ("day of the month") and rates ("requests per + second") are skipped. There is no baseline and no `gen:`; a red is a rename + under an ADR-0087 conversion or a describe to fix. +- e9fcd6b: feat(spec)!: declare the duration rule's two structural exemptions on the schema — a shared `EpochMs` instant and a `.meta({ externalVocabulary })` marker (#15676, ruling B on #14478) + + + + **BREAKING** — four published epoch-instant keys are renamed and tombstoned. + Shipped as `minor` under the repo's launch-window convention for breaking + changes; the hand-migration prescription is registered under protocol major 18. + Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, because two sibling keys both spelled `ttl` in different units + are indistinguishable at the authoring site. Ruling B exempts two structural + classes from it, and is explicit about the mechanism: both are **declared on the + schema, never in a gate ledger**. This change lands both declarations and + applies them. + + ## 1. Epoch instants — the shared `EpochMs` schema + + `EpochMs` (`@objectstack/spec/shared`) is a `z.number().int()` describing + milliseconds since the Unix epoch. A key whose value IS that schema is an + INSTANT, and the gate recognises it structurally — nothing anywhere names the + exempt keys. + + An instant reads to the rule exactly like an offending duration (a bare name + plus a describe that says "milliseconds"), but renaming it the way the rule + prescribes would resolve the wrong confusion. Measured on this package's own + authorable surface: all 51 distinct keys ending in `Ms` are durations + (`timeoutMs`, `backoffMs`, `latencyMs`, `uptimeMs`) and all 51 distinct keys + ending in `At` are instants (`createdAt`, `expiresAt`, `lastUsedAt`). Spelling + an instant `*Ms` would move it INTO the family the rule exists to separate it + from. So the six instants take `EpochMs`, and the four whose name was bare take + the `*At` convention. + + ### FROM → TO + + | Schema | Wrote | Write instead | + | :-- | :-- | :-- | + | `api/WebSocketEvent` | `timestamp` | `occurredAt` | + | `api/SimplePresenceState` | `lastSeen` | `lastSeenAt` | + | `kernel/KernelContext` (and `TenantRuntimeContext`) | `startTime` | `startedAt` | + | `kernel/HealthStatus` | `timestamp` | `checkedAt` | + + ```ts + // before + const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startTime: Date.now(), features: {} }; + // after — the value is unchanged; only the key name and the declared schema move + const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startedAt: Date.now(), features: {} }; + ``` + + Each old key is tombstoned with `retiredKey()`, so it fails `tsc` at the + construction site and fails the parse with the rename prescription rather than + being silently stripped. `kernel/ServiceMetadata.registeredAt` and + `kernel/ScopeInfo.createdAt` were already correctly named and only change + schema — they are not retirements and need no edit. + + ⚠️ `api/PresenceState.lastSeen` (`api/realtime-shared.zod.ts`) is a **different** + key holding an ISO-8601 datetime string. It is untouched; do not rename it with + its neighbour. + + **One tightening.** `WebSocketEvent.timestamp` and `SimplePresenceState.lastSeen` + were declared bare `z.number()`, and `EpochMs` is `z.number().int()`, so a + fractional epoch that used to parse at those two sites is now refused. + `Date.now()` has always satisfied it. The other four already declared `.int()`. + + ## 2. External-standard mirrors — `.meta({ externalVocabulary })` + + A key whose name is fixed outside this repo carries + `.meta({ externalVocabulary: '' })`. The marker rides + `z.toJSONSchema` verbatim (the channel `xRef` / `xExpression` already use), the + gate honours it, and **the reference page publishes it**: the description cell + now reads `… in seconds (unit per HTTP Cache-Control \`max-age\` (RFC 9111 §5.2.2.1))`. + Publishing it is what makes the exemption honest — the gate exists because a + bare `maxAge` publishes a naked number to a reader who cannot see the source. + + Eleven keys are marked: the three HTTP `Cache-Control` directives, the two CORS + `Access-Control-Max-Age` config keys, the two S3 presigned-URL `expiresIn` keys, + the three better-auth forwarded options, PostgreSQL's `statement_timeout` and + the DNS record `ttl`. No authorable key is renamed or re-typed by this half. + + ⛔ Neither exemption is a pass on lying: a marked key still fails + `name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit + other than milliseconds fails the new `instant-unit-contradicts-schema`. +- ef3a138: feat(spec)!: an evaluated expression slot requires a non-blank `source` — `EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention): on the schemas that type an EVALUATED expression + slot — today the `assignment` node's value envelope, + `AssignmentExpressionValueSchema` — an envelope with no `source` the engine can + evaluate is now **refused at authoring**, where it used to parse, register, + pass `objectstack validate`, and then fault at run time. + + Two spellings of one seam, refused by ONE rule with one message at `source` + (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`): + + ```yaml + assignments: + digest: { dialect: cel, ast: { kind: const } } # `ast` only — no engine evaluates it + greeting: { dialect: cel, source: ' ' } # blank after trimming — parses to EOF + ``` + + > An expression in an evaluated slot needs a non-blank `source`: the expression + > engine evaluates `source` (the canonical persisted form of phase M9.1) 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: '…' }`. + + - **`ExpressionSchema` is NOT narrowed.** It is the persistence contract — + `source` OR `ast` — and its docblock declares that `ast` becomes required in + build output at phase M9.2. The new export `EvaluatedExpressionSchema` (and + its type `EvaluatedExpression`) is a sibling: the same envelope with `source` + required and non-blank, spelled once and composed by every evaluated slot, so + when AST-only evaluation lands the flip is one edit there rather than a + per-slot unwinding. The rule is worded as "an evaluated slot requires whatever + the engine can actually evaluate"; what that is today is `source`. + - **The notion of blank is the engine's own** — `.trim()`, which + `cel-engine.ts`'s helpers already apply — not a third one beside the shape + rule's `min(1)` and `validateExpression`'s trim. + - **Three doors agree.** `registerFlow` refuses the flow, `objectstack validate` + and the runtime publish gate report a located `error` at the author's own + variable (`config.assignments..source`), and the executor's own shape + pass refuses the same set — all through the spec schema, so none of them + grew a rule of its own. + + **What an author does with a refused envelope.** An assignment value that + carried only `ast` has no evaluable form under M9.1: author its `source`. A + whitespace-only `source` was never an expression: delete the entry, or write + the expression. Every envelope with a non-blank `source` is unchanged, and + nothing is renamed, retired or rewritten — the refusal itself carries the + prescription. + + Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s + silent `false` on an envelope without a `source` — is a behaviour change on a + live path with its own card, and the edge-condition schema that carries that + envelope is narrowed in a follow-up once the in-flight change to + `automation/flow.zod.ts` lands. +- 68d5dfd: feat(spec): `ExecutionStepMetrics` gains an optional `failures` slot, and `FlowRunSummary.failed` is declared as the fold INCLUDING what a delegating node rolled up from its child (maintainer ruling 2026-09-06 on #15617, spec half) + + Additive. Nothing an author writes is renamed, retired or narrowed; no accept + set shrinks. One optional key is declared on a runtime-produced schema and the + prose of a published contract is reconciled with itself. + + **What was wrong.** `FlowRunSummary` said two things about `failed`. Its + header paragraph declared that a `subflow` node rolls its child run's totals + up into the parent — "this summary answers *what did this run cause*" — while + the field itself declared `failed = Σ nodes[].failures`, a fold over the + parent's own node executions. For a parent that delegates its rows to a + `subflow` (or a `map` item) those give different answers, and the engine could + only satisfy the second one: `ExecutionStepMetrics` carried `selected` / + `acted` / `unmeasuredEffect` and no failure slot, so a child's contained + failures had no path into the parent's fold. Measured on the real engine by + the services seat (#15617): parent `loop { subflow(child) }` → parent + `failed=0` while the five child summaries carried `failed=[0,0,0,0,1]` — + `acted` rolled up, `failed` did not. + + **What this declares.** + + - `ExecutionStepMetrics.failures` (optional, integer ≥ 0): node executions + that failed inside a child run this execution delegated to and went on from + — a `subflow` child or a `map` item whose run COMPLETED while containing + failures, i.e. the child's `summary.failed`, rolled up. It folds into the + delegating node's `nodes[].failures` and so into the run-level `failed` — + the same fold shape `acted` has, but not the same rule at the failed-child + boundary (next bullet). Absent means the step delegated nothing, or its + child tracked no count, or the producer did not track it (every step the + engine emits between this release and the engine half) — never zero. + - It is NOT the step's own outcome. A step that failed is `status: 'failure'` + and counts once through `nodes[].failures`, as before; a child that FAILED + — whether or not it also contained failures before it failed — is + precisely that step failure: its own `failed`, contained and fatal alike, + stays on the child's run row and nothing rides up, so one failure is never + counted twice. This is where the rule parts from `acted`, which does carry + a failed child's writes up to the parent. The control the card measured (a + failing child → parent `failed=1`) keeps counting exactly as today. + - `FlowRunSummary.failed` is declared, at the field, as the fold of + `nodes[].failures` INCLUDING what a delegating node rolled up; the + `FlowRunNodeSummary.failures` describe names the roll-up path, and its + `status` describe states that a delegating node whose child contained + failures reads `success` beside `failures > 0` — status is judged on the + node's own executions. + + **What this does not do yet.** This is the contract half of a two-lane + landing (contract first). No producer populates `failures` in this release: + `subflow-node.ts` and the `map` node roll the child's contained failures into + the slot in the services half, #16314, and only then does a parent's + `failed` start counting them. Until that lands, every `ExecutionStepMetrics` + the engine emits is byte-identical to today's, `failed` is numerically what it + was, and the flow-run reference page keeps the narrowed wording PR #15609 + shipped ("node executions **of this run**") on purpose — it is widened when + both halves are in. + + **Consumers.** A reader of `ExecutionStepMetrics` sees one more optional + number and nothing else changes shape; a consumer that already sums + `nodes[].failures` to cross-check `failed` keeps agreeing with it, because the + fold is unchanged — the roll-up enters the per-node array, not beside it. Two + consequences of that placement are part of the contract from this release, + even though no producer populates the slot yet: on a delegating node + `nodes[].failures` may exceed `runs` (`runs: 5, failures: 15` is a legal + shape — five subflow executions whose children each contained three), and it + is no longer only that node's own failed executions, so a reader that derived + "this node's executions that failed" or a failure RATE from `failures / runs` + must read a delegating node's number as "failures this node caused, its + child's contained ones included". +- 4cfc93b: `objectNavTargetExclusivity` — the object-level check on an object navigation item that refuses `filters` combined with `recordId` / `viewName`, and `runAction` combined with `recordId` — is now EXPORTED from `@objectstack/spec/ui`, one function per refinement in the same posture as the `check*` exports. A hand-written mirror of the object nav item chains this very function in its own `superRefine` instead of restating the rule from prose; a restatement is what drifts: objectui's hand-written mirror re-implements neither rule — its `superRefine` checks only `id` / `label`, and the file names no `filters` rule beyond the field's declaration (measured at the pinned `.objectui-sha` and at objectui `origin/main`). + + **What moves for consumers: one new export.** No schema's accept set moves. `NavigationItemSchema` chains the check exactly where it did — its `type: 'object'` branch — and the exported `ObjectNavItemSchema` still does not chain it: which schema mounts the check is a separate question from whether a mirror can, and it is not decided here. The two deliberate asymmetries are unchanged and now pinned: `recordId` + `viewName` stays a tolerated legacy combination, and `runAction` is refused with `recordId` only (it still composes with `filters` / `viewName`). + + **Also corrected, in the same file:** the `filters` docblock stated a complete precedence order (`recordId` → `filters` → `viewName`) a few lines above saying the combination is unrepresentable, and the mirror copied that first half. The docblock now states only what the guard refuses, says in as many words that no precedence order is stated and why, and names the one legacy combination the guard tolerates. The `.describe()` strings — what reaches the generated references — are unchanged. +- 859ded3: fix(spec): `FieldSchema` refuses a WHITESPACE-ONLY `reference` on `lookup` / `master_detail` + + **BREAKING** accept-set narrowing on `FieldSchema`, shipped as `minor` under the + repo's launch-window convention for breaking changes — the same grade the nearest + tightening precedents shipped with, including #13632, the narrowing this one + finishes. + + #13632 closed the declared-but-unenforced gap on `FieldSchema.reference` in 17.3.0, + but spelled its emptiness test as an equality against `''`, so a whitespace-only + target (`reference: ' '`) passed a door whose whole purpose is to name an object. + Measured on the built artifact before this change: absent and `''` were refused, + `' '` and `'\t\n'` were **accepted**, at both the field level (`FieldSchema`) and + the document level (`ObjectSchema`), on `lookup` and `master_detail` alike. + + A blank target names no object either. The declared grammar for an object name is + `/^[a-z_][a-z0-9_]*$/` (`ObjectSchema`'s own `fields` key schema), so no + whitespace-bearing string can ever resolve to one, and all three consequences the + existing refusal message lists hold verbatim for `' '`: the record picker has no + object to query, `$expand` has nothing to resolve, and no relationship index can be + built. It is also the state a cleared target picker emits — `''` and `' '` are one + authoring gesture that was getting opposite verdicts. + + What newly gets rejected: `type: 'lookup'` or `type: 'master_detail'` whose + `reference` is present but consists only of whitespace. It joins absent and `''` + under the same `custom` issue, on the same `reference` path, with the same + prescriptive message — no new message and no new error shape. The notion of blank + is `.trim()`, the same one `EvaluatedExpressionSchema` applies to `source`, not a + third one. + + Everything else is untouched. Trimming is applied to the TEST only, never to the + stored value: a target with surrounding whitespace (`' company '`) is still accepted + and still round-trips byte-identically. A non-string `reference` still answers + `invalid_type` from the base schema, not the custom message — that distinction is + deliberate and pinned. Non-relationship types never carried the requirement, and the + `Field.lookup()` / `Field.masterDetail()` helpers take the target as their first + positional argument, so helper-authored fields cannot produce this shape. + + The measured population of affected authored sources is zero: one repo-wide census + over all tracked files found a single whitespace-only `reference` in the tree, an + objectql test fixture cast past Zod on the documented `registerObject` path that + skips schema validation by design — it does not reach this door, and it is green + after the change. The census and its positive controls are recorded on the PR. + Downstream, objectui's two metadata writers already refuse this shape with + `reference.trim() !== ''`; upstream trimming turns their declared divergence into + contract-following, and that note can now be retired. + + +- fa125f3: feat(objectql,spec): `Field.valueDomain` binds at the write seam — a non-member is refused with `value_domain` (maintainer ruling 2026-09-02 on #14168, engine half) + + **BREAKING** accept-set narrowing on the ObjectQL record write path, shipped as + `minor` under the repo's launch-window convention for breaking changes. + + The key is **already published, and published unenforced**. The version-packages + cut `8a1bad8b8` (2026-09-04 10:20Z) consumed the spec half's changeset + `field-value-domain-slot.md` and released `@objectstack/spec@17.3.0`, which + declares `Field.valueDomain`, parses it, and refuses it on any type other than + `text` — and never reads it when a record is written. The 17.3.0 liveness ledger + states the gap in its own words: "a non-member WRITTEN to a `text` field + declaring a domain is accepted today". That write is accepted on 17.3.0 and is + refused from this release on. + + **Refused shape**, precisely: a record write that supplies a value for a `text` + field whose definition declares `valueDomain`, where the WRITTEN value is not a + member of the named standard. It fails with the field error code `value_domain`, + carrying `constraint: { valueDomain }` and a message that names the standard in + all four platform locales. Nothing else narrows — a field that declares no + `valueDomain` is untouched, and so is every other field type, because the schema + accepts the key on `text` alone and the validator judges exactly that set. + + **Remedy: write a member of the declared standard.** `iana_time_zone` admits + `UTC` and refuses `Mars/Olympus`; `iso_4217_currency` admits `CHF` and refuses + `chf`; `iso_3166_alpha2` admits `CH` and refuses `ZZ`. Dropping the + `valueDomain` declaration from the field lifts the refusal entirely, for an + author who declared a domain they did not mean. + + **No stored row is touched, and none becomes invalid.** This is the `min` / + `max` / `maxLength` transition-gate class: a value stored before the domain was + declared — or before this release — is never re-read, and it survives an edit of + another field on the same record. An absent or empty value follows the field's + `required` handling, not this check. + + + + - The membership test is the spec's shared `isValueDomainMember` — the same + predicate, over the same closed vocabulary, that a settings specifier's + `valueDomain` uses. A time zone accepted in Settings is the time zone + accepted in a field. + - The two authoring forms (`fieldForm`, `objectForm`) gain a `valueDomain` + control, shown on exactly the types the schema accepts the key on. The + object-form control's choices are derived from the vocabulary, not re-typed. +- a646120: `FILTER_TEXT_CASES` declares what a text operator answers over a stored value that is NOT a string, and the fixture gains its first non-string column. + + Measured before this row existed, one filter over one numeric column answered four ways across the platform: `driver-memory`'s reference matcher said NO to `$contains` and to `$notContains` for the same row; its live mingo path, `formula`, objectql's `having`, `driver-mongodb` and the analytics face type-gated (`$contains` NO, `$notContains` YES); the SQLite family coerced the number to text in its storage class's spelling (REAL renders `5` as `'5.0'`); and live Postgres refused at query time with SQLSTATE 42883 — a 500. + + The maintainer ruled the cell on 2026-09-05 (option A, type-gate): a stored value that is not a string never satisfies a positive text operator (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` — complementarity holds, on every face. Coercion was refused on the measurement; a declared-type door that refuses the filter before any backend runs is deferred to its own decision card, not rejected. + + - `FilterTextRow` is now `{ id, name, score }` — `score` is a NUMBER on every row (a `0` among them), chosen so a coercing backend answers a visibly non-empty set and a truthiness guard drops a row. + - Five new evaluated rows over `score`: the four positive operators the table can carry answer `[]`, `$notContains` answers all nine. (`$like` / `$ilike` follow the same rule and are pinned on the faces that answer them — the table is a driver's enrolment and `driver-mongodb` refuses those two.) + - `NON_TEXT_STORED_VALUE_TYPES` (`field-value.zod.ts`) — the numeric and boolean value classes, i.e. the declared field types whose stored value is never text — is the list the SQL faces classify a column by at compile time, since they cannot read the value. Temporal types are deliberately absent: their stored form is a dialect question (ADR-0053) the row does not decide. + + Every suite that materialises the fixture adds the column (SQL `initObjects` DDL included). +- 6f1ce7d: feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661) + + + + **BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny. + + The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares. + + New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes. + + The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set. + + What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant. +- 7778115: `ObjectQL.find()` now guarantees the array it declares: an `afterFind` hook that replaces the result container is refused with `FIND_HOOK_RESULT_NOT_ARRAY`. + + `find()` is declared `Promise`, but on the hook path it returned `hookContext.result` with nothing re-checking the value after the `afterFind` dispatch. A handler assigning `ctx.result = { records: [ … ] }` therefore made a read declared to resolve to an array resolve to an envelope instead — silently, with no throw, no diagnostic and no log, while roughly 140 call sites read the answer as an array on the strength of the declaration. + + The engine now refuses that, immediately after the `afterFind` dispatch and ahead of the two consumers that already assume the array (secret-field masking and the `__search` companion strip). The refusal is a named error, `FindHookResultNotArrayError`, carrying the registered ADR-0112 code `FIND_HOOK_RESULT_NOT_ARRAY` and HTTP `500`; its message names the hook event and the object, and `developerMessage` carries the remedy. + + **Shaping stays legal, and nothing about it changes.** A handler may still mutate rows in place, delete keys, filter rows out, or assign a *different array* built from them — `Array.isArray` is the whole predicate, deliberately, so that `ctx.result = ctx.result.map(…)` keeps working. Only the container is protected. + + What to do if this refusal fires: + + - to answer no rows, assign `[]`; + - to refuse the read, `throw` from the handler — the supported way for any hook guard to say no; + - to hand a caller a different structure, build it in the caller, not in the hook. + + `@objectstack/spec` widens by one member: `FIND_HOOK_RESULT_NOT_ARRAY` joins `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the generated `ErrorCode` union — and therefore `ApiErrorSchema.code` — accepts it. Additive: no existing code is removed or renamed. + + Scope: this closes the one `return hookContext.result` site in the engine with a concrete declared shape to violate. `findOne`, `update` and `delete` declare `Promise` and carry no enforceable declaration; that is a separate question about those declarations and is deliberately not answered here. +- 52804cd: feat(spec)!: `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964) + + + + **BREAKING** accept-set narrowing on `FlowSchema` — a flow whose `edges[]` + carries two edges with the same `id` is now **refused at parse time** — by + `FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a + flow through the schema (`objectstack validate`, the runtime publish gate, a + stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under + the repo's launch-window convention for breaking changes. Maintainer ruling + 2026-09-05 on #14964 (director decision batch #40, verbatim 「同意」): option + A — an `error`, not a `warning`; no opt-out, no transition window. + + Every reader of an edge id assumes the ids in a flow are unique — a designer, + a BPMN export, a flow diff, any traversal that dedupes by id — and nothing + enforced it. A real duplicate (`id: 'e20'` on two edges of one flow) shipped + through two releases of green CI in a downstream app and was inert only + because the engine keys out-edges by `source`, never by `id`: the collision is + invisible until something keys on ids, and then silently wrong rather than + loudly broken. The id space is hand-authored, so the next author picking a + "free" id from the sequence had no way to know it was taken. + + **What changes** (`packages/spec/src/automation/flow.zod.ts`): a `superRefine` + on the flow's `edges[]`. Each later occurrence of an already-declared id raises + one `custom` issue, anchored at `edges[N].id` of the *later* edge and naming + both positions, so the formatted error points at the edge to renumber: + + ```text + ✗ edges.7.id: Duplicate edge id `e20` — `edges[7]` reuses the id already declared by `edges[3]`; every edge id in a flow must be unique. Renumber one of them: … + ``` + + **What does NOT change:** `edges[].id` keeps its name, type and describe; the + node vocabulary, the edge `type` enum and every other refusal are untouched; + a flow with unique edge ids (or no edges) parses exactly as before. Node ids + are not covered by this change. + + The shape that is refused, and what the author does about it — a two-edge + excerpt, the later edge renumbered: + + ```ts + // before — parsed on green, both edges keyed 'e20' + edges: [ + { id: 'e20', source: 'qualify', target: 'convert' }, + { id: 'e20', source: 'convert', target: 'end' }, + ] + + // after — refused at parse (edges.1.id: Duplicate edge id `e20` …); renumber the later one: + edges: [ + { id: 'e20', source: 'qualify', target: 'convert' }, + { id: 'e21', source: 'convert', target: 'end' }, + ] + ``` + + **Remedy.** Renumber the later edge to an id no other edge in that flow + carries; nothing else in the flow needs to move. The census over this + repository found no flow to migrate, so this is a release note, not a + migration: no shipped example, fixture or seed in `packages/**` or + `examples/**` declares a duplicate edge id, and the pinned objectui tree + carries none in its authored flows. The one known downstream instance was + renumbered before this change (hotcrm PR #1571). +- 3f89967: A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`. + + Until now every terminal of a flow was "completed". A flow could say *do this* but not *refuse this, and say why, for which record* — the only channel that interpolated per-record text was a `screen` node's `description`, and a message-only screen renders Submit and, on submit, resumes to `end`, whose runner toasts `Flow "…" completed` at a user who was just told "this is refused". Maintainer ruling (2026-09-05, option 2′): the refusal is a first-class outcome of the existing terminal node, not a second node type. + + The contract, declared here first (the engine and runner halves follow in their own packages): + + - **`end` node config** — `EndConfigSchema` (`@objectstack/spec/automation`): `outcome?: 'completed' | 'refused'` (default `completed`) and `message?: string`, a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.). `outcome: 'refused'` without a `message` is refused at parse (a refusal without text is the shape this exists to replace); `message` on a completed end is refused too (nothing would ever render it). The shape is strict: an undeclared key is a parse error naming the intended key. Because `end` is structural (no executor, no descriptor), `FlowNodeSchema` applies the contract itself to every `type: 'end'` node it parses and writes the parsed (defaulted) config back; a node with no `config` is left without one. Every other node type's `config` stays the open, executor-owned slot it was. + - **Run row** — `ExecutionStatus` gains `refused` (appended last: a terminal state distinct from `failed` — a refusal is a successful evaluation that says no; never resumed) and `ExecutionLogSchema` gains `refusalMessage`, the rendered per-record text, set only on a refused run. + - **Result / wire** — `AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'`, and both carry `refusalMessage`; on a refusal `success` is `true` and `successMessage` is absent, so a runner shows the message with Close only — no Submit, no completion toast. + + Additive throughout: nothing renamed or retired, so no ADR-0087 conversion-layer entry (disposition: not-required). Flows that never set `config` on an `end` node parse exactly as before. +- 53cf263: feat(spec)!: `FlowSchema` refuses a flow whose top-level `nodes[]` declares the same id twice (#15713) + + + + **BREAKING** accept-set narrowing on `FlowSchema` — a flow whose top-level + `nodes[]` carries two nodes with the same `id` is now **refused at parse time** + — by `FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that + validates a flow through the schema (`objectstack validate`, the runtime + publish gate, a stack's `flows[]`) — where it used to parse on green. Shipped + as `minor` under the repo's launch-window convention for breaking changes. The + exact parallel of #14964 (edge ids, maintainer ruling 2026-09-05, option A — + an `error`, not a `warning`; no opt-out, no transition window), applied to the + other hand-authored id space in the same schema. + + Every edge's `source` / `target` names a node by id, and the engine's traversal + picks out-edges by `source` — with two nodes sharing an id, every edge from + that id is ambiguous and whichever node wins is decided by array order, + silently. A designer, a BPMN export and a flow diff key on node ids the same + way they key on edge ids. Only region bodies (`loop` / `try_catch` / `parallel` + sub-graphs) were checked, by `analyzeRegion` at `registerFlow()`; the flow's + own top-level `nodes[]` parsed with the collision intact — measured on + `origin/main` `1f2a02ba` with two lit controls on the same schema instance (a + node missing its `label` → refused at `nodes.1.label`; an unknown key on a node + → `unrecognized_keys`). + + **What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing + `superRefine` on `FlowSchema` gains a pass over the top-level `nodes[]`, the + same shape as the `edges[]` pass. Each later occurrence of an already-declared + id raises one `custom` issue, anchored at `nodes[N].id` of the *later* node and + naming both positions, so the formatted error points at the node to rename: + + ```text + ✗ nodes.2.id: Duplicate node id `n` — `nodes[2]` reuses the id already declared by `nodes[1]`; every node id in a flow must be unique. Rename one of them: … + ``` + + **What does NOT change:** `nodes[].id` keeps its name, type and describe; the + open node-type vocabulary (ADR-0018), the region rules (`analyzeRegion`) and + every other refusal are untouched; a flow with unique top-level node ids + parses exactly as before. The rule judges the flow's **own top-level** + `nodes[]` only — a region body's nodes remain `analyzeRegion`'s to judge, and + whether a region node may reuse a top-level node id (one id space or two) is a + separate decision (#16134) this change neither takes nor pre-empts. + + The shape that is refused, and what the author does about it — a four-node + excerpt, the later node renamed and its edge re-pointed: + + ```ts + // before — parsed on green, two nodes keyed 'n' + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'n', type: 'assignment', label: 'Assign A' }, + { id: 'n', type: 'assignment', label: 'Assign B' }, + { id: 'end', type: 'end', label: 'End' }, + ] + + // after — refused at parse (nodes.2.id: Duplicate node id `n` …); rename the later one + // and point the edges that meant it at the new id: + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'n', type: 'assignment', label: 'Assign A' }, + { id: 'n2', type: 'assignment', label: 'Assign B' }, + { id: 'end', type: 'end', label: 'End' }, + ] + ``` + + **Remedy.** Rename the later node to an id no other top-level node in that + flow carries, then re-point at the new id the edges whose `source` / `target` + meant that node; nothing else in the flow needs to move. The census over this + repository found no flow to migrate, so this is a release note, not a + migration: no shipped example, fixture or seed in `packages/**` or + `examples/**` declares a duplicate top-level node id. +- 21aabbc: feat(spec)!: `FlowSchema` refuses a region node whose id is already declared elsewhere in the flow — one node-id space across the top-level `nodes[]` and every region body (#16134) + + + + **BREAKING** accept-set narrowing on `FlowSchema` — a flow has **one node-id + space**. A node inside an ADR-0031 region body (`loop.config.body`, + `try_catch.config.try` / `.catch`, each `parallel.config.branches[]`, nested to + any depth the parse walks — up to `MAX_REGION_DEPTH` = 32 levels) whose `id` is + already declared by a top-level node, or by a node in + any other region of the same flow, is now **refused at parse time** — by + `FlowSchema.parse` / `safeParse`, `defineFlow`, and every door that validates a + flow through the schema (`objectstack validate`, the runtime publish gate, a + stack's `flows[]`) — where it used to parse on green. Shipped as `minor` under + the repo's launch-window convention for breaking changes. Maintainer ruling + (director seat, decision batch #61, 2026-09-07, 「同意」): ADR-0031's + "self-contained single-entry / single-exit sub-graph" describes control flow and + variable scope, not id reuse; every reader that flattens a flow may key on the + bare id. The ADR gains one sentence saying so in this same change. + + Before this change uniqueness was enforced **inside** each array — the + top-level `nodes[]` by `FlowSchema` (#15713) and each region body by + `analyzeRegion` at `registerFlow()` — and never **across** them: a loop-body + node could carry the same `id` as a top-level node, or as a node in a sibling + branch, and both rules stayed green. Every edge's `source` / `target` names a + node by id, and the designer canvas, the BPMN export, a flow diff and a + checkpoint's `completedNodeIds` all key on the bare id, so such a collision was + silently wrong wherever a flow is flattened. + + **What changes** (`packages/spec/src/automation/flow.zod.ts`): the existing + `superRefine` pass over `nodes[]` now walks every graph the parse reaches via + `collectFlowGraphs` — the top-level graph first, then each region in document + order, depth first, down to `MAX_REGION_DEPTH` (32) — keeping one map of first + declarations. A later occurrence + raises the same single `custom` issue as before, anchored at the later node's + own `id` (inside the region, e.g. `nodes.1.config.body.nodes.0.id`) and naming + both locations — a top-level index (`nodes[1]`) or a region path + (`loop 'sweep' body → nodes[0]`): + + ```text + ✗ nodes.1.config.body.nodes.0.id: Duplicate node id `start` — `loop 'n' body → nodes[0]` reuses the id already declared by `nodes[0]`; every node id in a flow must be unique. Rename one of them: … + ``` + + One refusal, one message shape, at every depth the parse walks: within + `MAX_REGION_DEPTH` an author never sees two issues for one collision. A region + nested beyond that ceiling is left raw by the parse and stays + `validateControlFlow`'s, in its own line — there `analyzeRegion`'s + `duplicate node id 'X'` is the only refusal of a within-region duplicate (a + cross-region collision past the ceiling is not judged), and the same line + guards `bpmn-mapping`'s raw-region caller, so it is kept on purpose. + `collectFlowGraphs` gains a `path` field beside `scope` — the same location as + a key path — so the issue can be anchored where the author wrote the node; it + also now skips a non-object element in a region its own schema refused (such a + region is left raw for `validateControlFlow` to name), where it used to throw a + `TypeError` from inside that validator. + + **What does NOT change:** `nodes[].id` keeps its name, type and describe; the + open node-type vocabulary (ADR-0018), the region rules (edge integrity, + single-entry / single-exit, acyclicity) and every other refusal are untouched; + a flow whose node ids are unique across the whole flow parses exactly as + before, region nodes included, in authored order. + + The shape that is refused, and what the author does about it — the region node + renamed, and any region edge that meant it re-pointed: + + ```ts + // before — parsed on green, `start` declared twice (top level + loop body) + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'sweep', type: 'loop', label: 'Sweep', config: { collection: '{items}', body: { + nodes: [{ id: 'start', type: 'assignment', label: 'First step' }], + } } }, + { id: 'end', type: 'end', label: 'End' }, + ] + + // after — refused at parse (nodes.1.config.body.nodes.0.id: Duplicate node id `start` …); + // rename the region node and point the region's edges that meant it at the new id: + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'sweep', type: 'loop', label: 'Sweep', config: { collection: '{items}', body: { + nodes: [{ id: 'sweep_first', type: 'assignment', label: 'First step' }], + } } }, + { id: 'end', type: 'end', label: 'End' }, + ] + ``` + + **Remedy.** Rename the later node to an id nothing else in that flow carries — + no top-level node, no node in any region — then re-point at the new id the + edges whose `source` / `target` meant it; nothing else in the flow needs to + move. The census over this repository found no flow to migrate, so this is a + release note, not a migration: no shipped example, fixture or seed in + `packages/**` or `examples/**` declares a region node id that collides with a + top-level or another region's node id. +- 9c270bb: feat(spec)!: `GanttConfigSchema` / `TreeConfigSchema` refuse undeclared keys — both `.passthrough()` windows are closed and the ten gantt members plugin-gantt read through the window are declared (#15469) + + + + **BREAKING** accept-set narrowing on two published authorable config blocks — + `ListView.gantt` (`GanttConfigSchema`) and `ListView.tree` (`TreeConfigSchema`) + in `@objectstack/spec/ui`, reached through every view door (`defineView`, + `objects[].listViews`, the `view` metadata type): an UNDECLARED key inside + either block is now **refused** at parse with the `strictObject` named error + (`unrecognized_keys`; surface named, key echoed, closest declared key + suggested), where it used to pass through silently. Shipped as `minor` under + the repo's launch-window convention for breaking changes. Maintainer ruling + 2026-09-05 on #15469 (director decision batch #41 item 2, verbatim 「同意」): + option A for both sites. + + Both blocks were `strictObject(…).passthrough()` — the campaign's own helper + applied and immediately undone, so `colourField` on a gantt block parsed green + and rendered an uncoloured bar while the same typo on a calendar or timeline + block got a named refusal. One `strictObject` applied and then undone is two + contracts on one surface (Prime Directive #12); the renderer-ahead window it + kept open is shut, and a renderer knob is declared in the spec before it is + read. + + **Newly declared on `GanttConfigSchema`** — all optional, types measured from + objectui's `GanttConfigExtensionFields` (`@object-ui/types/zod`) at pin + `a472b07`, each with a describe saying what plugin-gantt does with it: + + - `borderColorField: string` — field carrying a per-task alert stroke color + - `lockField: string` — field marking a row view-only (truthy = locked) + - `objectField: string` — field carrying the row's own object API name (mixed-object trees) + - `summaryExtent: 'children' | 'self'` — how a summary bar's span is computed + - `defaultCollapsedDepth: integer ≥ 0` — auto-collapse nodes at or below this depth + - `dependencyTypes: boolean` — whether the store persists dependency link types + - `timeZone: string` — IANA business time zone the calendar renders in + - `exportFileName: string` — base name for exported PNG / PDF files + - `interactions: { move?, resize?, progress?, link? : boolean }` — per-interaction switches (closed sub-object) + - `timeSegments: { dayStart?: string, bands: [{ key?, label, start, end, color? }], showMidnight?: boolean }` — shift segmentation for the day-mode timeline (closed sub-objects) + + **`TreeConfigSchema` declares nothing new.** plugin-tree's `getTreeConfig` + (objectui `a472b07`) reads exactly the four keys already declared — + `parentField`, `labelField`, `fields`, `defaultExpandedDepth` — from the `tree` + block, so the close refuses only what no renderer ever read. + + **Who is affected (measured, objectstack `f7db8f4fd`):** zero gantt or tree + blocks under `examples/**`, `content/docs/**`, `skills/**` or any package + fixture author one of the ten keys or any undeclared key; objectui's own gantt + fixtures author the ten and keep parsing because the keys are now declared. A + block carrying a key outside the declared set — a misspelling such as + `colourField`, or a renderer knob authored ahead of its declaration — is refused + on upgrade with the key named; fix the spelling, or declare the knob in the spec + first. +- a84e1ce: feat(spec): `II18nService.getFallbackLocale()` — the declared fallback locale is readable, so the metadata-document translators can be handed the chain the deployment declared (#14882) + + `ResolveOptions.fallbackChain` on the `@objectstack/spec/system` label + resolvers (`translateMetadataDocument`, `translateObject`, `translateApp`, + `resolveViewLabel`, …) is the ordered list of locales consulted after the + requested one and BEFORE the authored label. Nothing on `II18nService` + exposed the deployment's declared fallback (`i18n.fallbackLocale`, else + `defaultLocale`), so no serving layer could thread it, and every caller fell + to the resolver's literal `['en']` default. A `zh-CN` workspace that shipped a + courtesy `en` bundle therefore served English bundle text to a `zh-CN` + request ahead of its own authored Chinese labels. + + - New optional contract member `II18nService.getFallbackLocale?(): string | undefined` + — the locale the service's own `t()` consults second. `undefined` (or the + method absent) means nothing was declared, and a serving layer must then + leave the resolver's default in place rather than invent a chain. + - The `fallbackChain` documentation now states who supplies it (the serving + layer, from `getFallbackLocale()`) and that the `['en']` default applies + only when a caller declares no chain at all. The resolver's behaviour for + a caller that passes nothing is unchanged. + + Additive: no existing implementation or caller changes shape. +- bf1054a: feat(spec): retire the fourteen inert deadline keys of the incident-response, training and change-management schemas (#14477, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescriptions are + registered under protocol major 18, where `os migrate meta` users will look). + Maintainer ruling 2026-09-02 on the census card (ruled A: retire per family): + ADR-0049 enforce-or-remove decides it — declared-but-unenforced deadline + surface with zero measured readers comes off. + + Fourteen hour/minute/day-shaped deadline, SLA and duration key sites — twelve + distinct names, because `durationMinutes` and `estimatedMinutes` each occur at + two sites — sat on the exported incident-response, training and + change-management schemas and in the generated reference docs, and **nothing + read them**: the schemas are exported from `@objectstack/spec/system`, mounted + by no stack key, registered as no metadata type, absent from the 2026-06 + liveness ledgers, and the reader census over every package outside + `packages/spec` (tests and changelogs excluded) and over objectui at the + pinned sha returned zero hits for every key. An author could write + `triageDeadlineHours: 4`, `validityDays: 365` or `regulatorDeadlineHours: 72` + and reasonably expect the platform to escalate, expire or notify — it never + did, and it never said so. Six of the keys carried defaults (30 minutes, + 1 hour, 2555 days; 365, 30 and 14 days) that were materialized into every + parsed document without ever being consulted. A compliance-shaped deadline + that fails silently is the worst form of the shape ADR-0049 names. + + **What is refused:** authoring any of the keys below, with any value, on the + base schema and through every carrier that nests it (`Incident.responsePhases[]`, + `IncidentResponsePolicy.notificationMatrix`, `TrainingPlan.courses[]`, + `ChangeRequest.impact` / `.rollbackPlan` / `.implementation`). None of the + schemas is `.strict()`, so each key is a `retiredKey()` tombstone rather than a + bare deletion (a deletion would have stripped it in silence): authoring it is a + `tsc` error (`never`) and a parse error carrying the prescription + (`invalid_type` at the path of the key). + + | schema | retired keys | + |:--|:--| + | `IncidentResponsePhase` | `targetHours` | + | `IncidentNotificationRule` | `withinMinutes`, `regulatorDeadlineHours` | + | `IncidentNotificationMatrix` | `escalationTimeoutMinutes` (default 30) | + | `IncidentResponsePolicy` | `triageDeadlineHours` (default 1), `retentionDays` (default 2555) | + | `TrainingCourse` | `durationMinutes`, `validityDays` | + | `TrainingPlan` | `recertificationIntervalDays` (default 365), `gracePeriodDays` (default 30), `reminderDaysBefore` (default 14) | + | `ChangeImpact` | `downtime.durationMinutes` | + | `RollbackPlan` | `steps[].estimatedMinutes` | + | `ChangeRequest` | `implementation.steps[].estimatedMinutes` | + + **What stays, byte-identical:** every other key of the three families with its + default and its (absent) readers, and every export — no def leaves the public + surface. Parsed documents no longer carry the six former defaults. + + **Held, not touched:** the `ESignatureConfig` pair (`expirationDays`, + `reminderDays` in `data/document.zod.ts`) — the ruling left that branch open + pending the e-signature roadmap answer; it stays on the card. + + ## FROM → TO + + ```ts + // before — parsed green; no engine ever read a single one of these numbers + const policy: IncidentResponsePolicy = { + notificationMatrix: { + rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], + withinMinutes: 15, notifyRegulators: true, regulatorDeadlineHours: 72 }], + escalationTimeoutMinutes: 45, + }, + defaultResponseTeam: 'security_team', + triageDeadlineHours: 2, + retentionDays: 3650, + }; + const course: TrainingCourse = { + id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', + category: 'security_awareness', targetRoles: ['all_employees'], + durationMinutes: 60, validityDays: 365, + }; + const rollback: RollbackPlan = { + description: 'Restore from backup', + steps: [{ order: 1, description: 'Restore backup', estimatedMinutes: 15 }], + }; + + // after — delete the keys; there is no replacement because no incident-response, + // training-management or change-management engine exists to keep a deadline. + // Record retention is the object-level `lifecycle` block (ADR-0057), declared on + // the object that stores the records. + const policy: IncidentResponsePolicy = { + notificationMatrix: { + rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], + notifyRegulators: true }], + }, + defaultResponseTeam: 'security_team', + }; + const course: TrainingCourse = { + id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', + category: 'security_awareness', targetRoles: ['all_employees'], + }; + const rollback: RollbackPlan = { + description: 'Restore from backup', + steps: [{ order: 1, description: 'Restore backup' }], + }; + ``` + + One-line fix: delete the key wherever it is authored. There is no + `os migrate meta` edit list for these keys — none of the schemas is a stack + collection member, so the conversion chain has no seam to walk (the + `MetadataPluginConfig.additionalTypes` precedent); the tombstone prescription + and the protocol-18 upgrade guide are the channels. + + The retirement kit: + + - `retiredKey()` tombstones at all fourteen sites (`packages/spec/src/system/ + incident-response.zod.ts`, `training.zod.ts`, `change-management.zod.ts`; + each file's section comment records what the shape was and why no D2 + conversion exists) + - ADR-0087 registration: fourteen `RETIRED_KEYS_BY_MAJOR[18]` entries (the + three nested change-management sites spelled `ChangeImpact:downtime.durationMinutes`, + `RollbackPlan:steps.estimatedMinutes`, `ChangeRequest:implementation.steps.estimatedMinutes`) + and three D3 semantic entries, one per family + - no liveness-ledger row: none of the three families is an enrolled ledger + type, so there is no row to keep or drop + - pin tests (`deadline-keys-retirement.test.ts`): a refusal pin per site + asserting the issue path, code and prescription on the base schema and + through the nesting carriers; the tsc `never` channel; no-materialize pins + for the six former defaults; the ADR-0087 registration; and a tree-scoped + absence pin over every authored source in the repo + - generated baselines and docs follow the schema: `authorable-surface/` gains + eleven `[RETIRED]` rows, `authorable-defaults/` loses six rows, the three + system reference pages are regenerated, and the gitignored `json-schema/` + output is re-emitted on the next build + - `json-schema.manifest/` is unchanged, and correctly so: it ratchets def + *names*, and retiring keys removes no def from the published surface + - `spec-changes.json` and the protocol upgrade guide are unchanged too: both + project the migration chain at the current protocol major (17), so these + protocol-18 registrations reach them at the 18 cut + - zero authored occurrences in this repo's examples, skills and hand-written + docs, and zero hits in objectui at the pinned sha, so no in-repo source + changes ride along beyond the three families' own unit tests +- 222dc0f: feat(spec): `IJobService.replay` gains an optional third argument, `options?: JobReplayOptions`, carrying `force: true` (#14766 — the contract half of the #14501 A+a2 ruling) + + Additive: the argument is optional, an existing two-argument `replay(name, data?)` implementation keeps compiling and behaving as before, and omitting it is the pre-#14766 call exactly. `JobReplayOptions` is exported from `@objectstack/spec` (`contracts`), with one member, `force?: boolean`. + + **What the contract now declares** (`packages/spec/src/contracts/job-service.ts`, the `replay` TSDoc), for a scheduled (cron) flow whose tick window takes a `(flow, tick-window)` dispatch claim in `sys_flow_dispatch`: + + - `replay(name, data)` on a window whose claim is **absent or failed** re-runs the window — unchanged behaviour, and every job that never takes a claim is this row; + - `replay(name, data)` on a window whose claim **succeeded** is **refused loudly**: the promise rejects with an ADR-0112 envelope — `code: 'RESOURCE_CONFLICT'` (the standard-catalog member HTTP 409 derives; no new extension code) and `status: 409` — whose message names the window asked for and the claim that refused it. Never a silent no-op; + - `replay(name, data, { force: true })` sends anyway; the duplicate is the operator's, taken knowingly. + + **Declared here, enforced by #14501.** This release changes the contract text and the signature only. The refusal semantics are implemented by the services half (#14501: the `(flow, tick-window)` claim through `sys_flow_dispatch`, and `DbJobAdapter.replay` reading it); until that lands, shipped adapters still accept the third argument and ignore it, re-running the window as before. A third-party `IJobService` implementation that already declares `replay` needs no change to keep compiling; one that wants the once-only guarantee implements the table above. +- e9fcd6b: feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478) + + + + **BREAKING** — fourteen published `kernel/` duration keys are renamed and + tombstoned. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescriptions are registered under protocol + major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, + 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`. + Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**, + and the whole-tree count falls **36 → 22**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `EventPersistence.retention` | `retentionDays` | days | + | `EventSourcingConfig.retention` | `retentionDays` | days | + | `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds | + | `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds | + | `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds | + | `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds | + | `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds | + | `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days | + | `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours | + | `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds | + | `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds | + | `StartupOptions.timeout` | `timeoutMs` | milliseconds | + | `PluginStartupResult.duration` | `durationMs` | milliseconds | + | `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds | + + **Every value is unchanged** — only key names move, and every default moves with + its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to + 365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the + authoring site (input type `never`) and fails the parse with the rename + prescription rather than a bare unrecognized-key error. + + ## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace + + **`responseTime` meant two different units on two kernel shapes.** On + `PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a + publisher promises to answer a vulnerability report); on + `PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So + `responseTime: 24` was a day on one shape and a fortieth of a second on the + other, with nothing at the authoring site to tell them apart. They land on + `responseTimeHours` and `responseTimeMs` respectively — do not let one + find-and-replace rewrite both. + + **`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision + was already costing prose: the protocol lifecycle page carried a standing + paragraph whose only job was telling the two apart. `metrics.uptime` becomes + `metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a + separate, unchanged surface and must not be renamed with it. + + A third split worth reading before you migrate: `estimatedDurationSeconds: 120` + is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent + measurements of the same package install carried two different units, and no + parse can catch a value moved between them — both bounds accept any + non-negative integer. + + ## Dispositions — five semantic entries, no D2 conversion + + Justified per key rather than defaulted, and this card's answer is uniform: + **none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs + over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`, + `upgrade` or plugin-security root — none of these twelve defs is a stack + collection member or a registered metadata kind stored as a `sys_metadata` row, + so the conversion chain has no seam that would see one. They are host + construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`, + `MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and + runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`, + `StartupOrchestrationResult`, `UpgradePlan`, + `PackageDependencyResolutionResult`). Each therefore carries a **semantic** + entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on + one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling + B prescribes for a key that is not authorable metadata. All fourteen are + registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + + ## Keys deliberately left alone + + `EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and + `MultiVersionSupport.rollout.percentage` is a proportion — neither is a + duration, so neither has a unit to carry and both keep their names. + `RuntimeConfig.resourceLimits.timeout` names its unit only in the JSDoc above + the key ("Execution timeout in milliseconds"), a channel + `check:duration-unit-keys` does not read: it reads `.describe()` and + `.meta({ description })`, and this key's describe ("Maximum execution time") + names none. The gate therefore lists it among the duration-shaped keys but + deliberately does not judge it — neither an offender nor an exemption — so it is + outside this rename; that JSDoc-channel gap is filed as #15939. A pin test + asserts the key still parses bare, so a later sweep cannot read the four + security renames as "every timeout on that file". + + ## Readers moved in the same PR, at the same magnitude + + `@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() - + startTime`), the kernel and contracts test suites, and the hand-written + `content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now + states the collision the rename removes. + + ⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local + `PluginStartupResult` interface — a different type, carrying `startTime` rather + than any duration key. It is not a reader of this schema, it is untouched by + this rename, and the divergence between the two shapes is tracked separately. +- f9a3c32: feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall + + `BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). + + Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. + + - **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. + - **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. + - **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). + + What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. +- f502898: feat(spec): list-view grouping is server-side — the group header query and the per-group row page compile from the view (#14556) + + Maintainer ruling A on objectui#7189 (2026-09-02): grouping on a list view is + server-side. The set of groups and every number in a group header — the count + and any per-group aggregation — are properties of the query, not of the fetched + page; rows inside a group are paged. Grouping one fetched window (the interim + behaviour) rendered two headers (86, 14) or five (31/31/30/7/1) for the same + 186 rows in five units depending on row order, and left the rows past the + first window unreachable. + + The contract reuses the query shapes the platform already has — no new query + shape, no new engine verb, no new envelope: + + 1. **The group keys and every header number are ONE aggregate query** + (`EngineAggregateOptions`, executed by `IDataEngine.aggregate`): `groupBy` + is `grouping.fields[].field` in nesting order (a multi-level grouping is a + multi-column `groupBy`), `aggregations` is a `count` node (the group's total + row count, alias `count`) plus the view's declared column summaries mapped + onto `AggregationFunction` — the one aggregation vocabulary datasets already + use — and `where` is the view's composed filter. + 2. **The rows inside a group are the existing paged `find`** + (`EngineQueryOptions`) with the group's key predicate AND-ed into the view + filter, `limit` / `offset` per group. + + New on the `ui` entry, `view-grouping-query.ts`: + + - `compileListViewGroupQuery(view, { where?, depth? })` → the header query; + `compileListViewGroupRowsQuery(view, groupKey, { where?, limit?, offset?, orderBy?, fields? })` + → the row page; `listViewGroupKeyPredicate` (the empty group is spelled with + the `$null` predicate — the spelling the view filter dialect's `is_empty` + lowers to). + - `COLUMN_SUMMARY_AGGREGATION` — the `ColumnSummary` → aggregation table, + exhaustive by type: `count` → a fieldless `count` (`COUNT(*)`), + `count_unique` → `count_distinct`, `sum` / `avg` / `min` / `max` → the same + name, `none` → nothing; `count_filled` / `count_empty` / `percent_filled` / + `percent_empty` map by derivation — one `{ function: 'count', field }` node + (`COUNT(field)`, the non-null count, header column `count_`), from + which `deriveColumnSummary(row, summary, field)` computes all four on the + header row (`count_filled` = `count_`, `count_empty` = `count − + count_`, `percent_filled` = `count_ / count`, 0 when the count + is 0, `percent_empty` = `1 − percent_filled`). Server-side "empty" is `null` + on every face; the footer's client-side reading of `''` / `[]` as empty is + the renderer's to converge. A future member with no counterpart is refused + loudly at compile time (`ListViewGroupQueryError`, `NOT_IMPLEMENTED` / 501, + the summary's path — `UNMAPPED_COLUMN_SUMMARIES`, empty today); a value that + is no member at all is `INVALID_QUERY` / 400. + - Result-column naming on a header row: each grouped field under its own name + (raw stored value, `null` for the empty group; group keys are scalar), `count`, + and each summary under `_` (`columnSummaryAlias`). + + `GroupingConfigSchema` / `GroupingFieldSchema` / `ColumnSummarySchema` now say + this in their docs, with the shape's recorded limits (a date grouping field + groups per distinct stored instant; header cardinality is unbounded). Nothing + changes in what parses: no key is added, removed or re-shaped. `minor` because + a new exported helper and a declared contract semantics ship; not breaking — + the page-scoped behaviour was never declared. Both queries ride the existing + `POST /data/:object/query` door (`protocol.findData` → `engine.aggregate`, + answering `{ object, records, total, hasMore }`); the grid consuming the header + rows is objectui#7189. +- cf9bda4: The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) + + `i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one. + + The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key. + + What changed: + + - **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with. + - **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged. + - **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared. + + A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix. + + `getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. +- 784cb92: feat(spec): every `metadata.endpoints.*` switch gates exactly the face its name states, and the whole-store operations get their own key `maintenance` (#15542, #15854) + + `RestServerConfig.metadata.endpoints` declared three switches, each `describe()` naming + exactly one route, and each gated a different set. The mismatch ran in **both** + directions at once: + + - **`items`** — declared "GET /meta/:type - List items of type" — also gated the + whole-store family: the cross-type spec-validation sweep `GET /meta/diagnostics`, the + draft list `GET /meta/_drafts`, and the **`POST /meta/_migrate-stored` write door**. + An operator who switched off a listing read they considered chatty silently unmounted + a migration door. + - **`item`** — declared "GET /meta/:type/:name - Get specific item" — gated four + *reads* (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and left the + per-item **writes** `PUT` and `DELETE /meta/:type/:name` plus the whole history family + (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) answering to + `api.enableMetadata` alone. An operator who closed the per-item surface left its + writes mounted. + + Neither is a liveness defect — all three keys were genuinely read — which is why no + ADR-0049 census could ever flag them: what drifted was each key's **radius** against its + own documentation. + + **One principle now holds across the block: a switch gates exactly the face its name + states, reads and writes alike.** + + | key | mounts it gates (default prefix `/meta`) | + |---|---| + | `types` | `GET /meta`, `GET /meta/types` — one handler, two paths (unchanged) | + | `items` | `GET /meta/:type` — and nothing else | + | `item` | `GET` / `PUT` / `DELETE /meta/:type/:name`, `/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`, and `GET /meta/book/:name/tree` | + | `maintenance` | **new** — `GET /meta/diagnostics`, `GET /meta/_drafts`, `POST /meta/_migrate-stored` | + + All four `describe()` strings are rewritten to enumerate what they gate, so the + generated reference page is the radius rather than a sample of it. + `api.enableMetadata` remains the master switch above all four, and + `GET /meta/object/:name/state/:field` — the object FSM read, addressed by object name + rather than by `:type/:name` — deliberately stays under that master switch alone. + + **BREAKING** — for a programmatic embedder that authors `RestServerConfig.metadata.endpoints`, + the mounted route table moves for two of the four keys, in opposite directions: + + - **`items: false` now removes one route instead of four.** An embedder relying on it to + close `/diagnostics`, `/_drafts` and the `POST /_migrate-stored` door **regains all + three** unless it also sets `maintenance: false`. That is a write door coming back, so + it is the half to read twice. One line restores the old table: + `endpoints: { items: false, maintenance: false }`. + - **`item: false` now removes twelve routes instead of four.** An embedder relying on it + to close only the per-item *reads* while keeping `PUT`, `DELETE` and the history family + mounted **loses those eight**. There is no key that restores them — the per-item face is + one face by this ruling — so an embedder that wants the writes keeps `item` on and + closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`. + - **The exported type `MetadataEndpointsConfigParsed` narrows: `endpoints` gains a + REQUIRED member `maintenance: boolean`.** `maintenance` is `z.boolean().default(true)`, + so it is optional on the way *in* and always present on the way *out* — and + `MetadataEndpointsConfigParsed` is `z.infer`, the + OUTPUT side. Any code that builds one of these objects by hand — a test fixture, a + helper returning the parsed shape, a `satisfies MetadataEndpointsConfigParsed` literal — + stops compiling with `TS2741: Property 'maintenance' is missing`. This one IS + compiler-carried (the ADR-0087 D8 class), which is the good case: the break is loud, it + lands at build time, and no runtime behaviour depends on the author noticing a + changelog. Add `maintenance: true` to restore the previous mounts, or `false` to keep + the whole-store family closed. In-repo consumers of the type: none — the narrowing was + measured against a probe compiled from the rebuilt declaration, not assumed. + + Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no + shipped boot path** today (`os serve` fixes the config and the dev plugin passes none, + #15543), so the measured population of affected authors is **zero** and the blast radius + is programmatic embedders only. That is precisely why this lands now — once a boot path + starts authoring the config, the same change becomes a behaviour change on live + operators. + + **ADR-0087 disposition: a D3 semantic migration, no D2 conversion.** No + authored key changes shape or spelling — `items: false` still parses to `items: false`, + `maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema` + stays the #14691 tombstone it already was). There is nothing for the conversion layer to + convert: a `RestServerConfig` is plugin TS configuration, never a stack collection member + and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no + rehydration seam sees it. What changes is a mounted route table at construction time. + + Nor is the RADIUS change compiler-carried on the AUTHORED side — and that is the half a + D3 is owed for. Every authored key is an optional boolean, so `{ items: false }` still + compiles and still parses and simply mounts a different table: the author is told + nothing. (The parsed-type narrowing in the third BREAKING bullet above *is* + compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot + reach the embedder who authored `{ items: false }` and now silently gets three routes + back.) So for the change that actually moves the route table, both channels that would + otherwise reach a consumer are blind, which is precisely the residue D3 exists for — the prescription is registered as + `metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands + it to an upgrading embedder instead of leaving it as prose in a changelog. + + + + `@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where + the gates live, so the route-table change is observable there and not only in the + declaration. + + Every key's radius is pinned route by route, in both directions, in + `packages/rest/src/rest-config-mount-table.pin.test.ts` — the #15544 shape, which asserts + each route is **absent from the mounted table** when its switch is off rather than what + the switch normalizes to. A gate that grows or loses a route reddens there. +- 7629f4d: feat(spec)!: retire the three inert outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (formerly `ttl`) and `maxSize` — read by nothing; `cache.databaseLoader` is the only live half (#15624, ADR-0049) + + + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + ADR-0049 enforce-or-remove decides it: a declared-but-unenforced key with zero + measured readers comes off, and the published reference page stops teaching it. + + `MetadataManagerConfig.cache` declared three outer knobs — `enabled` (default + `true`), `ttlSeconds` (default 3600; spelled `ttl` until #14478) and `maxSize` + ("Max cache size in bytes") — beside the nested `databaseLoader` block, and + **nothing read the outer three**. The only runtime consumer of the block is + `MetadataManager` (`packages/metadata`), which hands `cache.databaseLoader` and + nothing else to `new DatabaseLoader({ cache })`; a reader census over + `packages/**` (tests and changelogs excluded) found no runtime reader of any + outer key, while the same grep shape found the nested `cache?.databaseLoader` + read twice — the control that makes the zero a measurement. An author writing + `cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse + and a cache that behaved exactly as before, with no error and no warning, and + the published reference page (`references/kernel/metadata-loader`) documented + all three as if they configured something. + + **What is refused:** authoring `cache.enabled`, `cache.ttlSeconds`, `cache.ttl` + or `cache.maxSize` on `MetadataManagerConfig`, with any value — directly, through + `MetadataManagerOptions`, or through `MetadataPluginConfig.storage`. The nested + object is not `.strict()`, so each key is a `retiredKey()` tombstone rather than + a bare deletion (a deletion would have stripped it in silence — the same no-op + one layer down): authoring it is a `tsc` error (`never`) and a parse error + carrying the prescription, which names the live nested knob. + + **What stays, byte-identical:** the DatabaseLoader read-through cache under + `cache.databaseLoader` — `enabled` (default `true`), `maxSize` (an entry count, + default 500) and `ttlMs` (milliseconds, default 60000) — and every runtime + path. Parsed configs no longer carry the two former defaults (`enabled: true`, + `ttlSeconds: 3600`) that were materialized and never consulted. + + **The #14478 rename is folded in.** `cache.ttl` → `cache.ttlSeconds` was + registered under this same unreleased major and never reached a published + release, so it is absorbed by the removal: `cache.ttl`'s tombstone now + prescribes deletion (naming `cache.databaseLoader.ttlMs`) instead of a rename to + a key that is itself retired — an author upgrading from a published 17.x sees + one hop. The nested `cache.databaseLoader.ttl` → `ttlMs` half of that rename is + unchanged. + + ## FROM → TO + + ```ts + // before — parsed green; no runtime ever read the three outer numbers + new MetadataManager({ + datasource: 'default', + cache: { enabled: true, ttlSeconds: 3600, maxSize: 10_485_760, databaseLoader: { ttlMs: 60_000 } }, + }); + + // after — delete the outer keys; the nested block is the cache that runs + new MetadataManager({ + datasource: 'default', + cache: { databaseLoader: { enabled: true, maxSize: 500, ttlMs: 60_000 } }, + }); + ``` + + **Migration.** Delete `cache.enabled`, `cache.ttlSeconds` / `cache.ttl` and + `cache.maxSize`; nothing replaces them, because nothing ever consumed them. If + you meant to switch the cache off, cap it or set its TTL, write + `cache.databaseLoader.enabled` / `.maxSize` (entries) / `.ttlMs` (milliseconds) + — those are honoured. No `os migrate meta` conversion runs on this surface: a + `MetadataManager` config is not a stack collection member and never a stored + row, so the chain has no seam for it; the D3 semantic entry + `metadata-manager-config-inert-cache-keys-retired` carries the prescription + into `spec-changes.json`, the upgrade guide and the `spec_changes` MCP tool. + + The retirement kit: `retiredKey()` tombstones on all three (and the absorbed + `ttl`), `RETIRED_KEYS_BY_MAJOR[18]` entries for each, the D3 semantic entry + above (the #14478 entry's outer half is re-worded from a rename to a deletion), + negative pins asserting each prescription and a positive pin asserting the + parse output no longer materializes the retired defaults, the published + reference pages regenerated, and the hand-written docs page and this package's + README (`@objectstack/metadata` ships `README.md`, hence its `patch`) no longer + authoring `cache.enabled`. +- a7da4de: feat(spec): `adr-0030-notification-event` joins `CREATION_ATTESTED_MIGRATION_IDS`, and its docblock states what a run may claim in the `sys_migration` ledger (maintainer ruling 2026-09-05 on #15710) + + The ADR-0030 notification-convergence migration id was registered so that + "has this cut-over run here?" is answerable at all, with its ledger semantics + deliberately left open on the constant. The maintainer has now ruled them + (decision batch #47 item 5, verbatim 「同意」 — the question batch #21 reserved), + and this release lands the spec half: + + - **Creation-attested.** A datastore created after the cut-over has no legacy + `sys_notification` inbox rows by construction, so the id is now a member of + `CREATION_ATTESTED_MIGRATION_IDS`. A store created from empty on this release + therefore carries a third attestation row in `sys_migration` at boot, in the + same uniform shape as the two ADR-0104 rows (`details.attested: + 'datastore-created-empty'`, `applied_at: null`, `blocking: 0`, `verified_at` + set for the fact observed at birth). Existing stores are untouched: + `attestFreshDatastore` writes only on a store it observed being created and + never overwrites a row, so a store created before this release attests + nothing new — its row for this id arrives with the first run of the migration. + - **The ledger-claim matrix**, on the constant's docblock, replacing the + registration-era "silence is not an answer": `last_run_at` on every completed + non-`error` run (`migrated`, `already_done`, `not_applicable`); `applied_at` + only on `migrated`; `verified_at` never set by a run (the migration has no + self-check, and `verified_at` means one passed); `blocking: 0`; + `details.outcome` carries the four-valued result; an `error` run writes no + claim at all. + - **Receipt, not gate.** Nothing reads the row as a precondition, and nothing + may: it is what an operator reads, in the shape the seed-tenancy repair + already uses (`verified_at: null`, `blocking: 0`), which + `isDataMigrationFlagVerified` answers `false` to by design. + + Additive: no authorable key, export or accept-set narrows, so no BREAKING + banner applies. Which caller writes the run receipt when the migration runs is + the runner's own contract (`@objectstack/metadata/migrations`) and lands + separately. +- de0bcdd: feat(spec): `ComponentPropsMap['object-kanban']` declares `limit`, the row cap four objectui faces already implement (#16503, the spec half of objectui#8172) + + `object-kanban` gains one optional authorable key: + + ```ts + limit: z.number().int().positive().optional() + ``` + + Maximum number of records loaded onto the board (row cap), lowered to the top-level `$top` of the board's one query. The renderer default stays 100 and is documented rather than declared, so an unset key remains unset. The component-level `dataSource.limit` wins when both are set, and a bound named view's `pagination.pageSize` fills the key only when the component authored none — the `ElementDataSourceGate` precedence table, unchanged. + + Measured at the objectui pin this repo builds against (`.objectui-sha` = `a472b0716`): `plugin-kanban` reads `schema.limit` as the query's `$top` (wired by objectui#4025), `OBJECT_KANBAN_DATA_SOURCE` maps `limit: 'limit'`, `KanbanSchema` declares `limit?: number`, and `content/docs/plugins/plugin-kanban.mdx` teaches it with a typed snippet (`limit: 250`) plus a Properties row. The strict props map refused the key by name, so an author following the published docs wrote a node the save gate rejected with the same `unrecognized_keys` verdict a typo gets. Decision batch #68 (2026-09-07, option A): the contract declares the capability that is already implemented, documented and in use. + + Widening a published accept set (Clause-② yes): `safeParse({ objectName: 'x', limit: 250 })` now succeeds; every other undeclared key on the node is refused exactly as before. objectui#8172 publishes the key in the registry declaration on its side. +- 554a160: `translatePage` now reads a region-level `page:header` by **page name only**. The id route + (`pages..components..*`) is no longer read for that component, even when it carries an `id`. + + **Behaviour change, stated plainly:** a bundle that overrode a region-level header's title through + `pages..components..title` now falls back to `pages..title` (which itself falls back to + `pages..label`). The components key still parses — nothing is removed from `TranslationBundleSchema` — it is + simply no longer the address for this one component. + + ``` + FROM pages..components..title # region-level page:header — no longer read + TO pages..title # …and .subtitle for the subtitle + ``` + + Fix in one line: move the string from the components entry up to the page's own `title` key, and delete the + components entry for that header id. `os i18n extract` has always offered exactly the `TO` key, so a bundle + generated or checked by the CLI already writes it. + + **Blast radius, as measured on the card (inherited, not re-measured here):** HotCRM found **zero** such overrides — + all five of its region-level headers carry ids and none writes the components key. + + **Why.** Both sides were deliberate and they disagreed. The extractor skips a region-level `page:header` on purpose + (its copy is offered under `pages..title` / `.subtitle`, and emitting it twice would put one string under two + keys); the resolver read the id route on purpose (the more specific route wins). Together they produced the exact + failure `walkAddressedPageComponents` was extracted to prevent — the resolver reading an id the extractor omits — so + the key an author reached for won silently while the key the tooling reported as translated lost. The maintainer + ruled (2026-09-06, decision batch #58, verbatim 「同意」) that the page-name route is canonical: one component, one + address. `title` and `subtitle` now follow the same rule, closing the asymmetry where `title` had two addresses and + `subtitle` — never in `PAGE_COMPONENT_COPY_KEYS` — had one. + + Unchanged: a `page:header` **nested** inside a container is reached by the id route only, as it always has been; and + a region-level header's id still claims its bundle entry and still blocks a nested namesake, which is what the + extractor does too. + + Not `major`: nothing an author can write is removed or renamed. `pages..components.` remains a declared, + parsing, resolving address for every other component — including a nested `page:header` — so there is no key to + tombstone and no ADR-0087 conversion to register. Recorded here so the choice is checkable rather than assumed. +- 2a3decc: `PluginSchema` now REQUIRES `staticPath` and `slug` when `type` is `'ui'`, and core's `Plugin` interface inherits every `PluginSchema` key from `PluginDefinition` instead of restating two of them. + + **BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). `packages/spec/src/kernel/plugin.zod.ts` described `staticPath` and `slug` as *"Required for type=\"ui\""* while declaring both `.optional()`, with nothing behind the prose; since `kernel.use()` runs the schema on the boot path (#16049), that was a promise the runtime visibly did not keep. This is the spec half of #16049, split by director ruling (decision batch #58, 2026-09-06). + + **Exactly what is newly refused.** A plugin object with `type: 'ui'` that omits `staticPath`, omits `slug`, or spells either as `undefined`. Nothing else: every other declared type (`standard`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`), and a plugin declaring no `type` at all, still parses with neither key. A PRESENT value is judged exactly as before — `slug` keeps its `/^[a-z0-9-_]+$/` regex, `staticPath` stays any string, and the empty string is not refused by this change. + + **What a refusal looks like.** One zod issue per missing key, `path` naming the key, the new stable code `PLUGIN_UI_REQUIRED_KEY_MISSING` (exported from `@objectstack/spec/kernel`) at the head of the issue `message` and on the issue's `params.code`. At `kernel.use()` it rides the existing `PLUGIN_CONTRACT_VIOLATION` envelope unchanged, because the loader surfaces the first issue's `path` and `message` and reads nothing else: + + ``` + PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared + plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` + plugin must declare `staticPath` — the absolute path of the static assets it + serves. Declare it, or drop `type: 'ui'` if this plugin serves no assets. + ``` + + **The fix for an affected plugin** is the one the message names: declare both keys (`staticPath`: the absolute path of the assets it serves; `slug`: the URL segment it is mounted under), or drop `type: 'ui'` if the plugin serves no assets. There is no fallback to lean on: the Hono server's `slug || name.split('/').pop()` derivation is no longer reachable through the kernel, because the object is refused before it is stored. + + **`@objectstack/core` — `Plugin` derives its metadata keys.** `Plugin` now `extends PluginDefinition` (`z.input`), so `id`, `type`, `staticPath`, `slug`, `default`, `version`, `description`, `author` and `homepage` are ONE declaration shared with the schema the kernel enforces. Additive for every existing implementer: `type` and `version` keep the shapes they had (`type` is still `PluginType | undefined`, pinned type-equal in `packages/rest`; `version` still `string | undefined`), and the seven other keys are new optional members. A `ui` plugin can now carry `staticPath` / `slug` without widening its own type. Runtime-only members (`name`, `dependencies`, `optionalDependencies`, `requiresServices`, `providesServices`, `init`, `start`, `destroy`) stay declared on the interface. + + **Blast radius, measured.** No in-repo plugin object outside test fixtures declares `type: 'ui'` (searched `packages/`, `apps/`, `examples/` non-dist sources for a `type` key or class field holding the literal `'ui'`: three test files, nothing shipped), so no in-repo composition changes behaviour. Externally authored `ui` plugins that relied on the slug derivation, or declared no assets, are the population this reaches — and they are refused at boot, by name, with the key to add. + + +- 4db3c61: `publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. + + `isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. + + Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. + + Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. +- e0af1a8: feat(spec)!: `` / `` are retired from the react-tier component contract — `data={{ provider: 'object', object }}` / `type` are the only spellings (#14791) + + + + **BREAKING** — an accept-set narrowing on a published contract. The `REACT_BLOCKS` + ListView entry no longer publishes the `objectName` and `viewType` overlay props that + #11284 had deprecated in favour of ListViewSchema's own `data` / `type`: the generated + contract (`skills/objectstack-ui/references/react-blocks.md`) drops both rows, and + `@objectstack/lint`'s `validate-react-page-props` now REFUSES either spelling on a + `kind:'react'` page with a new `react-prop-retired` error that carries the fix, where it + used to warn and accept. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescription is registered under protocol major 18 + (`ui-react-list-view-binding-aliases-retired`). Maintainer ruling on #14791 (2026-09-07, + director seat summon #17, decision batch #1, option B — retire now, no deprecation window, + 「同意」). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `` | `` | + | `` | `` | + | `` with no binding at all | add `data={{ provider: 'object', object: '…' }}` — it is the required binding on a react page | + + One-line fix: on every `` in react page source replace `objectName="X"` with + `data={{ provider: 'object', object: 'X' }}` and `viewType="K"` with `type="K"`, then re-run + `objectstack validate` — a leftover alias is reported as `react-prop-retired` with this + same prescription, and a list with no data source as `react-prop-missing-required`. + + ## Why now, and why no window + + The contract deprecated both aliases (#11284) while objectui's ListView still read only + `objectName`, so a page written the canonical way validated green and rendered an empty + list. That consumer half has landed and ships in the console this repo pins + (`normalizeListViewSchema` at `a472b071` folds `data.provider === 'object'` onto the key + the renderer reads and takes the author's `type` for the view kind), so both spellings + render today — and the maintainer's standing rule for a spelling with zero external + authors is to retire it at once rather than keep two vocabularies alive. + + ## What else moved + + - `REACT_RETIRED_OVERLAY_PROPS` is a new export of `@objectstack/spec/ui`: the tombstone + ledger (prop → replacement + one-line fix) the lint quotes, the react-tier twin of a + metadata schema's `retiredKey()`. + - `data` is restated on the ListView overlay as its **required** binding (ledgered in + `REACT_OVERLAY_SHADOWS`), so the generated contract marks it ✓ and a `` with no + data source is refused — the check the required `objectName` used to carry. + - `REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']` prescribes the canonical spelling. + - The showcase pages (`crm-workbench`, `renewals-pipeline`, `task-desk`), the published + `objectstack-ui` skill and the react-pages / validating-metadata guides write the + canonical spelling; `@objectstack/lint` exports `REACT_PROP_RETIRED`. +- 414c1fc: feat(spec)!: `ComponentPropsMap['element:record_picker'].filter` converges onto the `ViewFilterRule` array form — the last record-form `filter` in the map (#14406, objectui#6206 Option B) + + + + **BREAKING** accept-set change on one props-map entry, shipped as `minor` under + the repo's launch-window convention for breaking changes; the migration + prescription is registered under protocol major 18. + + One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, + verbatim 「同意」, Option B): after `element:number` converged (#12039 Key 2), + `element:record_picker`'s `filter` was the one `filter` input in + `ComponentPropsMap` still declared as the MongoDB-style record + (`FilterConditionSchema`) while the three array-declared siblings + (`record:related_list`, its nested Add-affordance picker, `element:number`) + declared `z.array(ViewFilterRuleSchema)` — the four `object-*` doors declare + `filter` as `z.unknown()`, #15449 — so the filter a list view stores and + renders was refused by the picker beside it. The entry now declares the same + array form those siblings do, and the `FilterConditionSchema` import that existed for this + one site leaves the file with it. + + Sequenced measurement-first, as that convergence had to be: the `record_picker` + read path was measured at the objectui pin before the declaration moved. The + renderer hands `filter` to `query.$filter` and calls `adapter.find()`, whose + `convertQueryParams` lowers a rule array through `translateFilterArray` into + filter AST tuples — the door every list view's stored rule array already takes + — and nothing on that path parses `properties` against the installed spec. + + **Migration** (`element-record-picker-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 `{ amount: { $gt: 100 } }` becomes + `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys + become several rules (they AND). The record form is refused at `filter` + (`invalid_type`, expected array). The binding-level `dataSource.filter` on the + same node is a different key and is unchanged by this release. + + `ElementRecordPickerPropsParsed` is declared (ADR-0122): the entry's parsed + state now differs from its authored state on `filter` (`operator` normalizes on + parse), so the bare alias is no longer isomorphic. +- 92b5d7f: `POST /api/v1/packages` now answers an install-time namespace collision with `error.code: "NAMESPACE_CONFLICT"`. `NAMESPACE_CONFLICT` is registered in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`) gains one member and a caller can branch on the refusal directly. + + **The wire, before and after** — measured through the shipped door (`HttpDispatcher.handlePackages` over a real `SchemaRegistry`), not derived from the call graph: + + - before: `422` with `error.code: "VALIDATION_ERROR"` and `error.declaredCode: "NAMESPACE_CONFLICT"` + - after: `422` with `error.code: "NAMESPACE_CONFLICT"` and **no** `declaredCode` — with the spelling registered there is nothing left to demote + + The status, the message and the throw are unchanged. `NamespaceConflictError` (`@objectstack/objectql`'s `SchemaRegistry.installPackage`, ADR-0048 Phase 1 / ADR-0130 D1) has carried `code` and `status: 422` since the envelope landed; what changed is that the door's #9106 narrowing no longer demotes the spelling. Until now a caller wanting to tell "your namespace is taken, rename it" from every other `422` had to read `declaredCode` — the channel ADR-0112 declares as the open, not-guaranteed one — because `error.code` carried the generic member `422` derives. + + Scope of the widening: one new accept value on `ApiErrorSchema.code`; no export changes, no schema-shape changes, and nothing narrowed. A consumer that treats `error.code` as a closed set it enumerates locally will see a value it does not know, which is what a vocabulary widening means and why this is a `minor`. + + The now-discharged `pending-registration` row ratchets out of `packages/runtime`'s dispatcher-error-vocabulary table in the same change — registration is what makes that row stale, and `pnpm check:dispatcher-error-vocabulary` fails on a registered code still carrying one. The door's answer is pinned in `packages/runtime/src/package-door-namespace-conflict-code.test.ts`, which drives the real route and asserts the body, so the reachability the removed row asserted is now held by a test rather than by a claim. +- 613bfbd: feat(spec): register the fourteen remaining `door: 'none'` error codes that ship in `dist` — the rest of the #16404 class after #16449 enters `ERROR_CODE_LEDGER` (#16649) + + Under the #16404 ruling (director seat, decision batch #62, 2026-09-07, option D; maintainer 「同意」) **the published contract face for error codes is `ERROR_CODE_LEDGER` / `StandardErrorCode`**: every `code` that ships in a package's `dist` is registered there, door or no door, because a consumer's `catch (e) { switch (e.code) }` pins the spelling the moment it ships. #16449 registered the nine codes measured on its tree; fourteen more were still shipping unregistered — every `boot-refusal` row `dispatcher-error-vocabulary.ts` carried — and now have rows, each under the package that stamps it: + + | code | stamped by | `status` on the thrown value | reaches an HTTP door on this tree? | + |---|---|---|---| + | `INVALID_ARTIFACT_PACKAGES` · `INVALID_ARTIFACT_PACKAGE_ENTRY` · `DUPLICATE_ARTIFACT_PACKAGE` | `@objectstack/core` (`resolveArtifactPackageOrder`, ADR-0130 D4/D5) | 422 | no — boot-time `manifest.register()` aborts boot; the install route answers with its own `PLUGIN_REGISTER_FAILED` | + | `NO_SUCH_RUN` · `PLAN_CHANGED` · `PREFLIGHT_FAILED` · `NOT_COMPENSABLE` | `@objectstack/core` (`MigrationJournalRefusal`, the migration-journal runner) | none | no — caught by the CLI's `migrate` commands with `instanceof` and printed | + | `SERVICE_NOT_REGISTERED` | `@objectstack/core` (`PluginLoader.getService`'s "never registered" rejection) | none, by design | no — read in-process by the seam that catches the rejection | + | `PLUGIN_CONTRACT_VIOLATION` | `@objectstack/core` (`assertPluginContract`, raised at `kernel.use()`) | none | no — raised while the kernel is still registering plugins | + | `MIXED_ARTIFACT_COLLECTION_SHAPE` | `@objectstack/runtime` (`resolveArtifactCollections`, ADR-0130 D4) | 422 | no — every call site resolves before a transport exists | + | `DUPLICATE_ARTIFACT_OBJECT_NAME` | `@objectstack/objectql` (`SchemaRegistry.installPackage`, ADR-0130 D3) | 422 | no — the HTTP install sites pass no artifact scope, so they cannot raise it | + | `MEMORY_MULTI_TENANT_UNSUPPORTED` | `@objectstack/driver-memory` (the tenancy guard) | none | no — a boot refusal the CLI rethrows pre-HTTP | + | `MONGODB_MULTI_TENANT_UNSUPPORTED` | `@objectstack/driver-mongodb` (the tenancy guard) | none | no — a boot refusal the CLI rethrows pre-HTTP (registered by #3724, unregistered by #8035, re-registered here under the ruling) | + | `WALLED_MEMBERSHIP_POLICY_UNDECLARED` | `@objectstack/organizations` (the walled-posture membership-policy gate, `kernel:bootstrapped`) | none | no — fires before `kernel:listening` opens the socket | + + **Wire consequence, stated plainly.** Registration changes what a client reads only where a code reaches an HTTP door: `error.code` would carry the specific code instead of the standard member the status derives, with the producer's spelling no longer demoted into `declaredCode`. Re-measured on this tree at the sites each `boot-refusal` row named (the table's last column is that reading, one line per group), **none of the fourteen has such a door**, so **no HTTP body changes with this release**. What changes is the face: `ErrorCode` — the union `ApiErrorSchema.code` parses against — gains fourteen members, `REGISTERED_ERROR_CODES` lists them, the ledger gains two owner keys (`@objectstack/driver-mongodb` returns after #8035 removed it; `@objectstack/organizations` is new), and each refusal's `e.code` is now a member of the union a consumer's exhaustive `switch` is written over. Should a door ever answer with one of them, the wire carries the specific code from then on. + + **`MONGODB_MULTI_TENANT_UNSUPPORTED` is a deliberate reversal, not drift.** #8035 unregistered it on the ground that "host boot matching is not wire vocabulary"; the #16404 ruling supersedes exactly that ground (the ledger header's "Retiring a code" section records both halves), and the test that pinned its absence now pins its presence. What still retires a row is a code with no producer left in `packages/**` — `OVERLAY_PERSISTENCE_FAILED` (#5783) remains the pinned witness of that class. + + **Why `minor`, and no `BREAKING` banner.** Nothing is removed or renamed; every existing body parses exactly as before. The change is a purely additive widening of a published surface (fourteen new `ErrorCode` members, two new owner keys), which the 2026-09-04 ruling on #15294 requires to be at least `minor`. The one consumer-visible cost is type-level: an exhaustive `switch` over the `ErrorCode` TYPE gains fourteen cases to cover. + + The fourteen `boot-refusal` classification rows in `dispatcher-error-vocabulary.ts` ratchet out with the registrations (the gate reports a registered code's row as `stale-row`), their reachability reading now carried on the ledger rows; that module is not part of `@objectstack/runtime`'s published entry, so nothing in that package's `dist` moves. The `boot-refusal` verdict itself stays declared for a future pre-HTTP producer; retiring it and widening the gate's spec-face refusal to every published package is the card's second half and is not in this release. +- abae16a: feat(spec): register every error code that ships in `dist` — `OBJECT_OWNERSHIP_CONFLICT`, the seven `STACK_*` `defineStack` refusals and `PLUGIN_UI_REQUIRED_KEY_MISSING` enter `ERROR_CODE_LEDGER` (#16449) + + Under the #16404 ruling (director seat, decision batch #62, 2026-09-07, option D; maintainer 「同意」) **the published contract face for error codes is `ERROR_CODE_LEDGER` / `StandardErrorCode`**: every `code` that ships in a package's `dist` is registered there, door or no door, because a consumer's `catch (e) { switch (e.code) }` pins the spelling the moment it ships and nothing could flag a later rename. Nine codes were shipping unregistered on this tree and now have rows, each under the package that stamps it: + + | code | stamped by | `status` | reaches an HTTP door on this tree? | + |---|---|---|---| + | `OBJECT_OWNERSHIP_CONFLICT` | `@objectstack/objectql` (`SchemaRegistry.registerObject`, ADR-0029 D3) | 422 | no — every path aborts boot or is caught below any door | + | `STACK_SCHEMA_INVALID` · `STACK_CAPABILITY_UNKNOWN` · `STACK_CROSS_REFERENCE_INVALID` · `STACK_NAMESPACE_PREFIX_INVALID` · `STACK_SINGLE_APP_VIOLATION` · `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` · `STACK_TRIGGER_CAPABILITY_REQUIRED` | `@objectstack/spec` (`defineStack`, #14552 / #15963) | 422 | no — raised by `os validate` / `os build` and the host configs at boot | + | `PLUGIN_UI_REQUIRED_KEY_MISSING` | `@objectstack/spec` (`PluginSchema`'s `superRefine`, on the zod issue's `params.code`, #16334) | rides `PLUGIN_CONTRACT_VIOLATION`'s | no — raised at `kernel.use()` | + + The card's ninth, `NAMESPACE_CONFLICT`, was already registered by #14748 and already answers `error.code: NAMESPACE_CONFLICT` at `POST /api/v1/packages`; this release changes nothing there. + + **Wire consequence, stated plainly.** For a code that reaches an HTTP door, registration changes what a client reads: `error.code` becomes the specific code instead of the standard member the status derives (`VALIDATION_ERROR` for 422) with the producer's spelling demoted beside it in `declaredCode`. That is the ruling's intended effect — a consumer can branch on the real code — and it is what the Clause-② review judges. Measured on this tree, **none of the nine has such a door** (the table's last column; `OBJECT_OWNERSHIP_CONFLICT`'s reading was re-taken: the only two non-test `registerObject` callers outside `objectql`, both in `metadata-protocol`, catch it and log), so **no HTTP body changes with this release**. What changes is the face: `ErrorCode` — the union `ApiErrorSchema.code` parses against — gains nine members, `REGISTERED_ERROR_CODES` lists them, the generated docs references carry them (`check:generated` found nothing else stale — no authorable schema and no JSON-schema artifact reads this union), and each refusal's `e.code` is now a member of the union a consumer's exhaustive `switch` is written over. Should a door ever answer with one of these codes, the wire carries the specific code from then on. The `declaredCode` demotion (#9106) remains for genuinely unknown / third-party spellings only. + + **Why `minor`, and no `BREAKING` banner.** Nothing is removed or renamed; every existing body parses exactly as before. The change is a purely additive widening of a published surface (nine new `ErrorCode` members), which the 2026-09-04 ruling on #15294 requires to be at least `minor`. The one consumer-visible cost is type-level: an exhaustive `switch` over the `ErrorCode` TYPE gains nine cases to cover — additive, and the shape the ruling asks for. + + Also in this release, as the mechanism that keeps the class closed: `check:dispatcher-error-vocabulary` now refuses to classify a `packages/spec/src/**` stamp site as anything but `foreign-vocabulary` or `runtime-pinned` (`spec-face-unregistered`) — a code raised under the spec tree is a ledger member or it fails CI — and the ledger's header records the ruling as the "door or no door" rule in its own words. The nine `boot-refusal` classification rows in `dispatcher-error-vocabulary.ts` ratcheted out with the registrations, their reachability reading now carried on the ledger rows. +- c7aca0d: `ResumeFailureReport` — the machine-readable half of a resume failure, declared once in `contracts/approval-service.ts` and carried as the optional, additive `resumeFailure` member of `ApprovalRecallResult` and `ApprovalDecisionResult` (the contract half of the #16472 family ruling, decision batch #76). + + The ruling: when a resume failure is told to the caller, it is told in a shape the caller can act on — a registered error code, the `runId` of the run that is actually stranded, and `repairable` — and the door's status code does not change because of it. A decision whose own run advanced still answers success, with the failure behind it carried on the success answer. This change declares that shape; the doors adopt it separately (#15556 for the decision door's subflow bubble-up, #15970 for `recall`). + + - **Declared once, by reuse.** `ResumeFailureReport extends ResumeFailureDetails` (`api/automation-api.zod.ts`), the structure the automation resume door already publishes inside its `400 FLOW_FAILED` details. `runId`, `status` and `repairable` are inherited, never re-spelled, so the carriers cannot drift; a caller that parses the member with `ResumeFailureDetailsSchema` reads the same three facts it reads off that door. The report adds exactly the one member a success envelope cannot leave to its envelope: `code`. + - **No new error code is minted.** `code` is typed as `ErrorCode`, the ADR-0112 ledger vocabulary, so an unregistered spelling fails `tsc` rather than reaching the wire. A consumer that needs a distinct code to branch is its own card. + - **The absence rule is explicit and pinned.** The member is optional because it is additive, and an absent member means no report was made — a producer that predates this field, a door that never resumes — never that no run is stranded. A consumer may branch on presence to read a failure; it must not branch on absence to conclude health. + - **`resumeError` is no longer "when `resumed` is false".** Both carriers' `resumeError` docblocks now say its presence is decided by whether a failure was told, never by `resumed`, and name `resumeFailure` as the machine-readable half of the same telling; both `resumed` docblocks say `true` speaks for this door's own resume, not for every run behind it. `ApprovalSendBackResult` and `ApprovalResubmitResult` are unchanged — the ruling names no carrier on those doors. + - `StrandedDecisionDetails` (`@objectstack/types`, the error-envelope carrier of the `decide` door's own strand, #13807) is unchanged. +- c1d8f98: Seed datasets gain a `locale` filter axis, composed with `env` by the loader. + + An app shipping demo data for two language markets — the same records, different display strings — had no declarative way to say which dataset applies. `SeedSchema` is a `strictObject`, so the app could not add the key itself; the selection had to happen in application code while the config was assembled. That is the wrong layer twice over: the choice is cached in the build output (switching markets means deleting `dist`), and because every profile is an `upsert` and the loader only writes, the other market's rows stay resident in the database. + + - **`Seed.locale?: string[]`** — BCP-47 tags scoping the dataset to one or more language markets. **Omitted means every locale.** Unlike `env`, whose three environments are a closed set that can be spelled out as a default, locales are open-ended tags with no enumerable universe — so absence, not a default array, is what carries "unrestricted". An empty array is rejected: a dataset that applies nowhere is an authoring mistake, the same reasoning that already governs a composite `externalId`. `locales`, `language` and `languages` are aliased onto it, matching the existing `environment` / `environments` → `env` pair. + - **`SeedLoaderConfig.locale?: string`** — the tag the load filters on. + - **The loader composes both axes by conjunction.** A dataset is loaded when it passes `env` **and** `locale`; neither axis can rescue a dataset the other excluded. `filterByLocale` mirrors `filterByEnv` down to the reporting posture — skipping is the declared, intended outcome, so it logs at `info`, but it always names what it dropped. Tags compare case-insensitively (BCP-47 casing is a convention, not part of a tag's identity) and otherwise exactly: `['zh']` does not match `zh-CN`, and widening that would be the lenient consumer-side fallback the contract-first rule forbids. + + The platform still translates nothing and merges nothing. The app authors both record sets; this adds only the axis that selects between them. + + **What is not wired yet, stated plainly.** The locale axis is evaluated against `config.locale`, and no first-party call site supplies one — the runtime wiring that would resolve it from the stack's configured locale is a separate change in `packages/runtime`. An embedding host that passes `config.locale` itself gets the full behaviour today; on the default boot path the axis is inert. That is the shape `Seed.env` was in before framework#4704, so it is not left silent: a load carrying locale-scoped datasets and no `config.locale` warns naming each dataset it let through and the config key that would make the scope take effect. The liveness ledger records `seed.locale` as `experimental` for exactly this reason, with the consumer side cited and the producer gap spelled out, rather than claiming `live` on a correct-but-insufficient consumer pointer. +- 8e0b297: fix(plugin-auth)!: `positions[]` on the session payload is the SECURITY axis, not the better-auth role scalar (#15136) + + + + **BREAKING** meaning change on a published payload — `user.positions` in + `GET /api/v1/auth/get-session`. Shipped as `minor` under the repo's + launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on + #15136 (director decision batch #39, item 2, verbatim 「同意」): option A, one + name, one meaning. + + `customSession` built the array from the better-auth `sys_user.role` scalar + split on commas, plus the active membership mapped to `org_*`, plus + `platform_admin` — and read **nothing** from `sys_user_position`, the ADR-0057 + D4 table that is the source of truth for custom positions. The Console binds + that array straight through as the CEL root `current_user`, so an + `action.visible` (or any `visibleWhen`, nav `visible`, page-tab gate) narrowed + by a business position answered FALSE for **everyone**, including the user who + genuinely held it. + + ⭐ It failed **silently and in the invisible direction**: the root was bound and + the key was present, so `has(current_user.positions)` was true, CEL raised + nothing, and the predicate simply returned FALSE. A predicate that *faults* + fails OPEN in the shell and would have shown the button; a successful FALSE + shows nothing and reports nothing. The documented example + (`'org_admin' in current_user.positions`) kept working throughout, because + `org_admin` is the one name that sits on **both** axes. + + This was a **declared** contract being violated, not an ambiguous name: + `EvalUserSchema` already specified `positions` as "built-in identity names + + position names", exposed to "every predicate surface (server formula, server + RLS, client UI gates) ... with an identical shape" so that a predicate + "evaluates identically wherever it is written". `/auth/me/permissions` and + every server-side evaluator (`ExecutionContext.positions`) already resolved the + security axis; only the session payload did not. + + **What changes** + + - `packages/plugins/plugin-auth` — the hand-rolled derivation is **deleted**, + not repaired. `customSession` now asks `resolveUserAuthzGrants`, the ONE + authority (`core/security/resolve-authz-context.ts`, whose header forbids + every entry point from re-reading the `sys_*` grant tables itself), scoped to + the session's active organization. The payload therefore carries the + `sys_user_position` assignments and the ADR-0090 D5 `everyone` anchor, and + agrees with `/auth/me/permissions` set for set. Same move + `isPlatformAdminUserId` made at #10348. + - `isPlatformAdmin` is now derived from that array (ADR-0068 D2 defines it as + an alias of `'platform_admin' in positions`), so one authority answers both. + - `packages/spec` — `EvalUserSchema` states which axis `positions` is, and + states that the better-auth role scalar is not it. + + **No key is renamed, and none is added.** The ruling anticipated a renamed + auth-role array; measured against the tree, it has no content to carry and no + consumer. Everything the old union contributed beyond the security axis was the + `sys_user.role` scalar's own tokens — and that scalar is **already published, + unchanged, as `user.role`** (the single exception ADR-0090 D3's "role" word ban + carves out, for third-party schema this platform does not own). Minting a + `roles` array would revive that banned word to publish information the payload + already carries. (Precisely: `check:role-word` ratchets the reserved word in + `content/docs` and `skills/` PROSE, while the identifier ban over authored + metadata lives in `packages/lint`; a TypeScript payload key trips neither + mechanically until it is documented. The ADR-level prohibition is what rules + here, not a gate that would have caught it.) A consumer that wants the + better-auth role reads `user.role`. + + **What does NOT change:** `user.role` is still never overwritten (ADR-0068 D2); + `platform_admin` still derives from the unscoped `admin_full_access` grant with + its ADR-0091 validity window and ADR-0049 active flag intact — + `platform-admin-standing.consolidation.test.ts` PIN 6 passes unchanged over + those shapes. + + ⚠️ **`isPlatformAdmin` is derived from the posture RUNG, never from the array.** + `positions.includes('platform_admin')` is the form + `resolve-authz-context.ts` forbids, because an ADR-0057 D4 `sys_user_position` + row may spell that very name — and this card is what made that reachable, by + moving `positions` onto an axis a tenant admin can write. Reading the name would + have let a tenant mint platform standing and pass the `/admin/*` mount gate. + `platform-admin-gate.ts` drops its positions leg for the same reason. + `session-platform-admin-rung-agreement.test.ts` requires the payload alias, that + gate and `hasPlatformAdminStanding` to agree, driven with such a row present and + a genuine grant as the control. + + **Upgrade.** If you gate on the better-auth role scalar, read `user.role` + instead of looking for its tokens in `user.positions`. Predicates written + against real position names, built-in identity names, or `everyone` need no + change — they start working. Deployments that stored business role names in + `sys_user.role` rather than assigning positions should assign them through + `sys_user_position` (the governed ADR-0090 D12 channel). + + A name in `sys_member.role` is still projected, **with one carve-out**: for a + session carrying NO active organization, membership names are now *added*, from + **every** membership the user holds — the resolver projects them all when no + tenant scopes it, where the old derivation contributed none. Measured on the + real pipeline (`autoActiveOrganization: false`, one `sys_member.role = 'admin'`): + `[]` before, `[org_admin, everyone]` after, pinned by + `session-positions-security-axis.test.ts`. With an active organization the + projection is tenant-scoped exactly as `/auth/me/permissions` scopes it, so + membership-derived names there are unchanged. +- 5f7fa1d: feat(spec): retire `SessionUser.language` — the session contract's never-produced "preferred language" (#14788, ADR-0049) + + + + **BREAKING** key removal on a published session type, landing after the + v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the + prescription is registered under protocol major 18 — `api/SessionUser:language` + in `RETIRED_KEYS_BY_MAJOR[18]` plus the D3 semantic entry + `session-user-language-retired` — where `os migrate meta` users will look). + + `SessionUserSchema.language` (`api/auth.zod.ts`) was declared + `z.string().default('en')` and described as "Preferred language", and had no + producer and no consumer anywhere: no session endpoint ever wrote it, no client + ever read it (objectui measured at its pinned sha: zero readers; the only + in-repo mentions were the schema's own unit test). A reader trusting the + published contract got a constant that was not the user's language — while the + user's real preference had just landed as the first-class column + `sys_user.locale` (#13881), which the session type could not see. Three + spellings of one concept on the published surface, none of them right. The + maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 + enforce-or-remove and make `GET /auth/me/localization` the ONE read face for + the signed-in user's language. No replacement field joins the session contract + until a session endpoint really produces one — no dual-spelling window. + + FROM → TO: + + - `SessionUser.language` / `SessionUserParsed.language` → *(removed)*. Read + the signed-in user's language from `GET /auth/me/localization` → `locale`, + which now resolves the user's own `sys_user.locale` when set → the request's + `Accept-Language` → the deployment default (`@objectstack/plugin-hono-server` + in the same release). + + One-line fix: delete the key. A producer still writing it fails `tsc` + (`never` input type) and fails to parse with this prescription; a reader still + keying on it now reads `undefined` instead of a permanent `'en'`, and should + read `locale` off `/auth/me/localization` instead. + + The retirement kit: + + - **`retiredKey()` tombstone** (the schema is a non-strict `z.object`, so a bare + delete would have stripped the key silently — ADR-0104): writing `language` + is a `tsc` error and a parse error carrying the prescription, on + `SessionUserSchema` and through both envelopes that embed it + (`SessionResponse.data.user`, `UserProfileResponse.data`). + - **ADR-0087 registration**: `api/SessionUser:language` under major 18 plus + the D3 semantic entry `session-user-language-retired`. A RESPONSE surface — + the server mints a `SessionUser`, nobody authors or persists one — so there + is no source for a D2 conversion to rewrite (the + `api/AuthFeaturesConfig:passkeys` disposition). + - **generated baselines**: `authorable-surface/api.json` carries the + `[RETIRED]` row; `authorable-defaults/api.json` drops the `= "en"` default; + `spec-changes.json`, the upgrade guide and `content/docs/references/api/auth.mdx` + regenerated. + - **pins** in `api/auth.test.ts`: the prescription on parse, absence (no default + minted) on a clean parse, both envelopes refusing the key, and a + `packages/spec/src`-scoped scan for any reader of `.language` off a + `SessionUser`. + - zero in-tree producers or readers, so no in-repo source changes ride along + beyond the endpoint change shipped with it. +- 87f0ccc: feat(spec): `SharingRuleEvaluationResult` declares `grantsRefused?: number` — the optional seventh key the sharing-rule evaluate route already answers (#14969) + + `minor`, derived: a new key on a published contract interface is additive public + API (semver "backwards-compatible functionality"), and not `major` because the + key is **optional** — every existing `ISharingRuleService` implementer, in-tree + and out, keeps compiling unchanged, and every consumer typed against the six + counts keeps reading them. + + `POST /api/v1/sharing/rules/:idOrName/evaluate` (ledgered `sdk`, + `shares.rules.evaluate`) passes the service's return value through unfiltered, + and `@objectstack/plugin-sharing` has counted refused grants on its own subtype + since #14754 — so the wire carried `grantsRefused` while the declared client + type (`client.shares.rules.evaluate`, typed `Promise`) + could not name it without a cast. The client gains the key through its spec + import with no edit of its own. + + What the key means, and what its absence means: it counts the grants the + engine **refused** during the pass (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on + an organization-less insert into a tenant-scoped `sys_record_share`); the pass + continues past a refusal, so `grantsRefused > 0` is not a failed pass. The key + is **absent — not `0`** — from any implementation that does not count + refusals. A consumer branching on it must read "unset" as "this implementation + does not report refusals", never as "no grant was refused"; only a present `0` + says the latter. Do not `?? 0` it. + + Optional in the spec composes with the plugin-local narrowing: an + implementation that counts refusals may require the key on its own subtype + (`SharingRuleReconcilePassResult extends SharingRuleEvaluationResult`), a legal + covariant narrowing that still satisfies `ISharingRuleService`. +- 10d05bb: docs(spec): the comparand door's array parenthetical states what the drivers do today + + `filter-comparand-type.ts`'s list of the cases its door deliberately does not rule described the array cell as "`driver-sql` refuses it with its own message; the document stores give it array-equality semantics". Measured on this tree, that second clause is no longer a true statement about `@objectstack/driver-memory`: its reference matcher compared an array comparand by REFERENCE (matching nothing), while its live query path deep-equalled it (matching the row) — one package, two answers, neither of them a stable "array-equality semantics" a reader could build on. With that driver's cell now refused, the sentence names the two dispositions that exist: `driver-sql` and `driver-memory` refuse it, each with its own message, and `driver-mongodb` hands it to MongoDB and inherits that engine's array semantics. + + The paragraph's point is unchanged and deliberately kept: the door does not rule this position, the matrix did not measure it, and it is left to the layers that already answer it. Only the description of what those layers do is corrected — a stale factual clause is how the next reader re-derives "the spec promises array-equality" from a passage that explicitly promises nothing. The neighbouring pass-through pin's comment carried the same stale characterisation and is corrected the same way; its assertion (that `parseFilterAST` leaves `{ tags: ['a','b'] }` untouched) is unchanged and still pins the door not judging. +- 69602e5: feat(spec): export `COMPOSE_KEY_DISPOSITIONS` and `STACK_DEFINITION_KEYS` — the artifact envelope's top-level key set and each key's composition rule, derivable from one source instead of hand-copied per consumer (#14877) + + `minor`, additive: two new named exports and two new exported types on the + root entry; nothing renamed, narrowed or removed. Every existing import keeps + compiling and every behaviour of `composeStacks` is unchanged — the table it + reads is the same object, now frozen and public. + + - `COMPOSE_KEY_DISPOSITIONS` — a frozen, read-only record from every top-level + key `ObjectStackDefinitionSchema` declares (`manifest`, `packages`, + `requires`, `objects`, … `onEnable`) to its composition rule: `'concat'` + (an array collection, concatenated in stack order), `'single'` (identical + declarations pass through, differing ones refuse naming the key), + `'manifest'` (picked by the `manifest` option), `'objects'` (the + `objectConflict` strategy) or `'functions'` (merged by handler name). + Literal-typed, so `(typeof COMPOSE_KEY_DISPOSITIONS)[K]` is K's disposition, + not the union. + - `STACK_DEFINITION_KEYS` — the top-level key set, derived from that table by + `Object.keys` (never a second literal), frozen. + - `StackDefinitionKey` and `ComposeDisposition` — the key union and the + disposition union, for a consumer that types its own seam against them. + + Why: the collection half of this key set was already derivable downstream + (`PLURAL_TO_SINGULAR`, `METADATA_ALIASES`), but the non-collection keys — + `manifest`, `requires`, `packages`, and whatever comes next — had to be + hand-copied by every consumer that walks an artifact's top level, and that + copy drifted silently twice: objectstack-ai/cloud#897 (`roles` → `positions` + dropped every hosted `positions[]`) and objectstack-ai/cloud#1888 (`packages[]` + dropped by an artifact merge, recreating downstream the duplicate-ownership + state #14599 had repaired at the door). A seam that derives its key set from + `STACK_DEFINITION_KEYS` — and asks `COMPOSE_KEY_DISPOSITIONS[key] === 'concat'` + whether a key may be concatenated across artifacts — picks up the next key + (#14865's `grantedPermissions`) the day the schema declares it, with no edit of + its own. + + Pinned (`compose-key-dispositions-export.pin.test.ts`): the exported key set + equals `ObjectStackDefinitionSchema`'s declared top-level key set in both + directions, the view is frozen, every value is a declared disposition, every + `'concat'` key is what `composeStacks` concatenates and every `'single'` key is + what it passes through or refuses, and the key list is `Object.keys` of the + table. +- 7936b29: feat(spec): export the object-level refinement checks the mirrored UI schemas run — `checkListViewPageMount`, `checkListViewCalendarVisualization`, `checkPageSourceCompleteness`, `checkGlobalFilterDateDefaultValue` (#16489, the spec half of objectui#7715) + + objectui derives its zod schemas from the spec's `.shape` (`specFieldsExcept(SpecListViewSchema.shape, …)`, six sites at the pinned build). That carries the spec's FIELDS by reference and drops every check attached to the spec OBJECT (`superRefine` / `refine`), so at 17.3.0 objectui's authoring door accepted `appearance.allowedVisualizations: ['calendar']` with no `calendar:` block while the spec's publish door refused it. Contract-first: the rule is written once, in the spec, and a mirror attaches that same rule instead of re-implementing it. + + Every spec object that carries an object-level refinement and is mirrored downstream now exports its check as a named, typed function (`(value, ctx: z.RefinementCtx) => void`) from `@objectstack/spec/ui`, alongside the schema — one function per refinement, no bundled "all checks" blob, so a mirror attaches exactly the ones whose fields it carries: + + | Schema | Export | Refuses | + |:--|:--|:--| + | `ListViewSchema` | `checkListViewPageMount` | `type: 'page'` with no `pageName`; `pageName` on a view that is not `type: 'page'`; a page mount declaring `columns` | + | `ListViewSchema` | `checkListViewCalendarVisualization` | `'calendar'` in `appearance.allowedVisualizations` with no `calendar:` block | + | `PageSchema` | `checkPageSourceCompleteness` | an `html` / `react` / `jsx` page with no non-empty `source` | + | `GlobalFilterSchema` | `checkGlobalFilterDateDefaultValue` | a `type: 'date'` filter whose `defaultValue` is neither a preset name, an ISO date, nor a date-macro token | + + The population is measured from the schemas themselves (`_zod.def.checks`) against the six objectui derivation sites: `ListViewSchema` (2 checks), `PageSchema` (1) and `GlobalFilterSchema` (1 — mirrored by a `.shape` spread rather than `specFieldsExcept`) carry object-level refinements; `NavigationAreaSchema`, `AppSchema`, `DashboardWidgetSchema` and `DashboardSchema` carry none, so nothing is exported for them. The two `ListViewSchema` checks were already named module-private functions and are now exported; the `PageSchema` and `GlobalFilterSchema` checks were inline `superRefine` bodies, extracted verbatim into named functions the schema now attaches by identifier. + + Additive, and the schemas are unchanged: every schema attaches the very function it exports, so no accept set moves — every ListView, Page and GlobalFilter document that parsed before parses identically, with identical issues. `minor` because four new symbols land in the published `dist/*.d.ts`. Each export is pinned to be the check its schema runs (`object-refinement-check-exports.test.ts`: parity over every failure path between the direct call, the schema's own check object and the schema's parse; the schema carries exactly as many checks as are exported for it; the module attaches each by identifier). Attaching them at the derivation sites is the objectui half, objectui#7715. +- 46803fa: feat(spec): the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711) + + + + **BREAKING** (launch-window convention: ships as `minor`; this entry is the signal) — the second facet of the #15711 ruling moves a published default of the `@objectstack/spec/system` label resolvers. A caller that passes no `fallbackChain` used to get a literal `['en']`; it now gets `[]`, "requested locale, then the authored label". Nothing silently falls to `en` because a literal said so: a chain is consulted only when someone declared it. In this repo the blast radius is zero production callers (the REST serving layer has declared its chain since #14882; one pin flips); out-of-repo hosts unmeasured. A host that relied on the implicit `en` declares it as `fallbackChain: ['en']`. + + ## The ruling (#15711, recorded 2026-09-05) + + A workspace that authors its metadata labels in its default locale (`i18n.defaultLocale: 'zh-CN'`, inline `label: '填报单'`) and ships a courtesy `en` bundle used to serve `Entry Sheet` to a `zh-CN` request whenever its declared chain named `en` — a reflexive `fallbackLocale: 'en'` in an AI-authored config was enough. `os i18n check` already counted the authored text as the default locale's coverage; the runtime did not. Ruled A: **the authored label IS the default locale's text**. + + - `ResolveOptions` gains an optional `defaultLocale?: string` — the deployment's default locale, the language its labels are authored in. When the requested locale names it (BCP-47 tags compare case-insensitively, the same rule `resolveBundleLocale` applies), the resolvers consult the requested locale's own bundle and then answer with the authored label; the fallback chain is not walked. + - `fallbackChain` keeps its full meaning for every non-default request: a `fr` request still walks the `fr` bundle, then the declared `en` bundle, then the authored label. + - A bundle entry for the default locale still wins when one is shipped, so `os i18n extract --locales=zh-CN` keeps working — optional now, not required. + - `II18nService.getDefaultLocale()` documents that it is also what the serving layer threads into `ResolveOptions.defaultLocale`; `@objectstack/rest` passes it through its single `translateOptionsFor` seam (that package's own changeset). + + Unchanged: `os i18n check`; both boot paths (`os serve` and the dev plugin still collapse the declaration to `fallbackLocale || defaultLocale || 'en'` before constructing the service); every request whose locale is not the default. + + Not taken, ruled out on the card: the rule living only in `packages/rest` (every other host would re-implement it and spec could not pin it); requiring every supported locale to ship a bundle (a generated bundle that duplicates the app's own source text, the stale-translation class already closed); documenting the divergence. +- c2a336c: `@objectstack/spec/system` now names the ADR-0030 notification cut-over, so "has this deployment run it?" has a place to be answered. + + `sys_migration` is the ledger a deployment writes to record that a data migration ran against its own database, and consumers read it instead of the platform version. Its well-known ids were `adr-0104-file-references` and `adr-0104-value-shapes` — the two ADR-0104 scans, both driven by an `os migrate` command that records the row. `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) had none. It is destructive and one-way, operators are handed the call verbatim in `docs/handoff/adr-0030-notification-convergence.md`, and it recorded nothing when it ran: a deployment that performed the cut-over and one that never did are indistinguishable from the ledger. A row can only be keyed by an id, so without one the question had nowhere to be answered even in principle. + + Added: `NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'`, exported from `@objectstack/spec/system`. Purely additive — no existing export, schema or predicate changes, and nothing reads the new id yet. + + Deliberately NOT decided here, and the constant's docblock says so rather than leaving its silence to be read as an answer: what a `sys_migration` row under this id means. The two ADR-0104 ids get their `last_run_at` / `applied_at` / `verified_at` / `blocking` semantics from a command that scans, self-checks and only then records; this migration has no command and no self-check, and reports `migrated` / `already_done` / `not_applicable` / `error` to its caller instead. Which of those columns one of its runs may claim, whether anything may gate on the row, and whether a datastore created after the cut-over belongs in `CREATION_ATTESTED_MIGRATION_IDS`, are contract questions on this surface and are left open. +- 9f890d3: `bareDateRangePresetComparandMessage`'s TSDoc — published in `dist/*.d.ts` — now states both moments the wording is reported at: the field-agnostic schema door in `data/filter.zod.ts` (ordering positions only: without a field type, equality on a select column is legitimate) and `@objectstack/lint`'s `filter-preset-comparand` rule, which with the field type in hand refuses every comparand position on a declared `date` / `datetime` field (#16106). The message text itself is unchanged. +- 1ecee3e: fix(spec): every `defineStack` refusal carries an ADR-0112 envelope — six new `STACK_*` codes beside `STACK_CROSS_REFERENCE_INVALID` (#15963) + + `defineStack` has seven refusal sites. After #14552 one of them — the cross-reference refusal — carried `code` / `status`; the other six still threw `new Error(message)` with both `undefined`. A consumer that had learned to branch on `error.code` from the cross-reference refusal read `undefined` from its six neighbours, which reads as "not a validation refusal" rather than "a refusal with no code yet" — the silent-tolerance shape ADR-0112's envelope exists to remove. Every site now throws an envelope, `status: 422`, one code per refusal, the findings the site collected on `issues`: + + | Refusal (header text, unchanged) | Raiser | `code` | + |---|---|---| + | `defineStack validation failed` | `ObjectStackDefinitionSchema.safeParse` | `STACK_SCHEMA_INVALID` | + | `defineStack capability validation failed` | `validateKnownCapabilities` | `STACK_CAPABILITY_UNKNOWN` | + | `defineStack cross-reference validation failed` | `validateCrossReferences` | `STACK_CROSS_REFERENCE_INVALID` (#14552, unchanged) | + | `defineStack namespace-prefix validation failed` | `validateNamespacePrefix` | `STACK_NAMESPACE_PREFIX_INVALID` | + | `defineStack single-app validation failed` | `validateSingleApp` | `STACK_SINGLE_APP_VIOLATION` | + | `defineStack hierarchy-scope capability validation failed` | `validateHierarchyScopeCapability` | `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` | + | `defineStack trigger capability validation failed` | `validateTriggerCapability` | `STACK_TRIGGER_CAPABILITY_REQUIRED` | + + Message text is byte-for-byte unchanged at every site — this adds the machine-readable half, it does not reword a sentence; the message pins across the tree still read the prose they always did. One code per site rather than one shared `STACK_VALIDATION_FAILED`: the dispatcher vocabulary's `boot-refusal` class was already at one-row-per-refusal granularity (14 rows), and `STACK_CROSS_REFERENCE_INVALID` is an instance of that granularity, not an exception to it. + + The schema arm was judged separately rather than copied from the five semantic cross-checks, because it is an aggregate of zod issues against the schema the stack declares, not a rule evaluated on a parsed stack. The reading: `@objectstack/spec` has no zod-failure envelope to reuse (`formatZodError` / `safeParsePretty` return prose); the two zod-shaped refusals the ledger already carries are both spelled `*_SCHEMA_INVALID` — `METADATA_SCHEMA_INVALID` (metadata-core's `SchemaValidationError`, the `issues`-carrying precedent; nothing in the tree assigns it a status) and `FLOW_INPUT_SCHEMA_INVALID` (answered 422 by the runtime's flow-dispatch table) — and the zod-shaped refusal `metadata-protocol` actually stamps at 422 is `INVALID_METADATA`; the two other channels a zod failure travels on — `400 VALIDATION_ERROR` (request syntax) and `VALIDATION_FAILED` + `fields[]` (record validation, duck-typed on `name === 'ValidationError'`) — would each file an authored stack as something it is not. So it is its own code at 422, and its `issues` carries the zod issues structurally (path, code, message per entry) rather than the formatted lines the message already renders. `issues` is therefore heterogeneous across the seven: strings for the six semantic refusals, zod issue objects for the schema arm; a reader branches on `code` first. + + Not narrowed: `defineStack` accepts and refuses exactly the inputs it did before, and no export changes — the error classes stay module-local, as `StackCrossReferenceError` did, because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112 contract is the `code` / `status` pair read structurally. All seven are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec` — the six new codes and the `STACK_CROSS_REFERENCE_INVALID` precedent alike — not by this change but by #16449 (PR #16652), which landed after this changeset was written and registered them under the #16404 ruling that every code shipped in `dist` is registered, door or no door. That registration records the same reachability reading this change measured rather than contradicting it: no wire door raises them — `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it (re-measured: every non-test `defineStack` occurrence under `packages/runtime/src` and `packages/rest/src` is a docstring, a comment or the vocabulary table's own prose). + + **Why `minor`, not the `patch` the #14552 precedent took.** Six new `STACK_*` spellings ship in `packages/spec/dist/index.js` and `dist/index.mjs`; nothing reaches `.d.ts`, but once shipped a consumer's `catch (e) { switch (e.code) … }` depends on them and they cannot be renamed without breaking it. That is a purely additive widening of a published package's public surface, and the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) requires at least `minor` for it: the commit type may raise a bump but never lower it below what the act requires, so a `fix(` that widens the surface is `minor`. The `patch` precedent is pre-rule; this level is required by that ruling, not chosen by taste. + + `@objectstack/runtime` carried one classification row per new code in the dispatcher error-code vocabulary (`door: 'none'`, `verdict: 'boot-refusal'` — the measured verdict) when this changeset was written, and carries none now: the same #16449 (PR #16652) landing that registered the seven codes ratcheted all seven rows back out of `packages/runtime/src/dispatcher-error-vocabulary.ts`, because a registered code drops out of that gate's scan and the reachability each row recorded now lives on its ledger row. `pnpm check:dispatcher-error-vocabulary` still reconciles sites and rows in both directions, and under the same ruling it now refuses any verdict but `foreign-vocabulary` / `runtime-pinned` for a stamp site under `packages/spec/src/**` — so these seven cannot take a `boot-refusal` row again. +- 9408b7f: A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. + + `evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author. + + - The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`. + + **This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too. + + **Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved. + + An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope. +- e9fcd6b: feat(spec)!: the fifteen `system/` duration keys carry their unit in the key name (#15679, ruling B on #14478) + + + + **BREAKING** — fifteen published `system/` duration keys are renamed and + tombstoned. Shipped as `minor` under the repo's launch-window convention for + breaking changes; the hand-migration prescriptions are registered under protocol + major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, + 「同意」). + + `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit + in the key NAME, never only in its `.describe()` prose, and grandfathers no + existing offender. Stack card 1/6 (#15676) landed the rule's two structural + exemptions, card 2/6 (#15677) cleared `api/` and card 3/6 (#15678) cleared + `kernel/`; this card clears `system/`. Measured with the gate itself: + `src/system/**` goes from 15 offenders to **0**, and the whole-tree count falls + **22 → 7**. + + ## FROM → TO + + | key | replacement | unit | + |:--|:--|:--| + | `CacheTier.ttl` | `ttlSeconds` | seconds | + | `CacheAvalanchePrevention.circuitBreaker.resetTimeout` | `resetTimeoutSeconds` | seconds | + | `CollaborationSessionConfig.idleTimeout` | `idleTimeoutMs` | milliseconds | + | `CollaborationSessionConfig.snapshot.interval` | `intervalMs` | milliseconds | + | `FailoverConfig.healthCheckInterval` | `healthCheckIntervalSeconds` | seconds | + | `MetricAggregationConfig.window.size` | `durationSeconds` | seconds | + | `ServiceLevelIndicator.window.size` | `durationSeconds` | seconds | + | `ServiceLevelObjective.period.duration` | `durationSeconds` | seconds | + | `AccessControlConfig.maxAge` | `maxAgeSeconds` | seconds | + | `StorageConnection.timeout` | `timeoutMs` | milliseconds | + | `RegistryUpstream.syncInterval` | `syncIntervalSeconds` | seconds | + | `RegistryUpstream.timeout` | `timeoutMs` | milliseconds | + | `RegistryConfig.cache.ttl` | `ttlSeconds` | seconds | + | `Span.duration` | `durationMs` | milliseconds | + | `QueueConfig.rateLimit.duration` | `durationMs` | milliseconds | + + **Every value is unchanged** — only key names move, and every default moves with + its key (`CacheTier` still defaults to 300, `CollaborationSessionConfig` to + 300000, `FailoverConfig` to 30, `RegistryUpstream.timeoutMs` to 30000, + `RegistryConfig.cache.ttlSeconds` to 3600). Bounds move with their keys too, so + `syncIntervalSeconds` still refuses anything under 60 and `timeoutMs` anything + under 1000. Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` + at the authoring site (input type `never`) and fails the parse with the rename + prescription rather than a bare unrecognized-key error. + + ## ⚠️ Two `maxAge` keys, opposite sides of the line — do not harmonise them + + `AccessControlConfig.maxAge` (bucket CORS) is **renamed** to `maxAgeSeconds`. + Its twin `shared/CorsConfig.maxAge` (HTTP CORS) is **not**, and keeps its bare + name under an `externalVocabulary` marker. + + The asymmetry is the whole point. Every bucket-CORS standard the first value is + forwarded to already spells the unit — S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, + Azure `MaxAgeInSeconds` — so marking that key would have exempted a *deviation + from* the cited standard rather than a mirror of it. The Fetch response header + the second mirrors, `Access-Control-Max-Age`, genuinely carries no unit token. + A find-and-replace across both leaves no gate red: the marker exempts the twin + either way. A pin test in `object-storage.test.ts` is the only guard. + + ## ⚠️ `window.size` becomes `durationSeconds`, not the mechanical `sizeSeconds` + + The gate prints `sizeSeconds` for the two `window.size` keys, and that name is + wrong on its face. `size` means a byte or row count everywhere else in this spec + — `CacheTier.maxSize` is megabytes, `RegistryConfig.cache.maxSize` is bytes, and + `MetricExportConfig.batch.size` on the very same file is a record count — so + `sizeSeconds` would have kept the misleading half of the name and bolted a unit + onto it. `windowSeconds` was rejected for a plainer reason: the parent key is + already `window`, so it would read `window.windowSeconds`. + + `durationSeconds` names what the number is, and the file supplied its own + precedent: `ServiceLevelObjective.period.duration` already called a period + length a duration. After the rename all three read alike. The prescription says + so explicitly, so the next author does not read the departure as a slip and + "correct" it back to the mechanical name. + + ## Dispositions — eight semantic entries, no D2 conversion + + Justified per key rather than defaulted, and this card's answer is uniform: + **none of the fifteen gets an ADR-0087 D2 conversion.** A D2 conversion runs + over a stack document, and `stack.zod.ts` declares no `cache`, `collaboration`, + `disasterRecovery`, `metrics`, `objectStorage`, `registry`, `tracing` or + `worker` root — none of these twelve defs is a stack collection member or a + registered metadata kind stored as a `sys_metadata` row, so the conversion chain + has no seam that would see one. They are host configuration (`CacheTier`, + `FailoverConfig`, `StorageConnection`, `RegistryUpstream`, `RegistryConfig`, + `QueueConfig`), call arguments (`CollaborationSessionConfig`) and + runtime-emitted measurements (`Span`). Each therefore carries a **semantic** + entry, which is what ruling B prescribes for a key that is not authorable stack + metadata. All fifteen are registered by exact key in `RETIRED_KEYS_BY_MAJOR`, + nested spellings included. + + ## Keys deliberately left alone + + `FailoverConfig.dns.ttl` is a declared `externalVocabulary` mirror of the DNS + resource-record TTL field (RFC 1035 §4.1.3) and keeps its bare name. + `CacheAvalanchePrevention.lockout.lockTimeoutMs` was already correct — and it is + milliseconds where its `resetTimeoutSeconds` sibling is seconds, so the two must + not be migrated as if they were one unit. `MetricExportConfig.batch.size` is a + record count and `QueueConfig.rateLimit.max` is a task count: neither is a + duration, so neither has a unit to carry. `ServiceLevelObjective.errorBudget`'s + burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit + anywhere in their prose, so both are outside the gate's population entirely. + Pin tests assert each of these, so a later sweep cannot read this card as + "every duration-shaped number on these files". +- fb77aa5: feat(spec)!: a `tree` field's `reference`, when present, must name the declaring object — any other target is refused at parse (#14892) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention). Maintainer ruling 2026-09-05 on #14892, option A. + + **What changes.** `ObjectSchema` (and `ObjectExtensionSchema`, judged against + the object it extends) now refuses a field declared `type: 'tree'` whose + `reference` names any object other than the declaring one. The refusal is a + located parse issue at `fields..reference` whose message names both + objects and the three ways out: drop `reference` (it is optional on a `tree`), + name the object itself, or declare a `lookup` if a link to a different object + was meant. `FieldSchema` alone is unchanged — a field does not know which + object declares it, so the judgment lives on the object door. + + **Why.** A hierarchy is parent/child within one object, and that is what every + reader of the type already assumed: the tree renderer's parent-pointer + auto-detection takes the first `tree` field as the object's own parent column, + four prose surfaces said self-reference, and `deleteBehavior` materialises on + `tree` beside `lookup` because a self-referential hierarchy is a relation whose + cascade is exactly the intended semantics. The designer's shared `reference` + input reused one "Target object name" help text for three types, and the one + shipped `tree` example pointed at another object under a hedging label — two + spellings parsed silently, and an example taught a third. The key is now + enforced with one meaning; `reference` stays optional on a `tree` as a + redundant self-annotation, which is also what makes a reference-less `tree` + being classified `relation` (and materialising `deleteBehavior`) coherent. + + **Alongside.** `checkViewCompleteness`'s parent-pointer predicate reads the + same rule: a `tree` field is a detectable parent pointer only when its + `reference` is absent or the object's own name, so a `tree` view bound to an + object whose only `tree` field points elsewhere is reported `view/tree-without- + parent-field` rather than blessed. The designer help text for the shared + `reference` row now says so for `tree`, the showcase `showcase_field_zoo.f_tree` + is a self-reference, and the data-modeling docs say "optional and, if given, + must be this object". + + ```ts + // accepted — a self-reference, or no reference at all + parent: { type: 'tree', reference: 'category' } + parent: { type: 'tree' } + // refused at parse — `fields.parent.reference` on object `category` + parent: { type: 'tree', reference: 'department' } + ``` + + **Not measured.** Out-of-repo cross-object trees are NOT MEASURED: no customer + application was surveyed for a `tree` field pointing at a different object. + In-repo, every other `tree` author is a self-reference or carries no + `reference`; the objectui pin's unit fixtures are outside this schema's reach + and are listed on the card. +- 581d8f8: feat(spec): `TryCatchErrorValueSchema` declares the `code` key the `try_catch` engine binds (#14954) + + `TryCatchErrorValue` — the ONE shape the catch region's author, the engine and the run log share for the value a `try_catch` binds to `errorVariable` (default `$error`) — gains an optional `code: string`: the platform-classified error code (ADR-0112) the failing node's own result carried, e.g. `create_record`'s `DUPLICATE_RECORD`. The engine has bound it since `@objectstack/service-automation`'s #14419 change; the schema was a plain `z.object` that did not declare it, so a round-trip through the declared shape silently STRIPPED the key the engine had put there, and the generated reference page documented four keys where the runtime binds five. The `errorVariable` description on `TryCatchConfig` names `code` too, so the authorable surface documents branching on `$error.code`. + + Typed as an open `string`, deliberately not `StandardErrorCode` and not the ledger union: ADR-0112 D3/D4 with the #9106 amendment make the code vocabulary `StandardErrorCode` ∪ registered ledger codes ∪ tenant-authored codes, and `NodeExecutor` is third-party-registrable, so a closed type would be false the moment anyone registers an executor that throws its own code. The closed-at-every-door rule governs `ApiErrorSchema.code` at an HTTP door; this value is bound in-process and never crosses one. + + Additive and optional: every value that parsed before parses byte-identically, and a binding without a classified code still carries no `code` key — absent means "no classified code", never "nothing failed". Semver: a new optional key on a published schema widens the accept set and the exported `TryCatchErrorValue` type without retiring or renaming anything ⇒ `minor`; no ADR-0087 entry is owed because there is nothing an upgrader must migrate. +- f81afe3: feat(spec)!: a typed expression slot fixes its dialect on the envelope arm too, and refuses a blank string (#15028, #15035) + + + + **BREAKING** accept-set narrowing on the twelve authorable keys typed + `CronExpressionInputSchema` (`system/CronSchedule:expression`, + `ai/KnowledgeRefreshPolicy:cron`, `api/ScheduledExport` and + `api/ScheduleExportRequest` `schedule.cronExpression`, + `automation/ScheduleState:cronExpression`, `integration/DataSyncConfig:schedule`, + `system/CacheWarmup:schedule`, `system/BackupConfig:schedule`, + `system/DisasterRecoveryPlan` `testing.schedule`) and + `TemplateExpressionInputSchema` (`ai/PromptTemplate:system`, + `ai/PromptTemplate:user`, `data/Object:titleFormat`). Shipped as `minor` under + the repo's launch-window convention for breaking changes. Measured cost: zero + — of the 46 author values probed across the repo, the examples, the docs, the + skills and the objectui pin, every one is a bare string or a same-dialect + envelope. + + **What changes** (`packages/spec/src/shared/expression.zod.ts`): + + - The envelope arm of each typed schema is `ExpressionSchema` narrowed to that + one dialect literal. A cron-typed slot accepts a bare string or + `{ dialect: 'cron', source }` only; a template-typed slot likewise for + `template`. An envelope naming any other dialect — `cel` or `template` on a + cron slot, `cel` or `cron` on a template slot, or the retired `js` — is + refused with ONE `invalid_union` at the slot whose message is the slot's + dialect-only sentence (`TYPED_EXPRESSION_DIALECT_ONLY[dialect]`, exported). + Before, the arm was the unrestricted `ExpressionSchema`, so a cron slot + parsed a `cel` envelope green and whatever read it received an expression it + could not schedule — a copy-paste artifact of the untyped schema, never a + decision. + - The bare-string arm refuses a blank string — empty or whitespace-only, the + notion of blank `EvaluatedExpressionSchema` already applies (`source.trim()`) + — with ONE `invalid_union` at the slot whose message is the slot's + source-required sentence (`TYPED_EXPRESSION_SOURCE_REQUIRED[dialect]`, + exported). Before, `.min(1)` did not trim, so `' '` normalized to + `{ dialect: 'cron', source: ' ' }` on every typed slot. + - The author type narrows with it: `CronExpressionInput` / + `TemplateExpressionInput` no longer admit a foreign-dialect envelope, and the + published JSON Schema and the generated reference page declare the envelope's + `dialect` as that one literal. `TypedExpressionDialect` names the pair. + + **What does NOT change.** No cron syntax is judged at parse time; `croner` + judges it where a schedule is wired (`CronSchedule.expression`, the one cron + slot with a reader); no grammar is restated in spec. `'not a cron'` still + normalizes to `{ dialect: 'cron', source: 'not a cron' }`, deliberately: the + repo's two cron grammars already disagree on 5 of 32 probed patterns, and a + restatement would be a third. `ExpressionInputSchema` and `ExpressionSchema` + are untouched — the untyped envelope still takes every declared dialect, and an + envelope with neither `source` nor `ast` is refused exactly as before. + + ```ts + // a cron-typed slot, e.g. defineStack({ jobs: [{ schedule: { type: 'cron', expression } }] }) + expression: '0 9 * * 1-5' // accepted, normalized to { dialect: 'cron', source } + expression: { dialect: 'cron', source: '0 9 * * 1-5' } // accepted verbatim + expression: { dialect: 'cel', source: 'now()' } // refused at jobs.0.schedule.expression + expression: ' ' // refused at jobs.0.schedule.expression + expression: 'not a cron' // accepted — syntax is croner's verdict at schedule time + ``` + +### Patch Changes + +- ecd2158: Liveness ledger: `ActionSchema.operation` and `ActionSchema.patch` re-graded `planned` → `live`, and their author warning dropped. + + Both keys were seeded `planned` with an `authorWarn` whose hint said, in as many words, that "nothing performs the write yet". That premise is gone: the runtime half of the declarative row-level field write is merged, so the ledger now says what the tree does. + + - **`operation` → `live`.** It is the executor's own discriminator (`isDeclarativeUpdateAction`, a bare read of the declared key) and it is consulted *before* `type` at every reader: the REST `/actions` door, the MCP `run_action` door, the headless-invokability predicate, the type-error prescription, and the MCP listing summary. + - **`patch` → `live`.** `declarativeUpdateWrite` reads it as the base of the write bag `{ ...patch, ...params }` — the static values sit *under* the ones the dialog collected — and `executeDeclarativeUpdateAction` hands that bag to a single data-plane update of the routed row, under the caller's own execution context. + + Judged separately and both measured, not inferred: deleting the `operation` read fails 24 of the executor's 27 pins, deleting the `patch` read fails 16 of them, and the unmutated tree passes all 27. + + **What moves for consumers.** `@objectstack/spec` ships `liveness/` in its published files, and `@objectstack/lint` resolves that directory off the installed package to build its author-warning map. Dropping `authorWarn` on these two rows therefore removes a real `os lint` finding: authoring `operation: 'update'` + `patch` no longer draws `liveness-planned-property`. Nothing else moves — no schema, no `.describe()`, no export, no accept-set change. +- f2b5e46: fix(spec): withdraw the `field-required-notnull-explicit` ADR-0087 conversion — `required: true` no longer stamps `storage.notNull: true` on anybody's fields (#16693) + + ADR-0113 split the pre-17 `required` tri-binding on purpose: `required` is the **write-time contract** and is NOT a column constraint, and `storage.notNull` alone binds the physical column (`sql-driver.ts#createColumn` has keyed off it alone since that ADR's P0). The `field-required-notnull-explicit` conversion asserted exactly the implication the ADR abolished — it added `storage: { notNull: true }` to every field it found `required: true` on — so it is removed from the conversion registry and from protocol 17's ADR-0087 ledger entry. + + **Who this was reaching, and why it was not confined to old artifacts.** The entry carried `retiredFromLoadPath: true` and a docblock stating that "only `os migrate meta --from <16 or lower>` may apply it". That was not true of this tree. The artifact-ingestion door replays the whole chain with `includeRetired: true` (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) and keys the replay off the artifact's declared `engines.protocol` **floor**, not its age — so any artifact declaring `^17.0.0`, which is the range `create-objectstack` stamps, was converted at boot. Measured on this tree at that seam: an artifact declaring `^17.0.0` on a 17.3.0 runtime came back from the door with `storage.notNull: true` written onto a field its author wrote as nullable-and-write-gated, and the boot logged `converted N site(s) forward` with a remedy sentence telling the author to write the same tightening into the source. On a populated database that instruction is a `tighten_not_null` / `severity: error` / `category: destructive` migration — prescribed as the remedy for a deprecation notice. + + **What moves for consumers.** + + - `applyConversions(stack, { includeRetired: true })` — the artifact-ingestion door and `os migrate meta` — no longer emits or applies this rewrite. The default load posture (`includeRetired: false`) is unchanged: the conversion was already skipped there. + - `os migrate meta --from 16` no longer lists it, and a `required: true` field crosses 16 → 17 carrying its write contract and nothing else. + - Boot no longer warns about it, so an artifact whose only conversion was this one now boots with that warning gone. + - Nothing is authored differently and nothing is refused that was accepted before. `required` and `storage.notNull` both remain authorable and both keep their ADR-0113 meanings. A column is NOT NULL because its author wrote `storage: { notNull: true }`, and for no other reason. + + **No migration is owed to anyone** (maintainer ruling, 2026-09-08, decision batch #85, option A). Genuinely pre-ADR-0113 artifacts are not measured to exist, existing columns are left exactly as they are, and an app that wants NOT NULL columns declares `storage.notNull` deliberately — which is what the app that reported this had already done. + + The protocol-17 ledger entry and the generated upgrade guide now say this in the other direction too, and the falsified sentence in `sql-driver.ts` — "sources authored before protocol 17 carry `storage.notNull` explicitly via the `field-required-notnull-explicit` conversion, so their columns come out exactly as they always did" — is corrected where it stood. + + Two sentences in `@objectstack/driver-sql` that this withdrawal falsifies are corrected with it, and no drift behaviour changes. The `relax_not_null` finding — raised when a column is NOT NULL and the metadata declares no `storage` constraint — used to prescribe "(pre-protocol-17 sources: `os migrate meta` stamps it for every previously-required field)"; it now says the constraint has to be declared by its author, because nothing supplies it any more. The comment beside it, which closed with "`os migrate meta` ratifies it whenever the source is next migrated", says so too. The deliberate SILENCE for a `required: true` field whose column is already NOT NULL is unchanged — this corrects the sentences, never the finding. +- 07f40e5: A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768) + + `POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource): + + ```json + {"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}], + "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]} + ``` + + `min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch. + + What changed: + + - **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies. + - **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached. + - **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path. + - **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it. + + `AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string. +- ceb4877: Documentation: the analytics `where` contract and the `element:number` D3 entry now name the hop an array filter is lowered at. + + Text only — no schema, accept-set, runtime or test behaviour changes. `AnalyticsQuerySchema.where` is still `FilterConditionSchema` and still refuses an array, which is the protocol working as `FilterArray`'s docblock (#5158 ruling C) declares it: a `FilterArray` is input-only authoring sugar, lowered to a `FilterCondition` at the single sink `parseFilterAST` (`@objectstack/spec/data`) the moment it arrives, and only the lowered `FilterCondition` travels any further. + + - `AnalyticsQuerySchema.where`'s `.describe()` gains one sentence pointing array authors at that lowering: an authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire, and this field admits only the lowered `FilterCondition`. It lands in the generated `content/docs/references/{api,data}/analytics.mdx` prop tables, which is where an author reads it. + - The `element-number-filter-rule-array` semantic migration entry recorded its runtime prerequisite one hop too late: "authored array → adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`". `lowerAnalyticsWhere` (`service-analytics`) is the in-process door (#5334) for callers reaching `analyticsService.query` directly. The wire's door is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` before any service code runs, so an un-lowered array is refused there. The entry's reason clause now names that route hop and the `parseFilterAST` lowering the adapter owes before the wire (#15828; the adapter-side fix is objectui#7752). + + The sibling entry `element-record-picker-filter-rule-array` was read for the same claim and does not make it — its measured path is `find()` / `convertQueryParams`, not the analytics wire — so it is unchanged. +- 90e7e6d: `api-surface/` now records the value half of a name declared as both a const and a type, so deleting it is a breaking change the gate reports. + + TypeScript merges an `export const X` and an `export type X` into ONE symbol whose flags carry both. `build-api-surface.ts` mapped that symbol through a first-match-wins lookup that tested `TypeAlias` before `Variable`, so the shard recorded `X (type)` alone and the value half was never enumerated. Ablated: deleting `export const RestApiRouteRegistration` while keeping its type alias left all 17 shards byte-identical, the export total unmoved, and `check:api-surface` printing "public API surface + factory signatures unchanged" at exit 0 — on a removed public value export, which is the exact removal the ADR-0059 breadth gate exists to make loud. On the fixed generator the same deletion reports `- RestApiRouteRegistration (const)` as 1 breaking change and exits 1. + + The generator now emits one row per DECLARED kind. The shipped `api-surface/` shards gain **134 rows across 10 of 17 entry points** — every one of them the previously-missing `(const)` half of a name that also declares a type — as a pure insertion: zero rows removed, zero modified, no reordering. + + **Why `patch` and not `minor`, measured against what a consumer can observe.** No export was added, removed or renamed: `dist/` is byte-identical across this change, and the row grammar `Name (kind)` is untouched, so anything that parsed the artifact before parses it now. The 134 new rows describe exports that already existed — the record got more complete, no capability arrived. What changes is the accuracy of a shipped record and the strictness of this repo's own gate, which is a fix. + + One consequence for the release seat, stated because it is not visible from the diff: `build-spec-changes.ts --previous-surface` is a release-time join, so a release crossing this change will list those 134 rows as `added` surface entries. They are not new API — they are the same exports, newly recorded. +- ca326b5: `IApprovalService.recall`'s contract prose names every actor who may recall, and scopes each one by status (#14670) + + **Documentation only — no key, no accepted value, no runtime behaviour moves.** The implementation has been correct since #12775; only the contract's description of it was stale. + + The docstring said *"Only the submitter (or a system context) may recall"*, then widened to `returned` requests in a second paragraph. Both halves were wrong, in opposite directions: + + - **The list was not exhaustive.** A #3424 override actor — a platform or tenant admin holding no approver slot — may recall a `pending` request. That is the in-product recovery path for an approval routed to an unstaffed position, and this same file already documented it 387 lines above the sentence denying it: the docblock on `ApprovalRequestRow.viewer.can_override` spells the override's levers as `(approve / reject / reassign / recall it)`. One file, two contradicting sentences about the same verb. + - **The ADR-0044 widening read as though it applied to that whole list.** It does not. The override and system arms are ANDed with `status === 'pending'` where they are computed, so neither reaches a `returned` request; an override actor is refused there exactly as any other non-submitter (#12775, maintainer ruling 2026-09-02). Abandoning a revision window is the submitter's alone. + + The rewrite makes **status** the axis instead of appending a caveat, so the second defect cannot come back on a re-read: each status carries its own admitted set, and the `returned` bullet says outright that the submitter is alone in it. + + `ApprovalRecallInput.actorId` carried the same stale sentence (*"Must be the request's submitter (or a system context)"*) and is corrected with it. Fixing only the method docstring would have left the contradiction alive on the very input type the corrected method takes. + + The two sibling docstrings sharing that phrasing are **correct and unchanged**: `ApprovalSendBackInput.actorId` and `ApprovalResubmitInput.actorId`. `isOverrideActor` is called from exactly five places in `plugin-approvals` — `decideNode`, `reassign`, `recall`, `attachViewers` and `visibleRequestIds` — and neither `sendBack` nor `resubmit` is among them, so no override actor reaches either. + + The published prose already described the corrected rule (`content/docs/automation/approvals.mdx`: an admin "may act on any `pending` request — approve, reject, reassign it to a real approver, or recall it"). This docstring was the one surface that had not kept up. +- d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct. + + Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid. + + No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both. + + Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps. +- ae05f2e: `collectFlowGraphs` now honours the `readonly FlowNodeParsed[]` it declares: a member of a region's node list that is not a record is dropped from the `FlowGraph` it hands out, instead of being passed through verbatim. + + An ADR-0031 container keeps a whole sub-graph inside `FlowNodeSchema.config`, a deliberately open `z.record`, so `collectFlowGraphs` re-derives those inner node lists at run time and checks them with `Array.isArray` — which proves the LIST and never its MEMBERS. An empty item in a YAML `nodes:` list under a `loop` body deserialises to `null`, and that `null` reached `graph.nodes` on every returned graph, at every depth. No caller could prevent it: this is an array the walk picks up itself, so no coercion at a call site ever holds it. Filed as #16752. + + - **What changed.** The walk filters what it hands out and skips what it descends into, through one predicate. Array identity is preserved when nothing is dropped, so a well-formed flow allocates nothing new. + - **What deliberately did NOT change.** The declared input type is untouched — widening it to tolerate malformed members was refused on the anti-AI-error axis, and this is the opposite move: the producer now keeps the promise it already made. The schema refusal that owns a malformed region still fires, unchanged; this walk runs inside `FlowSchema`'s parse, where a thrown `TypeError` would escape `safeParse`, so the repair is a drop and a skip and never a throw. `FlowGraph.path` still indexes the raw authored list, so a Zod issue stays anchored where the author wrote the node. + - **Visible consequence.** As with the sibling repairs that read their lists through a record filter, a dropped member renumbers the ones behind it *within* `graph.nodes` — a difference in the index, never in whether a node was judged, and only in a list that was already malformed. +- b548e43: `colorField` now documents what it means: a field to DERIVE a colour from, not a field holding one. + + `TimelineConfigSchema`, `CalendarConfigSchema` and `GanttConfigSchema` each declare a `colorField`, and all three `.describe()` strings said only that the field "determines"/"drives" the colour — `'Field to determine item color'`, `'Field whose value determines the event color'`, `'Field that drives the bar color'`. Read literally, that invites pointing the key at a field whose stored value *is* a colour, which is the one case the renderers need the least: the common author intent is `colorField: 'status'`, a select field whose options already carry the colours. + + The renderers resolve it as a derivation ladder (objectui#7243, shared as `createFieldColorResolver` in `@object-ui/core`): + + 1. the option `color` the field declares for the record's stored value; + 2. else the value itself, when it already is a colour literal (hex 3/6/8-digit, `rgb(...)`, `hsl(...)`); + 3. else each renderer's own last rung — the gantt derives a semantic colour token, the calendar hashes onto its theme-aware palette, the timeline draws its default marker. + + The three strings now say that, each naming its own last rung. **Nothing in the accept set moves**: all three keys stay `z.string().optional()`, and a config pointing `colorField` at a plain hex field is still exactly as valid as before — that is rung 2. This is prose on a declared key, so the only regenerated follower is `content/docs/references/ui/view.mdx`. +- 132742f: The Expression Protocol dialect table no longer names `cron-parser` as the `cron` engine. That package is not a dependency of any ObjectStack package; the row shipped to authors through the generated reference page (`content/docs/references/shared/expression.mdx`) and pointed them at the wrong library for field counts, alias vocabulary and second-field semantics. + + The row now says what the code does: no cron syntax is judged at parse time; `croner` evaluates a cron expression only when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → `CronJobAdapter`, where an invalid pattern is refused); every other cron-typed slot is parsed and reaches no engine; and `@objectstack/formula`'s registered `cron` engine has no caller outside that package. Documentation only — no schema, accept set or behaviour changes. +- 85a2459: fix(spec): the dashboard `gap` field no longer describes itself to app authors in Tailwind vocabulary + + `ui/dashboard`'s `gap` key told app authors its value in the vocabulary of a CSS + library they never chose and cannot act on. **Two** independent producer strings + carried that wording, and they feed two independent customer-facing surfaces: + + - `dashboardForm`'s `helpText` — `Grid gap (Tailwind units)` — rendered verbatim in + the Studio property panel, which is spec-driven and feeds this form straight into + the generic form renderer. + - `DashboardSchema.gap`'s `.describe()` — `Grid gap in Tailwind spacing units` — + rendered as this field's row in the published reference page + `content/docs/references/ui/dashboard.mdx`. The reference corpus renders + `.describe()`, never `helpText`. + + Both now read **Space between widgets, in steps of 0.25rem (4 = 1rem)**: what the + author decides, plus the magnitude, stated in a CSS unit instead of a framework's + scale. The magnitude had to survive the rewrite rather than be dropped with the + framework name — the number is a spacing step, so `4` means `1rem` and not `4px`, + and an author who lost that would come away knowing less than before. + + The step size is stated as measured rather than inferred: the dashboard renderer + sets the grid gap as an inline style computed from this key, so every accepted + value is linear and one step is exactly `0.25rem`. "Tailwind units" was doubly + wrong — it named an implementation dependency, and it named one the consumer of + this key does not have. + + **No schema change.** `gap` stays `z.number().int().min(0).optional()` and accepts + exactly what it accepted before; nothing is added to or removed from any public + surface. `columns` is deliberately untouched on both of its producer lines — + `12` is an author-visible fact about the grid being laid out, not a framework + detail — and this is one field's two strings, not a sweep for framework words. + + The `en` metadata-forms translation bundle is a mechanical copy of the form source, + so it is regenerated to match. Translated locales are not touched: regeneration + fills gaps only and never overwrites an existing leaf. +- 8976ea1: `DatasetMeasureSchema.format` now documents what a DATE-valued measure can and cannot say, and the numeral-pattern examples no longer stand as the whole story. + + The field was silent about date measures while advertising `e.g. "$0,0.00", "0.0%"` — the pattern grammar a date measure is precisely unable to read. An author with a `min` / `max` over a date field read that line, wrote `format: 'YYYY-MM-DD'`, parsed clean, and got the locale default. + + The statement is carried by a `.describe()` where there was none, so it reaches the published surfaces an author actually reads: the generated JSON Schema (`json-schema/ui/Dataset.json`, `DatasetMeasure.json`) and the reference table in `content/docs/references/ui/dataset.mdx`, whose Description cell for `format` had been rendering the silence as a blank. The docblock above it carries the longer measured record. + + What it now says, measured rather than assumed against the objectui pin this repo builds against: a numeral pattern applies to a numeric measure; a date-valued measure never reads a date PATTERN — a date-only value reads `format` as a display STYLE (`short`, `relative`), and a datetime value ignores `format` altogether. + + Nothing accepts or rejects differently: `format` remains `z.string().optional()` and no measure is refused. Documentation over a published schema (objectui#7178 ruled A). +- acabd24: The authoring-time `defaultValue` gate now prescribes the key rename an author actually made, instead of a type error about a member they never wrote. + + `checkLiteralDefaultValue` — the shared core of the field gate (`FieldSchema.defaultValue`) and the action-param gate (`ActionParamSchema.defaultValue`) — read a value-contract rejection positionally, `result.error.issues[0]`. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a default whose keys were **renamed** the actionable message sorted last and was discarded. An `address` default authored as `{ street: 5, postal_code: '98101' }` answered `Invalid input: expected string, received number`, and a `location` default authored as the legacy `{ latitude, longitude }` pair answered `Invalid input: expected number, received undefined` — while `AddressValueSchema` and `LocationValueSchema` had each built the rename prescription and thrown it away. Which of the two the author got depended on whether some unrelated member happened to also be wrong: nobody chose that, and nobody could see it. + + The gate now prefers the undeclared-key issue when the rejection carries one. `LiteralDefaultValueVerdict.detail` keeps its name, its type and its documented meaning — "the 'why' a refusal carries verbatim"; what changes is which of several already-reachable messages it carries. + + ⛔ No verdict moves. Exactly the same defaults are accepted and refused, on the same evidence — only the refusal text changes. + + Scoped by measurement rather than inherited: the sixteen classes `valueSchemaFor(def, 'stored')` covers were swept again on this function, at both arities. Only `location` and `address` can emit `unrecognized_keys` at all, because only they are backed by a key-closed object schema — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half of the rejection. +- ab50c8f: Record, on `DeleteDataRequestSchema` itself, what it is for and why the DELETE data door carries no `requestSchema` for it. + + The schema is the request contract of `DataProtocol.deleteData()`, consumed statically through the `DeleteDataRequest` type alias and parsed at runtime nowhere — a grep that finds "exported, documented, zero `safeParse` call sites" is reading the wrong surface, and had already filed it once as a gap. Its docblock now says so; records that the absence of a `requestSchema` on `DELETE /api/v1/data/:object/:id` is a pinned decision (#3899 — the catalog entry states it in place of the key, and `plugin-rest-api.schema-refs.test.ts` goes red if one is added, because the route reads no body); and points at the compile-time check (#15866) under which a field added to the schema as required reddens the door at build instead of being silently unsent. + + Documentation only: no shape, `.describe()` text, or export changes. `@objectstack/spec` ships the new text in its published type declarations and in the source file it publishes directly via its `src/**/*.zod.ts` entry. +- 89cf4d6: `check:api-surface` (and every other gate that reads `packages/spec/dist`) no longer refuses a dist that is exactly current because a source file's mtime moved without its bytes changing. + + The freshness rule shared by four gates and the pre-commit hook compares `dist/**/*.d.ts` mtimes against `src/**/*.ts` mtimes. That is the right primitive — it is the artifact those gates consume, and it sees the hand-edited dist and the toolchain change no content digest can — but it cannot tell a real edit from a rewrite that left the bytes alone. A `git merge` re-checks-out an unchanged source file and bumps its mtime; the build that follows correctly does not run, because turbo's cache hashes content, so it is a cache hit that rewrites nothing and leaves every `dist/` mtime where the previous build left it. The gate then refused a correct dist, and prescribed a full rebuild — minutes, under the shared verify lock — of an artifact that needed none. + + The mtime rule keeps its power to convict and gains one way to be answered. `packages/spec`'s build now records a second stamp beside the existing one, `dist/.build-input-hash-dts`, holding the same build-input digest — but written **only** by a build that actually emitted declarations, so `OS_SKIP_DTS=1` leaves it alone. When that digest equals the sources on disk, the declarations demonstrably describe them and the refusal is cleared. The evidence may only ever **acquit**: a missing, unreadable or mismatched stamp leaves the mtime verdict standing, so nothing that passed before can start failing, and the `OS_SKIP_DTS=1`-on-a-built-tree shape that ruled out `dist/.build-input-hash` for this purpose still fails, because that build never refreshes the new file. + + The refusal message was wrong in the same case and is now driven by what was measured: it names a real content change and prints both digests when the stamp disagrees, says plainly that there is nothing to compare against when no stamp exists, and no longer sends every reader after `OS_SKIP_DTS` regardless of cause. It also notes that a repo-wide `pnpm build` may be a cache hit that rewrites nothing, so the remedy names the package build directly. + + The published tarball gains one 65-byte file next to the stamp it already shipped. +- 21c5dcb: The `DisasterRecoveryPlan` docblock example no longer teaches a cron dialect the platform's scheduler refuses. + + `DisasterRecoveryPlanSchema`'s `@example` block spelled its six-hourly backup schedule `'0 0/6 * * *'`. A numeric prefix before the step (`0/6`) is Quartz-style stepping. The only cron parser this platform runs is `croner` — reached through `CronJobAdapter`, which hands every scheduled expression to `new Cron(...)` — and it refuses that spelling. Measured against the `croner` 10.0.1 copy installed for `@objectstack/service-job`: + + ``` + new Cron('0 0/6 * * *') + -> TypeError: CronPattern: Syntax error, stepping with numeric prefix ('0/6') + is not allowed. Use wildcard (asterisk-slash-step) or range (min-max/step) instead. + ``` + + The example now reads `'0 0,6,12,18 * * *'`, which the same parser accepts and which fires at 00:00, 06:00, 12:00 and 18:00 — the instants the old spelling was written to mean. The sibling example `'0 2 * * *'` on the same schema is accepted unchanged; it was the positive control for the measurement, so the refusal above is a reading rather than a broken probe. + + The wildcard-step spelling croner's own error message suggests, and which this schema's tests use, is **not writable in this position**: inside a `/** … */` block comment the step separator closes the comment, and the file stops parsing (measured — esbuild refuses it at the example's own line). The enumerated form is the equivalent that survives a docblock, and both forms were measured to produce identical firing instants. + + Nothing fires differently, because nothing fires at all: `BackupConfig.schedule` is declared-but-unwired and reaches no scheduler, and `CronExpressionInputSchema` judges no cron syntax at parse time by design (`shared/expression.zod.ts`) — so the bad example sat in a position that is deliberately undefended. The accept set of every schema is unchanged by this edit, and no export moves. What changes is what an author copying the example gets: the docblock publishes verbatim into the shipped `dist/system/index.d.ts`, so it is the text an editor shows on hover. +- 1a7a7c9: The environment artifact's `checksum` now states its own coverage boundary, and `grantedPermissions` states that it sits outside the digest by design. + + Describe text only — no key, value schema or accept-set change on `EnvironmentArtifactSchema`, and the digest itself is computed and verified by the control plane, not here. + + - **`checksum`** carried the shared `Sha256DigestSchema` describe ("SHA-256 digest (64 hex chars)"), which says what the value *is* and nothing about what it *covers*. It now has its own field-level describe: the SHA-256 digest of the canonical JSON serialization of the `metadata` block (stable key ordering), computed by the control plane when assembling the GET response — and coverage stops there, so no other key on the envelope is under the digest. The shared `Sha256DigestSchema` describe is unchanged, so every other digest field still inherits it. + - **`grantedPermissions`** gains one sentence group at the end of its describe: it sits beside `metadata`, outside the digest, and integrity of the granted consent set rests on the carrier — the artifact is environment-local and control-plane served (ADR-0003 / cloud ADR-0007) — an accepted boundary of this envelope rather than an oversight. Its five existing clauses (the manifest-`id` keying, the `sys_package_installation` source, the enforcer consumer, absent ≠ `{}`) are unchanged. +- 74628d9: Every filter-operator member now carries a `.describe()`, so the published `data/filter` reference documents all of them instead of a subset. + + The Description column of `content/docs/references/data/filter.mdx` is filled from `prop.description` — the JSON-Schema projection of a Zod `.describe()`. A JSDoc block above a member never reaches that column, so members documented by JSDoc alone rendered with an empty Description cell on a published reference page, including operators whose semantics an earlier correction campaign existed to fix. + + Seven cells on that page were blank and are now filled: `EqualityOperator.$eq` / `$ne`, `StringOperator.$contains` / `$notContains` / `$startsWith` / `$endsWith`, and `QueryFilter.where`. + + The descriptions are prose about behaviour that already ships — no operator semantics, accept-set, export or authorable key moved, and the JSDoc blocks are kept as-is: + + - `$eq` / `$ne` state the default-operator role, the SQL and MongoDB lowerings, the `{ $field }` comparand position, and that `{ "$eq": null }` / `{ "$ne": null }` are the has-no-value / **has-a-value** predicates — the value question, never a key-presence one. + - The four case-sensitive `$contains`-family members state their case contract, their SQL lowering, and the comparand contract they share: `%` and `_` are ordinary characters because the family escapes and anchors the comparand for the caller, which is what separates them from `$like` / `$ilike`. + - `where` states the condition-tree shape plus the two semantics that were ruled rather than inherited — `$not` is NULL-safe, and empty `$and` / `$or` are the boolean identities. + + Each description is now a module-level constant read by **both** copies of its operator — the documentation schema (`EqualityOperator`, `StringOperator`, `RangeOperator`, `SpecialOperator`) and the enforced `FieldOperatorsSchema` — extending the pairing `ORDERING_COMPARAND_DESCRIPTION` and `SET_MEMBER_DESCRIPTION` already gave the ordering and set slots. The two copies now share the text rather than a description of it, so an operator can no longer be documented in one and blank in the other. +- 2c753fe: feat(runtime): a flow action's run context now carries `recordLoadDenied` (#15168) + + The previous release declared `AutomationContext.recordLoadDenied?: true` and + said so plainly: **declared, not yet populated on the flow face.** The + script/body face of both action doors emitted the signal, but + `dispatchFlowAction` handed `automation.execute` a context without it, so a + `runAs: 'system'` flow that guarded on the documented key was inert — never + `true`, never wrong, and indistinguishable from a flow whose caller could read + the row. + + **This release populates it, on both doors in one stroke** — REST + `POST /api/v1/actions/...` and the MCP `run_action` bridge: + + ```js + // a runAs:'system' flow, guarding before it acts on the subject row + if (context.recordLoadDenied === true) { /* the invoker cannot read this row */ } + ``` + + - **The exact producer shape, unchanged.** The one shared producer + (`loadActionSubjectRecord` → `actionRecordLoadSignal`) already returns + `{ recordLoadDenied?: true }`, and the flow door now spreads it as a + **sibling of `record`** — never a key on the record, and **absent**, never + `false`, when nothing was refused. So a flow reads it exactly as a handler + does, `recordLoadDenied === true`. + - **Both doors, structurally.** `dispatchFlowAction`'s wiring now takes the + load OUTCOME (`subject`) instead of a bare `record`, and derives both the + record and the signal from it. A caller can no longer forward the row while + dropping the verdict that says the caller could not read it — the omission is + a compile error rather than a guard silently inert one door over, which is + the defect the handler-face signal was filed for. + - **Purely additive.** Nothing is refused that was not refused before, no + existing key changes value, and the `recordId` stamp is deliberately kept: + `record.id` still arrives exactly as it did, which is why the flag — and not + `record.id` — is the authorization predicate. Whether the automation engine + *acts* on the key (a flow-level refusal, a step condition) is a separate + decision and is deliberately not part of this change. + - **`@objectstack/spec` (docs only).** The contract's "not yet populated on the + flow face" sentence is retired; no type changes. +- 76c8c5a: `GetMetaItemsRequestSchema.organizationId` no longer documents itself as always consulted. + + The published `describe()` opened with "Selects the org partition in the ADR-0005 overlay read order" and closed with "Absent = environment-wide read: only env-level overlays apply and no org partition is consulted." Stating only the absent case invites the converse, and an integrator reading it completes it as *present ⇒ consulted* — so a caller who supplies an organization believes it has scoped a read that can in fact be environment-wide. A supplied organization is not consulted on every `getMetaItems` read. + + The corrected text qualifies the promise instead of implying its converse: the parameter selects the org partition **when an org partition applies**, and supplying a value "does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide and only env-level overlays apply." + + Prose only. No key is added, removed or renamed, no export moves, no accept set changes and no runtime behaviour changes — the schema's shape and validation are byte-for-byte what they were. What ships is the JSON-Schema `description` for the existing `organizationId` key and the matching row in the generated API reference, which is why this is user-visible enough to owe an entry and narrow enough to be a patch. + + The three sibling `organizationId` describes on `GetMetaItemRequestSchema`, `GetMetaItemLayeredRequestSchema` and `GetMetaItemCachedRequestSchema` are deliberately left alone. +- d8d2776: The tenant-scope and owning-business-unit system columns now render a localised display name on the `/meta` read exits, as the other platform-injected columns already did. + + `translateObject` carries a built-in label table for the columns the platform injects onto every eligible object, applied while a column still carries its injected English default, so a `zh-CN` / `ja-JP` / `es-ES` request never sees the English label on a custom object that ships no translation entries of its own. The table covered `owner_id`, `created_at`, `created_by`, `updated_at` and `updated_by` but not the two remaining injected columns, `organization_id` (`Organization`) and `owning_business_unit_id` (`Owning Business Unit`), so those two leaked English on every locale. Both rows are added, with the wording the platform bundles already use for the same columns on platform objects. The identity-stable column definitions are untouched, no new authorable key is introduced, and a label a tenant or author customised is still never overridden. +- 32c917d: `KnowledgeRefreshPolicy.cron` no longer tells authors that the `cron` dialect engine judges their syntax "when the expression is evaluated". Both halves of that sentence were false: nothing evaluates `refresh.cron` — `service-knowledge` reads `refresh.onRecordChange` and never `refresh.cron` — and `@objectstack/formula`'s registered `cron` engine has no caller outside that package, so it was never going to issue that verdict either. The claim shipped to authors through the generated reference page (`content/docs/references/ai/knowledge-source.mdx`), naming both an engine that never sees the value and an event that never happens. + + The docblock, the `.describe()` and the slot's two pin-test comments now say what is true today, matching the wording of the already-corrected Expression Protocol dialect table: cron syntax is not checked at parse time and no engine evaluates this slot — `croner` judges a cron pattern only where a schedule is wired (`CronSchedule.expression`, a different slot) — so the verdict belongs to whatever external scheduler the author hands the value to. Documentation only: no exported symbol, no authorable key and no accept-set movement; the parse behaviour is byte-for-byte unchanged, and the pin that proves `'not a cron'` still normalizes is untouched. +- af7edfe: Liveness ledger: four verdicts re-derived and corrected ahead of the author-warning flip. + + The ledger's `dead` and `live-elsewhere` verdicts are about to start warning downstream authors, so each row was re-measured against a pinned tree — objectstack `5d55afec4d`, objectui `a472b071` — with a firing positive control on the same instrument and corpus before any zero was read as a reading. + + - **`validation.label` / `.description` / `.tags`: `dead` → `live`.** The 2026-08-10 sweep upheld `dead` on *reachability*, not on the read: `ValidationPreview` genuinely rendered all three, but the only route that mounted it was the standalone `validation` resource door, and ADR-0088 had retired that kind — so on the governed path (a rule embedded in its object) the preview was never handed a draft. That note named its own falsifier, and it has since landed: the standalone door is gone, and `EmbeddedItemEditor` now resolves `getMetadataPreview(editAs)` and mounts the preview on the live draft, with the embedded anchor binding `editAs: 'validation'`. Under the ruling that a designer preview rendering a key to a human is a runtime consumer, these three display keys are live. They remain docs-shaped and are still not author-warned. + - **`view` `list.tabs`: `live` → `dead`.** The previous note was wrong in both directions at once. It credited objectui's `TabBar` with reading `icon`/`visible`/`pinned`/`filter` — true of the component, but **nothing mounts it**: every `TabBar` render site in the whole renderer tree is its own definition or one of its two test files, and `ListView` never reads `tabs` off the view schema, so authoring `list.tabs` draws no tab bar. And it called `tabs[].order` a dead sub-surface while `getVisibleTabs` sorts on exactly that key. The two author-time readers that do walk the key (a field-reference lint and the metadata diagnostics) check `tabs[].filter[].field` for reference integrity and deliver none of the key's declared effect — validated-then-ignored is accept/reject, which this ledger has always kept separate from liveness. + + No published surface moves: these are ledger JSON rows plus the generated count table, with no export, key, or accept-set change. The `list.tabs` re-grade does mean an author who writes tabs on a list view will be told the key is inert — which it is, and was. +- b60f48b: `manifest.integrity`'s TSDoc no longer asserts an unpack-time verification that nobody performs. + + The `integrity` docblocks said that per-file re-verification at unpack is the **cloud control plane's** obligation. The cloud repo's own design docs said it is the **runtime's**. Neither side unpacks anything, so the two published texts pointed at each other and a reader of either learned that a verification exists when none does. This text ships in `@objectstack/spec`'s `.d.ts`, so the wrong claim reached every consumer that hovered the field. + + Both `integrity` docblocks in `manifest.zod.ts` — `PluginIntegritySchema` and the `ManifestSchema` field — now state what is true: + + - **Computed and self-checked by the publisher.** `os plugin build` computes the map into the compiled manifest, and the `os plugin publish` preflight re-hashes the artifact bytes against it, refusing the upload on a digest mismatch, a declared entry with no file, or a packaged file the map does not declare. An absent map is a permissive pass — the field is `.optional()`. + - **Not re-verified at unpack.** That leg is not implemented: there is no `os plugin install`, and the archive reader's only production caller is the publish preflight reading back its own output. It is owned by the **future runtime loader** (ADR-0025 §3.5 steps 4–7), not by the control plane, which stores the artifact blob opaquely. The enforce leg is tracked on #11331. + + No schema, export, key or accept-set changes — the field's shape, optionality and `.describe()` are untouched, and a present `integrity` map validates exactly as before. What changes is that the documentation no longer advertises a guarantee the runtime does not deliver. + + The liveness ledger row for `integrity` and its README note carry the same corrected attribution. The row's `status` (`dead`) and `verifiedAt` are deliberately unchanged: this is a prose correction, not a re-measurement. +- c78c918: Documentation: the manifest surface no longer describes itself as an open object. + + `ManifestSchema` became a `strictObject` when the manifest surface was closed against unknown keys, but five prose sites still described the earlier posture. They shipped, so an author (or an AI writing metadata) reading the declarations was told the manifest tolerates undeclared keys — while the runtime rejects them by name and offers the declared spelling for a near miss. Prose that contradicts a tightened contract teaches exactly the wrong reflex, so each site now states the current refusal rather than merely dropping the old claim: + + - `AssembledPackageBodySchema`'s docblock no longer explains its lack of a `strictObject` spelling by calling `ManifestSchema` open. The posture is inherited: the schema is `ManifestSchema.extend(...)`, and `.extend()` carries the base's unknown-key handling, so an undeclared key on an assembled body is refused — measured, with the rename suggestion intact. + - The artifact-registration seam kept the half of its reasoning that still holds (the schema applies defaults, so a parsed clone would not be byte-identical) and retired the half that does not ("Zod strips undeclared keys") — the key is now refused at that parse rather than dropped from the clone. + - The `os compile` per-package rule pass explains why a body may be re-read as its own manifest: nothing parses that superset, and against `ManifestSchema` it would now be refused. + + No schema, behaviour or export changed; `check:api-surface` and the generated reference pages are unmoved. +- 51df9fd: `navigationContributions[].group` now documents the mis-aimed case, not only the omitted one (#14925) + + The `describe()` on that key said what happens when `group` is **omitted** and nothing about what happens when it is **present and names no group the target app declares** — which is the case that actually bites. A contributing package cannot see the target app's group ids at authoring time (the target app belongs to another package), so a wrong id is undetectable by reading the contributor's own source; and the platform **relocates** the items to the app's top level rather than refusing them, so the menu renders, a smoke test passes, and the information architecture has silently changed. + + The description now names that third case: it is **not refused**, the items are appended at the app top level anyway, and a `nav_contribution_group_missing` diagnostic is emitted — by the runtime at `warn`, and by **both** `os build` and `os validate` at compile time, in each command's `--json` payload under the existing `warnings` key. + + Prose only. `group` remains `SnakeCaseIdentifierSchema.optional()`, the accept set is unchanged and nothing is refused that was not refused before; the recorded authorable key surface (`authorable-surface.json`) and the schema manifest (`json-schema.manifest.json`) are byte-identical. What moves is the string an author reads: the generated reference rows in `content/docs/references/ui/app.mdx` and `content/docs/references/kernel/manifest.mdx`, and the `description` on the published JSON Schemas that embed `NavigationContribution` (its own schema, the bundled `objectstack.json`, and 22 `json-schema/api/*` and `json-schema/kernel/*` package envelopes). +- 70f7d6d: `ObjectTitleCompleteness.status` now documents which INPUT each grade describes. No predicate, no grade computation and no accept set moves — the classification is unchanged and correct; what changes is what the declaration tells the reader it means. + + `explicit` was documented as "an explicit pointer (`nameField`/`displayNameField`) is set". That is accurate about the predicate, and that is what made it dangerous: on a body served by a `/meta` read exit a pointer is present whether or not the author wrote one, because the exit replays the registry's object-materialization seam (`materializeServedObjectOnto`, which runs `provisionPrimary` in designate-only mode). So `explicit` there means "a pointer is present", never "the author designated this" — and the old wording invited the second reading at every call site. + + The corrected declaration says three things the old one left to inference: + + - **Provenance decides the grade.** On a served body `explicit` carries no authorship information, and `derived` is unreachable except where that replay withheld the designation. On a body captured before the write seam — an authored definition as written, which is what `os build` / `os lint` hand this predicate through `@objectstack/lint`'s `validateRecordTitle` — `explicit` really does mean the author wrote a pointer. + - **Authorship is not recoverable afterwards, deliberately.** The write-side inverse `stripProvisionedPrimaryFrom` removes the pointer exactly when it is byte-identical to what the derivation would produce, and says so in its own words: the two are indistinguishable by construction, the same bytes. Neither the served document nor the stored row can answer "did the author designate this?". + - **The consequence for callers.** Never build a check on `explicit` that needs the authored answer without first proving the input is a pre-write body. + + `objectTitleCompleteness` itself carries the one-sentence form of the same warning, because a caller hovering the function is on a different path from a caller hovering the grade. + + The correct words already existed in this repo — on the WRITE seam, where nobody reading the grade goes. This moves their substance to the grade. +- c677cda: Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an + Apache-2.0 package in this repository (ADR-0132). + + Single-database, row-level organization isolation was already open — the tenant Layer 0 wall, + the three tenancy postures, the organization and invitation objects, better-auth's organization + plugin, and the `requiresService: 'org-scoping'` Setup gates. What was closed was the one + registrar of the `org-scoping` service, so an install that set `OS_TENANCY_POSTURE=isolated` + could not enforce it: `serve` refused the boot, and the only way past was + `OS_ALLOW_DEGRADED_TENANCY=1` — the wall configured but not enforced. This package is that + missing registrar. + + It provides: + + - **`organization_id` auto-stamp on insert**, from the caller's active organization. A supplied + — possibly forged — value is overwritten, never trusted. + - **Per-organization seed replay** on `sys_organization` insert, from the app's own seed + definitions. Never another organization's rows. + - **Default-organization bootstrap** for the platform admin, idempotent. + - **The walled-posture membership-policy gate**: a deployment that raises the wall must declare + what a new user joins, or the boot is refused. + + Only the commercial **entitlement** stays closed. The open class carries no licence check of any + kind and offers no hook for one; an enterprise deployment resolves the same package name to a + private, licence-gated subclass through its own `workspace:*` declaration, so which class is + mounted is decided by the manifest that declares the name. + + ⚠️ Shipping the registrar is not yet the same as an open install raising the wall: `objectstack + serve` still resolves the runtime from the served app's own declaration and is not yet wired to + mount this package off `OS_TENANCY_POSTURE`. That, and the isolation matrix run against a real + registrar rather than a posture stub, are tracked separately. +- 7f745c3: The manifest `permissions` block's unknown-key refusal now names the surface and offers the rename, like every other block on the manifest. + + `PluginPermissionsSchema` decides which services, hooks, network hosts and filesystem paths a plugin may touch. It has refused unknown keys since it was introduced, but through zod's own bare message: an author who transposed `hooks` as `hoooks` read `Unrecognized key: "hoooks"` — the key echoed back, with no surface name and no suggested spelling — while every neighbouring block on the same manifest (`contributes`, `contributes.kinds[]`, `engines`, the legacy `engine`, and the manifest root itself) named all three. Born closed at the ADR-0025 plugin-distribution work, it never passed through the unknown-key campaign that gave the others their error maps. + + It now uses the same `strictObject` helper as its neighbours, so the refusal reads: + + ``` + Unrecognized key(s) on the `permissions` block of this package manifest: `hoooks`. + Did you mean `hoooks` → `hooks`? … + ``` + + Three spelled-out near-misses that edit distance cannot reach are curated as aliases: `filesystem` and `paths` point at `fs`, and `hosts` points at `network`. + + **The accept set does not move.** `strictObject` is `z.object(shape, { error }).strict()` — the declared keys and the strictness are unchanged, and an error map is consulted only once an issue is already being raised. The `permissions` union keeps both arms (the legacy flat string list and the structured block), and the union itself is untouched. Only the text of a refusal that already happened is different. +- 5eb24f8: The `PluginSchema` describe strings for `staticPath`, `slug` and `default` now name `ui`, the plugin type the enum actually accepts. + + `PluginSchema.type` is `z.enum(['standard', ...CORE_PLUGIN_TYPES])`, and `CORE_PLUGIN_TYPES` spells the frontend member `ui`. The three describe strings beside it still named `ui-plugin` — a value the same schema refuses two lines above. They are not merely stale: they read as instructions ("Required for `type="ui-plugin"`"), so an author or an agent following the field's own documentation writes a value that is then rejected, with the correct spelling nowhere in the sentence that sent them there. + + The strings now read `(Required for type="ui")`, `(Required for type="ui")` and `(Only one "ui" plugin can be default)`. Because these describes compile into the published JSON Schema and into the generated reference page, the correction reaches every consumer that reads field documentation out of the spec rather than out of the source file — the generated `content/docs/references/kernel/plugin.mdx` table now agrees with the `type` row printed directly above it, which previously listed `'ui'` among the accepted members while the three rows underneath told the reader to write `ui-plugin`. + + No accept/reject behaviour moves: `type: 'ui-plugin'` is refused before and after, `type: 'ui'` is accepted before and after, and no key is added, renamed or removed. The closed-set pin tests that name `ui-plugin` as a non-member are deliberately unchanged — they are the reason this correction is provable. +- cc00df2: ADR-0087 semantic-migration ledger: register the retirement of `@objectstack/core`'s `PluginSecurityScanner` (#14919) + + `PluginSecurityScanner`, `ScanTarget` and `SecurityIssue` are removed from + `@objectstack/core` in the same PR, under ADR-0049 enforce-or-remove (maintainer + ruling 2026-09-05, director summon #14, decision batch #42). This is the ledger + half: a D3 semantic entry + (`src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts`, + concatenated into `MIGRATIONS_BY_MAJOR[18].semantic` by `gen:migration-registry`) + so the retirement reaches `spec-changes.json` and the generated upgrade guide + rather than being invisible to every upgrade channel. + + FROM `new PluginSecurityScanner(kernel.logger)` → TO nothing: delete the import + and every call. There is no replacement export, and a caller that branched on + `result.status === 'passed'` takes that branch unconditionally — it is the only + branch the scanner ever produced, because four of its five scan methods returned + an empty issue list on every input and the fifth read a vulnerability database + whose only writer had zero callers. + + Why an entry is owed at all, and why D3 rather than a D2 conversion: the class + has no spec schema and never had one. It is a runtime TS class, so there is no + authorable key to tombstone with `retiredKey()` and no stored `sys_metadata` row + a conversion could rewrite — a scanner was constructed per call and every result + lived in a per-instance Map discarded with the object, so + `applyConversionsToStoredItem` has no seam that would ever see one. The enforced + channel is tsc at the consumer's own import site; for anyone it does not reach, + this entry and the upgrade guide are the only channel. That is the + `contracts.IDataDriver.findStream` and `actor-user-roles-to-positions` + disposition, applied to a surface one layer further out than either — those are + declared in `packages/spec`, this one only in `packages/core`. + + Measured, and worth recording because the entries README warns of a regeneration + lap that did not materialise here: `check:generated` reports all 15 artifacts up + to date after the entry landed, and running `gen:spec-changes` and + `gen:upgrade-guide` explicitly moved neither file — a major-18 semantic entry is + not yet projected into either. `registry.ts` is the whole generated diff. + + No behaviour in `@objectstack/spec` changes; this adds a ledger row and the + regenerated region that carries it. +- f4e6adf: docs(spec): mark `PromptTemplate.system` / `.user` `[EXPERIMENTAL — not enforced]` (#15954, #16321) + + Prose only. `Clause-②: no` — no accept-set change, no new/narrowed authorable + key, no matrix declaration. Every value that parsed before parses now, and + every value refused before is refused identically. + + Under the #15954 ruling (decision batch #56, option B) the template-typed pair + is **marked, not retired**. Both `.describe()` strings on + `ai/PromptTemplateSchema` now carry the repo's existing + `[EXPERIMENTAL — not enforced]` prefix and state that no runtime renders or + executes the template today: + + ```ts + system: TemplateExpressionInputSchema.optional().describe('[EXPERIMENTAL — not enforced] System prompt — supports {{var}} interpolation. No runtime renders or executes the template today.'), + user: TemplateExpressionInputSchema.describe('[EXPERIMENTAL — not enforced] User prompt template — supports {{var}} interpolation. No runtime renders or executes the template today.'), + ``` + + **Why an author sees this.** `PromptTemplateSchema` has no consumer outside + `packages/spec`, so the `{{var}}` holes are never interpolated and the declared + `variables` are never checked against them. The ADR-0058 D7 conformance ledger + already recorded that verdict (`template-prompt`, `state: 'experimental'`, + `PARSE ONLY — NO EVALUATOR FOUND`); until now nothing said it at the + declaration, so the generated reference page advertised a capability the + runtime does not deliver. + + **What does NOT change.** `.user` remains **required** and `.system` remains + optional — the schema shape is untouched. Optionalising or retiring a required + key is a parse-breaking change and is deliberately left to its own card. No + tombstone and no ADR-0087 entry is owed: nothing is renamed, retired or + re-typed. +- 5ca314a: Document `publicSharing.enabled` as the standing policy it is, and name the switched-off block among `resolveToken`'s `null` causes. + + The TSDoc above `publicSharing.enabled` read "when false, no share links can be issued for this object" — true, but only the mint half. Since the switch became a standing policy held at every redemption, a block that is off also stops every existing link on it from resolving: links minted while it was on, and links minted through the system-context / `permissive` mint bypass alike. Re-enabling the block serves them again; no row moves. The comment now says so, in the shape the sibling `eligibility` predicate's prose already uses. + + `IShareLinkService.resolveToken` enumerated the causes of its undifferentiated `null` — unknown, revoked, expired, audience, password, record gone, ineligible — without the switched-off block, so an implementer reading the list to enumerate refusal causes got an incomplete set. The list now carries it, in the position the gates run; the contract's design notes gain a matching entry beside the eligibility one, and the `isSystem` mint bypass is marked as mint-only. + + Documentation only: no schema, shape or behaviour change, and the `.describe()` string that feeds the generated reference is untouched. Where the corrected text reaches consumers, measured on the built package: every new line in `share-link-service.ts` ships in the published `dist/contracts/index.d.ts` (the interface-member docs and the module design notes both survive the declaration bundle); the `object.zod.ts` property comment reaches no `.d.ts` (the schema's declaration is an inferred type) and ships through the source file `@objectstack/spec` publishes directly (`src/**/*.zod.ts`) and through `dist/data/index.js.map`. +- 0db2947: Reference pages no longer print `@example` and `@category` tag lines as literal text. + + A module docblock is JSDoc, so its header carries block tags, and the reference-docs + renderer emitted a tag written on a prose line verbatim — 18 such lines reached 14 + customer-facing pages, as `@example Basic field mapping` above a code fence and + `@category Security` at the foot of four `system/` pages. `#13796` removed `@module` + from the page and left these two open, because a blanket `^@\w+` line filter would + have taken reader prose off the page and orphaned the fences below it. + + The verdict is per tag, and the axis is the payload rather than the spelling: + + - **`@example CAPTION` is REWRITTEN** into that caption, in bold, above the block it + captions — the shape `@see` already had (`See also: …`). 12 lines across 10 pages. + Bold rather than a heading because heading renumbering has already run by then, so + an emitted heading would carry a level chosen blind of the page, add entries to the + pages' tables of contents, and put a caption in reach of `check:docs-single-h1`. + - **A bare `@example` is DROPPED.** With no payload it is the `@module` case exactly, + and the fence beneath it is visibly an example without a line announcing one. 2 + lines (`studio/plugin`, `studio/object-designer`), both sitting against the + `check:skill-examples` opt-in marker that was already dropped there. + - **`@category VALUE` is DROPPED.** 4 lines, all reading `Security`, on four pages that + already sit under a `system/` section saying as much — and nothing in the repo reads + the tag: no typedoc or api-extractor (neither is used here), no search index, no + gate. Routing it into page frontmatter instead would publish a field with no + consumer. The tag stays in the source, where it is a legitimate JSDoc tag; only the + rendered page drops it. + + No schema behavior changes. The pins assert on the rendered fragment rather than on the + emitted `.mdx`, because `check:docs` compares the artifact against the source and + reproduced all 18 tag lines faithfully. +- 094b8fd: `RestServerConfig` now documents its own reachability: the `crud` / `metadata` / `batch` blocks are embedder-only, and the schema says so instead of implying a deployment posture nobody can author. + + `RestServerConfig` is the argument a host passes when it constructs the REST server, and there is exactly one door: `createRestApiPlugin({ api })`, whose `start()` is the only non-test site that reaches `new RestServer(...)`. No shipped boot path opens it with a config of its own — `os serve` reads the stack config's own top-level `api:` block and forwards exactly two keys out of it (`api.enableProjectScoping`, `api.projectResolution`), and the dev plugin passes none at all. So on a CLI-started deployment every other key is whatever its `.default()` says, and until now the schema did not say so anywhere an operator would look. + + - **The file header gains a `WHO CAN WRITE THIS CONFIG` section**, which is the part that reaches the generated reference page, and the `crud` / `metadata` / `batch` sub-schemas each gain a `Reachability: EMBEDDER-ONLY` line. The three keys' entries on the parent `RestServerConfig` table say it too, so the fact survives into `content/docs/references/api/rest-server.mdx` rather than living only in the TS source. + - **One documented carve-out, and it is the security-relevant key.** `RestServer.normalizeConfig` folds the environment into the *effective* value of `metadata.maskObjectFields`: `OS_ALLOW_UNMASKED_OBJECT_METADATA` turns the ADR-0106 D8 mask off whatever the key says. That env var is the only thing outside an embedder's argument that moves any value in this schema, and it is now stated on the key, on the sub-schema and in the file header. + - **`metadata.maskObjectFields`'s docblock is corrected.** It said `false` "opts this server out and serves the full schema to every authenticated caller" and offered the env var as a "deployment-wide counterpart", as if a deployment could pick either. Only an embedder can write the key; a deployment reaches the mask only through the env var. + - **`api.enableSearch` is corrected the same way.** Its docblock called it a "Deployment-wide switch" and its `describe()` a "deployment-wide search opt-out"; `os serve` does not forward it either, so it is embedder-only like the rest of the block apart from the two project-scoping keys. + - **The liveness ledger answers the ADR-0049 question in writing.** Every `live` row in `liveness/crud_endpoints.json`, `metadata_endpoints.json` and `batch_endpoints.json` gains a `REACHABILITY` sentence, and each file's `_note` carries the measurement once. `status` and `verifiedAt` are untouched on purpose: `live` answers who *reads* a key, reachability answers who can *set* it, and adding the second re-verified no call graph. + + ⚠️ **A correction to the record this change is built on.** An earlier draft of these sentences named a second door, `createHonoServerPlugin({ restConfig })`. No such function exists — a definition probe returns zero across the tree, against a positive control that finds `createRestApiPlugin` at `packages/rest/src/rest-api-plugin.ts:115`. `HonoServerPlugin` is a class that declares a `restConfig?: RestServerConfig` option whose single reader takes `api.basePath` for the SPA fallback; it never constructs a REST server, so it is not a door onto any of these keys. The claim was inherited from prose that was already in the tree, and on a card whose whole subject is a declared posture nobody can reach, publishing a declared door that does not exist would have been the same defect one level up. Every place this change touches now says the corrected thing. + + ⚠️ **`batch.maxBatchSize` really does describe itself as deployment policy — in another package.** The phrase does not occur in `packages/spec/src/api/rest-server.zod.ts`, but it exists verbatim in the REST server: *"The cap is deployment policy — `RestServerConfig.batch.maxBatchSize` (1..1000, default 200)"* at `packages/rest/src/rest-server.ts:2071`. Same defect class, different package, and not touched here — it is owed to a follow-up in `packages/rest`. + + No behaviour changes and no schema shape changes — no key, default, bound or refusal moves, so the accept set is byte-identical. This is prose plus ledger rows, and the regenerated `content/docs/references/api/rest-server.mdx` that follows from the `describe()` edits. +- aedbaef: `POST /sign-up/email` for an address that already has a `sys_user` row is refused explicitly, instead of answering 200 for a row that is never written (#15587) + + **This is a wire-behaviour change on one lane**: a call that answers `200 {"token":null,"user":{…}}` today answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` after this change. Nothing is newly admitted — the response that changes is one that reported a creation that never happened. + + ### What was measured + + Under audience posture `email_domain` (domain allowlisted, `selfRegistrationPermissionSet` resolvable), a sign-up for an address that already carried a `sys_user` row answered **200 with a freshly minted user id** and persisted nothing: no new `sys_user`, no `sys_account`, and the next sign-in a `401` with nothing anywhere explaining it. The same call on the same population under the `invite_only` default was refused honestly with `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`. An operator, a provisioning script or the console reading the status code concludes the account exists — and this sits directly on the recovery path a locked-out deployment walks, where widening the posture to let a seeded person register is exactly the remedy an operator is pointed at. + + ### The mechanism + + better-auth's sign-up route computes `shouldReturnGenericDuplicateResponse = requireEmailVerification || autoSignIn === false` and, when it is on, answers a duplicate with a synthetic in-memory user instead of throwing. **No insert is attempted and nothing is swallowed**: the vendor's `findUserByEmail` short-circuits ahead of `createUser`, which is why no row and no credential appear. + + The posture is not itself the cause — it is only what arms the shield: a posture that permits self-registration **forces** `requireEmailVerification` on. Holding the posture constant at the `invite_only` default and moving only that flag reproduces the divergence exactly, which also means the defect was never confined to the widened postures: `emailAndPassword.autoSignIn: false` arms the same shield under any posture. + + ### The fix + + The uniqueness refusal is raised on the `/sign-up/email` before-hook, the same seam and the same reason the audience-posture refusal is already raised there, and built from better-auth's own `BASE_ERROR_CODES` entry so both lanes answer byte-identically. + + **Order is load-bearing: it runs only for a caller the posture already admitted.** Asking uniqueness first would hand an uninvited stranger an account-existence oracle under the `invite_only` default (422 for a real address versus 403 for an unknown one). After the gate, `invite_only` is untouched — a stranger still gets `SELF_REGISTRATION_CLOSED` and learns nothing. + + **Operators of `open` / `email_domain` should know what the honest refusal costs:** on those postures a caller the audience gate admits can now distinguish an address that has an account from one that does not, where the synthetic 200 previously hid it. That is the disclosure the `invite_only` lane has always made to an invitation holder, and the platform's answer for a widened posture is now the same fact rather than a false receipt. + + `USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` is registered in the ADR-0112 error-code ledger under `@objectstack/plugin-auth`: the platform now **emits** it rather than only passing it through, and an emitted-but-unregistered code is the silent fourth state that ledger exists to prevent. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- 0bb2318: Three published prose carriers in `packages/spec` now state what the tree does, instead of a premise the same package's own code contradicts. No predicate, schema arm, key or export moves; every accept set is byte-identical. + + All three reach consumers: `@objectstack/spec` ships `src/**/*.zod.ts` in its published `files[]`, so a TSDoc comment in one of these files is a published byte, and one of the three is a `.describe()` that additionally lands in the JSON Schema and the generated reference page. + + - **`FILE_REFERENCE_TYPES` said the stored schema "deliberately admits both until D3 lands".** ADR-0104 D3 wave 2 landed: `valueSchemaFor` returns `FileReferenceIdValueSchema` ALONE for `form === 'stored'` and the id-or-object union only for `'expanded'`, and `field-value.test.ts` pins both directions. The docblock now states the shipped contract — the stored value is an opaque `sys_file` id, the inline metadata object is the expanded READ form — and names where legacy stored values are actually admitted: the warn-first ADR-0104 value-shape path in `@objectstack/objectql`'s record validator, never this schema. The sentence promised a future that had already arrived, which is exactly how it kept reading as current. + - **`CreateManyDataResponseSchema.droppedFields` justified its aggregated shape with "schema-uniform, so every row drops the same set".** Ruling C moved the static-`readonly` strip INSIDE `engine.insert`, after the `beforeInsert` hooks, and exempts keys a hook itself wrote — tracked per row (`rowHookWrittenKeys`). A hook that stamps a protected key on some rows and not others therefore makes those rows drop different sets, so the premise is false. The shape is unchanged and stays right for the reason the producer already gives: this response is `{ object, records, count }` and has no per-row slot to hang a drop set on, so a union is the only view it can represent. The description now says that, and tells a reader how to read a name in the array — "at least one row dropped this field", not "every row did". Callers needing row precision still use the per-row `insertMany`/`batch` results. + - **`FieldReferenceSchema`'s `@example` spelled the #14104 shape as the relation path `duty.grace_days`.** Nineteen lines below it, in the same block, the Execution support prose states that SQL push-down compiles same-table columns only and refuses a dotted path with `INVALID_FILTER`. An author copying the example gets a filter that passes in memory — `matchesFilter` walks dot paths — and 400s on SQL. The example is now the same-table spelling both execution paths compile, matching the wording the hand-written query-syntax guide already publishes; the block's own prose remains the statement of what a dotted path does. +- f7db8f4: fix(spec): `defineStack`'s cross-reference refusal carries an ADR-0112 envelope, so the five REFUSED ADR-0130 item classes are machine-readable (#14552) + + `validateCrossReferences` — reached through `defineStack` — refuses a stack whose items name an object the stack does not define. That refusal was `new Error(message)` with `code` and `status` both `undefined`, so all five REFUSED item classes of the ADR-0130 matrix (action `objectName`, view `data.object`, permission-set `objects`, seed dataset `object`, import mapping `targetObject`) plus the `hooks[].object` rule (#14122 §4 rule R4) were distinguishable only by MESSAGE TEXT. It now throws `StackCrossReferenceError`, carrying `code: 'STACK_CROSS_REFERENCE_INVALID'`, `status: 422`, and one entry per finding in `issues`. The message text is byte-for-byte unchanged: this adds fields rather than rewriting a sentence, and five message-substring pins in the tree read that prose. + + ADR-0112 makes `code` / `status` the machine-readable half of every refusal. Without them `os validate`, `os build` and any AI author reading the refusal could only pattern-match prose — the fragile shape the envelope exists to remove, made worse here because the message had already become load-bearing for those pins. + + Why ONE code rather than five: there is exactly one raise site. `validateCrossReferences` returns every finding as a `string[]` and `defineStack` throws the collected set at once, so a single refusal can carry findings from several classes together and a per-class code would have to pick one of several true answers. The classes stay machine-readable in `issues`. The family is also wider than "undefined object" — the same aggregate carries the duplicate-action-key, global-`update`-action and mapping `javascript`-transform findings — so a `…_UNDEFINED_OBJECT` spelling would have been false for those. + + Not narrowed, not widened: no accept-set changes and no export changes. `defineStack` accepts and refuses exactly the inputs it did before, and `StackCrossReferenceError` is deliberately module-local — `packages/spec/src/index.ts` re-exports that module with `export *`, so exporting the class would widen the published api-surface of the contract package, and the ADR-0112 contract is the `code` / `status` fields, which every reader reads structurally rather than by `instanceof`. No ledger registration either, for the same reason its two precedents (`ObjectOwnershipConflictError` #14367, `NamespaceConflictError` #14474) carry none: no wire door raises it. `defineStack` runs at authoring and boot time, and no HTTP domain handler calls it. + + `@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `boot-refusal`, door `none` — the measured verdict, not the expected one). +- 9bcd9be: `TemplateExpressionInputSchema` documents what the `template` dialect actually accepts, instead of illustrating it with a grammar it does not judge. + + The docblock introduced the dialect as "anything with `{{var}}` interpolation". Nothing in the schema enforces that: `TemplateExpressionInputSchema` judges the dialect tag and non-emptiness and nothing else, and the same docblock already said so one clause later. Read as a declaration, it made every single-brace `titleFormat` value in the wild look like it was crossing a gate. There is no gate. + + The corrected prose states where the placeholder grammar really lives — with the renderer that consumes the slot — and that the two spellings in circulation are not interchangeable everywhere: + + - `{{var}}` is the canonical form and the only one the registered `template` engine reads (`@objectstack/formula`'s `templateEngine`); the messaging renderer, the email plugin and the i18n adapters match it alone and leave a `{var}` in their output verbatim. + - `titleFormat` is the exception: its renderers accept `{{var}}` and `{var}` as equivalent, normalizing the double form down to the single one before substituting. Single-brace `titleFormat` values are legal by construction, not a grammar the schema failed to enforce. + + `titleFormat`'s `.describe()` says the same thing at the slot. Documentation only — the schema's judging logic, its accept-set and its exports are untouched, so nothing an author can write changes meaning. Both strings publish (`dist/*.d.ts` and the bundled `.describe()`), which is why this is a `patch` rather than a `skip-changeset`. +- b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + + Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event. + + - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one. + - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not. + - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL. + - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip. + - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip. + + +- 99261a7: Documentation: the `_actions` and `globalActions` convention lists in `translation.zod.ts` now name every key the schema accepts. + + Both docblocks are hand-written prose copies of what one factory declares. `actionTranslationSchema(...)` builds both surfaces — the file says so outright, "Shared by object `_actions` and `globalActions`" — so the two lists carried the identical four addresses (`label`, `confirmText`, `successMessage`, `resultDialog.*`) and the identical two omissions. An author reading either list to learn which keys exist saw a strict subset of what the schema has accepted all along. + + Added to both lists, in the order the factory declares them and matching the spelling already landed in `i18n-resolver.ts`'s own header: + + - `description` — the explanatory line under the title in the action's param dialog, resolved at `objects.._actions..description` with a `globalActions..description` fallback. + - `params..{label, helpText, placeholder, options.}` — the per-parameter translations for an action's param dialog. + + Prose only. No schema, factory or resolver changed: the keys were already declared and already accepted, so nothing about what a bundle validates to moves. `packages/spec` publishes `src/**/*.zod.ts`, which is why documentation-only text still ships and still earns a changeset. +- 81b426f: liveness ledger: `translation`'s `_note` states the live/planned boundary instead of a hand-maintained total + + The header claimed "11 of 12 groups live; the twelfth, `datasets`, …". No reading of the + file's own `props` produces that pair. Measured on this commit, `props` holds fourteen + entries — the eleven translation groups `translationDataShape()` declares, plus `locale` + and the item-identity keys `name` / `label` — of which exactly one row, `flows`, is not + `live`, and `datasets` is one of the groups rather than a twelfth. Counting all of `props` + gives thirteen live of fourteen; counting groups only gives ten of eleven. Neither is + eleven of twelve. + + This is the second wrong total the same sentence has carried. It previously read "10 of 11 + groups live; the one dead group (`validationMessages`) …", describing a group removed in + 17.0.0 (#4667) — prose outliving its subject in the header of the very file whose rows warn + about that. So the integers are deleted rather than re-derived, on the #7377 precedent that + moved this ledger family's other hand-maintained counts out of prose and into a generated + artifact: the sentence now names the BOUNDARY ("every group but `flows` is live"), which the + per-prop rows below it carry and `state-counts.md` totals, and it records why a total taken + over `props` is not a total of groups. Both former totals are kept, quoted, as the + sentence's own correction record. + + Published data, prose only: `liveness/` is in this package's `files` array, so these ledgers + ship in the npm tarball. No `status` value moves, no schema changes and no gate verdict + changes — every non-`live` row in the file, at every nesting level, is `flows` or one of its + children. +- 001af1c: liveness ledger: `translation`'s `_note` WALK BOUNDARY sentence excepts `settingsCommon` and states that group's own boundary + + The header said "every group is a z.record keyed by target names — the drill sees each + record's VALUE shape one level". Measured on this commit straight off + `translationDataShape()`, ten of the eleven groups are `z.record`s and `settingsCommon` is a + plain strictObject: a fixed shape whose one member `sourceLabels` is itself a fixed + strictObject keyed by the ADR-0010 resolution layers (`env`, `global`, `tenant`, `user`, + `default`) — a closed set, with the retired spellings (`org`, `workspace`, `system`, + `fallback`, `environment`) rejected and pointed at the layer each meant. Nothing about it is + keyed by target names, so the universal was false for one group, and false in the direction + that matters: the sentence is the file's declaration of how far down its rows reach. + + The sentence now excepts `settingsCommon` and states what the boundary is for it: the + walk's one level lands on the named member `sourceLabels` (exactly what + `check:liveness --undrilled` prints for `translation/settingsCommon`), the layer keys + beneath it sit below the boundary and are read as one unit by `resolveSettingsSourceLabel` + and objectui's `useSettingsLabel`, and the blanket verdict over them is the declared kind — + `translation/settingsCommon` is already a row of `undrilled-containers.baseline.json`, so + no new pinned artifact is added. The `datasets` row's "WALK BOUNDARY as for every other + group here" inherited the same universal by reference and now cites the record groups only. + + Published data, prose only: `liveness/` is in this package's `files` array, so these + ledgers ship in the npm tarball. No `status` value moves, no schema changes and no gate + verdict changes. The header's summary-count sentence (#15775) is untouched. +- 40a44b9: fix(spec): the `undefined` comparand refusal prescribes the null predicate by its ruled spellings (#14426) + + `parseFilterAST`'s comparand-type door refuses an `undefined` comparand at every + position. Its prescription read "Write null for the null predicate, or omit the + key" — position-agnostic advice that, followed at `{ $gt: undefined }`, produced + `{ $gt: null }`, which the 2026-09-01 ruling refuses one door over (and, at an + `$in` / `$nin` / `$between` member, produced the list shapes refused on + 2026-08-31). Two loud refusals to reach one right answer. + + The sentence now names the null predicate by its complete spellings — + `{"$eq": null}` / `{"$ne": null}` — or omit the key, so following it never lands + in a refusal at any position the sentence is emitted at. No accept/refuse + behaviour changes: same envelope (`INVALID_FILTER` / 400), same path, same + accepted-set and NOT-applied sentences. +- 7a7fb03: fix(spec): `isValueDomainMember` refuses an off-vocabulary domain instead of failing OPEN on `Object.prototype` names + + `DOMAIN_MEMBERSHIP` is an object literal, so it inherits `Object.prototype`, and + `isValueDomainMember` indexed it with no own-property guard. Measured against the + built artifact (`dist/shared/index.mjs`) on the repo's Node 22 baseline (v22.22.2), + an off-vocabulary `domain` did one of two wrong things — and one of them was a + membership FALSE POSITIVE out of a predicate whose whole job is to refuse + non-members: + + | `domain` | before | after | + |:--|:--|:--| + | `iana_time_zone` (in vocabulary) | `true` for `UTC` | `true` for `UTC` — unmoved | + | `toString` | `'[object Object]'` — a truthy **string** | `false` | + | `valueOf` | a truthy **object** | `false` | + | `constructor` | a truthy **object** | `false` | + | `__proto__` | threw a `TypeError` | `false` | + | `nope`, `''` | threw a `TypeError` | `false` | + + **Why it is reachable.** "Unreachable in-repo" is not "unreachable". The parameter + is typed `ValueDomain` and every in-repo call site names a member, but + `isValueDomainMember` is **published** on `@objectstack/spec/shared` (it is in + `packages/spec/api-surface/shared.json`). A plain-JS consumer, or any caller + handing over a domain string read from **metadata** rather than written in source, + reaches it with no type checking at all — and metadata-sourced strings are exactly + where `constructor` and `toString` show up. + + **This narrows and widens nothing, measured rather than asserted.** Every accepted + `domain` is an own key of the record, so no value that was accepted before is + refused now; the three real domains answer from their own definitions, unmoved. + The change is one `Object.prototype.hasOwnProperty.call` guard — the same spelling + the `iso_4217_currency` definition in the same module already uses — returning + `false` for a domain that is not an own key. A **null-prototype record** was the + other shape available and was not taken: it converts the truthy answers into + throws rather than into `false`, and it costs the `Readonly>` + annotation that makes a vocabulary member added without a definition fail to + compile. + + **Unknown domain answers `false`; it does not throw.** `false` is the narrowing + reading — it refuses more and accepts nothing new — whereas a thrown refusal would + change published behaviour for callers who today receive a truthy value. This is + the same third branch a sister ruling settled for the same defect family: list + reject / own-member value / prototype-resolvable ⇒ reject. + + The pin that existed did not cover this, and the fix is as much about its + POPULATION as about the guard: the totality pin asserted the return `typeof` was + `boolean` but iterated `ValueDomainSchema.options` **only** — exactly the domains + that behave. The new pins put `toString`, `valueOf`, `constructor`, + `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable`, `__proto__` and plainly + absent words into the population, and a third pin holds that population honest by + asserting every one of them is still outside the vocabulary. + ## 17.3.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index ca4b0991ba..ed7905edf0 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.3.0", + "version": "17.4.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 6c3234d589..035b0d05a2 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/trigger-api +## 17.4.0 + +### Patch Changes + +- be92d46: These fourteen packages now declare `repository.directory`, so their npm pages carry a working "source" deep link to their own directory in the monorepo. + + npm renders that field by concatenating it onto `repository.url`. None of these fourteen manifests carried a `repository` block at all, so every one of their npm pages offered no route from the package back to its code — not a broken link, no link. That is what this publishes: the block those pages read, naming each package's own directory. + + Nothing else about these packages changes. No export, no runtime behaviour, no dependency and no file in the tarball other than the manifest's own `repository` key. The version bump exists because the fix is only real once it is published: the field lives in the manifest npm serves, so a corrected manifest sitting in the repository leaves the package page exactly as wrong as it was. + + The rule behind it is now mechanical rather than remembered — `check:manifest-repository-directory` makes a publishable (non-private) workspace manifest declare the field naming its own directory, so a package added or moved after this cannot quietly go back to having no source link. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 25ce660b9e..63c4f0044f 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.3.0", + "version": "17.4.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 8cf2a4a4d2..ea5bfed465 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,208 @@ # @objectstack/plugin-trigger-record-change +## 17.4.0 + +### Patch Changes + +- 4f85e4d: fix(trigger-record-change)!: the record handed to a record-change flow no longer aliases the write's payload (#14744) + + + + **BREAKING** for a flow whose `script` node mutates a NESTED value of the + triggering record IN PLACE: that mutation no longer affects the write the flow + was triggered by. Shipped as `patch` — this change moves no public surface (no + exported symbol, no accepted key or value), and under the maintainer's + 2026-09-04 rule (decision batch #35, on #15294) a `fix(` that changes no public + surface stays `patch`, with breaking-ness carried by this banner and the + ADR-0087 disposition rather than by the level. Maintainer ruling 2026-09-04 on + #14744 (decision batch #38, verbatim 「同意」), adopting option A. + + **Why.** `buildContext` builds the flow's `record` as a shallow overlay of the + pre-image, the mutation payload and the after-row. The top-level object was + new, so a flow ASSIGNING a top-level key reached nothing — but every nested + value in it was the engine's own object, shared by reference. One of those is + `ctx.input.data`, and on a `multi: true` update ADR-0058 Addendum II D3 hands + every per-row context that same payload object, which is the SET clause of the + single `updateMany`. A registered function doing `record.tags.push(...)` + therefore wrote the SET clause without assigning any key: every dispatch's + contribution landed on EVERY matched row, including values derived from another + row's pre-image, and #14099's key-set refusal could not see it because no key + was assigned. Measured end to end on the memory driver and on + `@objectstack/driver-sql` (#15356). + + **What changes.** Both flow-facing roots — `record` (and the `params` alias of + it) and `previous` — are decoupled from the engine's state before the flow + runs. Arrays, plain objects, `Date`, `RegExp`, `Map` and `Set` are copied; + primitives, functions and other class instances are shared, which is the + documented and pinned boundary. A flow still mutates its roots freely and still + observes its own writes for the rest of the run; those writes simply reach + nothing outside it. `previous` is decoupled in the same stroke because it is the + engine's single pre-image object and the same hook context reaches every other + flow bound to the same write. + + **What does NOT change.** The engine's write shape. ADR-0058 Addendum II D3 + stands untouched: one payload still serves N rows and every per-row context is + still handed that one object. #14099's key-set refusal is untouched and is not + widened — a hook that assigns the same key with per-row values still passes it, + and divergent key sets are still refused whole. Flow metadata with no registered + function reached nothing before this change and reaches nothing after it: + assignment nodes write the run's variable map, and `update_record` issues its own + by-id write. Lookup expansion (`config.expand`) still grafts onto the record the + flow holds. + + **Consumer note.** A flow that relied on an in-place nested mutation to persist + — which on a by-id write did persist, and on a `multi: true` write corrupted + every other matched row — writes the record with the `update_record` node + instead. That node is the supported per-row write and is unaffected by this + change. +- b224324: `@objectstack/trigger-record-change` and `@objectstack/trigger-schedule` now declare a `repository.directory` that resolves to the directory they actually live in. + + Both manifests declared a path under `packages/plugins/` that no longer exists in the repository: + + | package | declared | actual | + |---|---|---| + | `@objectstack/trigger-record-change` | `packages/plugins/plugin-trigger-record-change` | `packages/triggers/trigger-record-change` | + | `@objectstack/trigger-schedule` | `packages/plugins/plugin-trigger-schedule` | `packages/triggers/trigger-schedule` | + + `repository.directory` is what npm uses to build the **Repository** deep link on a package page, and what tooling uses to locate a monorepo package's source from its tarball. Pointing it at a path that does not exist sends a reader to a 404 instead of to the source — on packages published today at `17.3.0`. The value ships inside the tarball, so this correction only reaches npm by being published; that is why it carries a changeset rather than `skip-changeset`. + + The residue came from a three-commit sequence on 2026-06-12, and only one of those commits was a pure rename. `f15d6f6f6` **copied** the two packages to `packages/plugins/trigger-*` (26 files, +2222/-19, with all four directories briefly coexisting) and edited exactly one line of each copied manifest — its `name` — leaving `directory` pointing at the path it was copied from; `290c62514` deleted the originals five minutes later; and `ea4941ad8` then promoted `packages/plugins/trigger-*` to a first-class `packages/triggers/` directory as a pure 16-file rename with zero content changes, which made the declared value wrong in a second segment. Six weeks after that, `9a43e042f` (#3380) rewrote `repository.url` and `bugs` in both of these manifests, with the stale `directory` line sitting as unchanged context one line below the edited `url`. So the field was not merely never in anyone's way: one commit edited its immediate neighbour inside the same object, and a later reviewed hunk had the wrong line on screen. Nothing caught it because nothing reads it. + + Scope of this change, stated as a measured set rather than a general claim: over all **81** tracked `package.json` files in the repository, **57** declare `repository.directory`; before this change **55** resolved to the manifest's own directory and **2** did not — the two above. After it, **57 of 57** resolve. No other manifest field is edited, and no package's code, exports or behaviour is touched. The remaining **24** manifests declare no `repository.directory` at all; that population is deliberately left alone here and is reported separately, because whether declaring the field is mandatory is a policy question rather than a correction. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index 52cca9e261..30a82a1a70 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.3.0", + "version": "17.4.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 771882421b..4b3437f283 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,185 @@ # @objectstack/plugin-trigger-schedule +## 17.4.0 + +### Minor Changes + +- 8341ed2: A scheduled (cron) flow is now delivered once per tick window, and replaying a window that was already delivered is refused instead of silently sent again. + + A `time_relative` flow has taken a persisted dispatch claim per `(flow, window, record)` since #10220, so per-record once-only delivery is free for it. A `schedule` flow runs once per tick with no record and had no claim surface at all, so "this batch already went out" fell back to whatever each app remembered for itself. A scheduled digest that was replayed by an operator, or whose process restarted inside its window, delivered twice. + + Scheduled flows now claim `(flow, tick-window)` in the same `sys_flow_dispatch` ledger, and settle that claim with what the run turned into: + + - **A second fire inside one window does nothing.** The window key is a pure function of the schedule descriptor and the clock — the previous occurrence of the very same cron expression in the very same timezone, computed with the same library the job adapter schedules with — so a restart inside the window computes the same key and hits the same claim. + - **`IJobService.replay()` refuses a delivered window**, with the ADR-0112 envelope its contract declares: `code: 'RESOURCE_CONFLICT'`, `status: 409`, and a message naming the window and the claim that refused it. The promise rejects — an operator who presses replay and sees nothing happen is exactly the outcome this replaces. + - **`replay(name, data, { force: true })` sends anyway.** The duplicate is the operator's, taken knowingly. + - **A window whose claim is absent, failed or unsettled re-runs** on a plain `replay()`, with no force needed. A job that takes no claim at all — every job that is not a scheduled flow — is the absent row and behaves exactly as before. + - **`succeeded` is absorbing.** A replay that repairs a failed window records `succeeded`, so the next unforced replay is refused. A *forced* replay that throws leaves the window recorded delivered rather than rewriting it to `failed` — otherwise a failed re-send would silently reopen the unforced re-delivery door. An operator whose forced replay failed forces again. + - **A `once` schedule now has a tick window too** — the single instant it is due, which is one window for the job's whole life. The visible consequence is on replay: an operator who replays a one-shot job *before* its due instant claims that single window, so the real fire then finds the claim and does nothing. Previously both ran. + + The error-isolation `catch` that keeps a throwing flow from crashing the ticker is unchanged and still swallows. What it no longer does is leave the run indistinguishable from a delivered one: the throw settles the window's claim as `failed`, so a replay repairs it. + + `sys_flow_dispatch` gains two optional columns, `outcome` and `settled_at`. Rows written before this release read as unsettled, which reads as not delivered — the safe direction, since a replay of one re-runs rather than being refused. Only `schedule:` claims are ever settled; a `time_relative` sweep's rows stay `null` by design. + + ⚠️ **If you manage this table's DDL out of band** — anything other than letting the platform sync `sys_flow_dispatch` from its object definition — add `outcome` (text) and `settled_at` (datetime) yourself before upgrading. Without them every `settle()` throws against the driver. Dispatch dedup still works and no flow fails (the settle is best-effort and logged), but no claim ever records an outcome, so the replay refusal never fires and this release's headline change is silently absent. + + Interface changes for hosts that implement the ledger themselves: + + - `FlowDispatchStore` gains **optional** `settle()` and `read()`. A store without them still deduplicates; it announces once that the refusal cannot fire. + - `FlowDispatchStoreEngine` — the narrow ObjectQL slice the bundled store demands — now **requires** `update` alongside `find` and `insert`. A custom engine adapter typed against it must add the method. + - New exported types: `FlowDispatchClaim` and `FlowDispatchOutcome` from `@objectstack/service-automation`; `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/service-job` (the parameter type of `DbJobAdapter.setReplayGuard`, exported so it can be named); `ScheduleDispatchLedger`, `ScheduleDispatchClaim`, `ScheduleDispatchOutcome`, `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/trigger-schedule`. + - `croner` moves from a devDependency to a **dependency** of `@objectstack/trigger-schedule`, which now imports it at runtime to compute the cron tick window. It is already a runtime dependency of `@objectstack/service-job` at the same range, so the platform's dependency set does not grow. + +### Patch Changes + +- b224324: `@objectstack/trigger-record-change` and `@objectstack/trigger-schedule` now declare a `repository.directory` that resolves to the directory they actually live in. + + Both manifests declared a path under `packages/plugins/` that no longer exists in the repository: + + | package | declared | actual | + |---|---|---| + | `@objectstack/trigger-record-change` | `packages/plugins/plugin-trigger-record-change` | `packages/triggers/trigger-record-change` | + | `@objectstack/trigger-schedule` | `packages/plugins/plugin-trigger-schedule` | `packages/triggers/trigger-schedule` | + + `repository.directory` is what npm uses to build the **Repository** deep link on a package page, and what tooling uses to locate a monorepo package's source from its tarball. Pointing it at a path that does not exist sends a reader to a 404 instead of to the source — on packages published today at `17.3.0`. The value ships inside the tarball, so this correction only reaches npm by being published; that is why it carries a changeset rather than `skip-changeset`. + + The residue came from a three-commit sequence on 2026-06-12, and only one of those commits was a pure rename. `f15d6f6f6` **copied** the two packages to `packages/plugins/trigger-*` (26 files, +2222/-19, with all four directories briefly coexisting) and edited exactly one line of each copied manifest — its `name` — leaving `directory` pointing at the path it was copied from; `290c62514` deleted the originals five minutes later; and `ea4941ad8` then promoted `packages/plugins/trigger-*` to a first-class `packages/triggers/` directory as a pure 16-file rename with zero content changes, which made the declared value wrong in a second segment. Six weeks after that, `9a43e042f` (#3380) rewrote `repository.url` and `bugs` in both of these manifests, with the stale `directory` line sitting as unchanged context one line below the edited `url`. So the field was not merely never in anyone's way: one commit edited its immediate neighbour inside the same object, and a later reviewed hunk had the wrong line on screen. Nothing caught it because nothing reads it. + + Scope of this change, stated as a measured set rather than a general claim: over all **81** tracked `package.json` files in the repository, **57** declare `repository.directory`; before this change **55** resolved to the manifest's own directory and **2** did not — the two above. After it, **57 of 57** resolve. No other manifest field is edited, and no package's code, exports or behaviour is touched. The remaining **24** manifests declare no `repository.directory` at all; that population is deliberately left alone here and is reported separately, because whether declaring the field is mandatory is a policy question rather than a correction. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [2025b1f] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + - @objectstack/core@17.4.0 + ## 17.3.0 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 164ea4fc4a..27647b178a 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.3.0", + "version": "17.4.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 d32b82b11f..71b2dd13e3 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,160 @@ # @objectstack/types +## 17.4.0 + +### Minor Changes + +- 3d3f60e: An approval decision that lands while its flow run strands now says so in fields, not only in prose. + + `POST /api/v1/approvals/requests/{id}/reject` — and its sibling decision doors — could produce three coexisting outcomes from one call: the caller read HTTP 500, the request row **was** in its terminal status and had left the pending inbox, and the workflow run was stranded. A caller reading 500 has one honest inference available — "the rejection did not happen" — and it was the wrong one, so scripts and operators retried or escalated against a decision that was already durable. The only carrier of the truth was English prose in `error`, so finding the affected run meant regexing a run id out of a sentence, and nothing said whether that run could be repaired at all. + + The 500 stays. A recorded decision whose flow never advances is still a failure and is still reported as one; the door does not become atomic and no decision is ever rolled back. What changed is that it stops discarding what the engine already said: + + - **The `RESUME_FAILED` body gains four fields**, additively — `finalized` (always `true`: the decision stands), `decision`, `runId`, and `repairable`. Existing consumers see the same `code`, the same `error` and the same status. + - **`repairable` carries the engine's own discriminator** — `AutomationResult.status === 'stranded'`, the state stamped on exactly the exit that journals a repair snapshot. `false` is the answer for every other failure, including a lost run: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise. + - **`serviceResume` carries `status`** through to the door. It previously read only `success` / `code` / `error`, and the stranded exit reports a `status` and no `code` at all — so the platform's own repairability signal died one line before the envelope was built. + + `@objectstack/types` gains `strandedDecisionFailure` / `strandedDecisionDetails` and the `StrandedDecisionDetails` type — the constructor and its recogniser in one module, so the producing service and the REST door cannot drift. A `RESUME_FAILED` raised without that carrier answers exactly the body it always did; the door never synthesises the envelope. + +### Patch Changes + +- 088f761: `createHostImporter` now loads the `import` build of an ALIASED dual-published package, instead of silently keeping its `require` build. + + An alias declaration — `{"dependencies": {"foo": "npm:bar@1"}}` — installs a package whose manifest is named `bar` under the key `foo`. On the path where CommonJS resolution SUCCEEDS, the importer re-decides only the CONDITION (it asks the package which entry an `import()` gets, so the caller's ESM chain and this load share one instance). That re-decision recognised the package root by walking up from the resolved entry until it found a manifest named after the DECLARATION KEY — `foo` — while an aliased install's manifest is named `bar`. The walk therefore never matched, the re-decision produced nothing, and the load fell back to whatever the CommonJS resolver had answered: the `require` condition. + + For an aliased dual publish that left the process holding two live copies of one package — the CommonJS build behind the host importer, the `import` build in the caller's own chain — which is exactly the split the condition re-decision exists to remove: a plugin registry, a singleton kernel, a module-level cache, one copy each. + + The expectation now comes from the host's own declaration (`npm:name@range`, aliased `workspace:name@range`), the same reading the ESM-only fallback finder has used since it learned about aliases. Nothing about the check's strictness moves: an alias naming one package still does not license a directory holding another, and a non-aliased declaration is still verified against its key. Declarations that name a LOCATION rather than a package (`link:`, `file:`) carry no name to expect, so they keep today's behaviour unchanged. + + Measured population for the behaviour change: zero aliased declarations exist across this workspace's 875 dependency declarations, and 867 of 867 installed declarations already match their key — no ordinary, non-aliased install reaches this path. +- c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + + `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + + `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + + A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [f2b5e46] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [07f40e5] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [ca326b5] +- Updated dependencies [8f404a5] +- Updated dependencies [68437d4] +- Updated dependencies [abb140c] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [d5d8d50] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [132742f] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [6491463] +- Updated dependencies [89cf4d6] +- Updated dependencies [21c5dcb] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [a84e1ce] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [f502898] +- Updated dependencies [af7edfe] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [c677cda] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [cc00df2] +- Updated dependencies [f4e6adf] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [e0af1a8] +- Updated dependencies [414c1fc] +- Updated dependencies [0db2947] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [094b8fd] +- Updated dependencies [c7aca0d] +- Updated dependencies [c1d8f98] +- Updated dependencies [8e0b297] +- Updated dependencies [5f7fa1d] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [9408b7f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [b398ad2] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [7a7fb03] + - @objectstack/spec@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 5b376cbdc5..7f4d29ac13 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.3.0", + "version": "17.4.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 a1cc9fe274..8ef7ac3173 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,312 @@ # @objectstack/verify +## 17.4.0 + +### Patch Changes + +- c550baf: fix(verify): `os verify` no longer reports a green run over a multi-package app it measured nothing about + + Every reader in this package took the artifact's **flattened** top level and + nothing else. A multi-package app whose definitions live under `packages[]` — + the shape ADR-0130 D4's option B emits — therefore reached `deriveCrudCases` + with no objects and no datasources, and reached `rlsProbePermissionSet` and + `declaredPositionNames` with no objects and no positions. Nothing threw. The run + derived zero CRUD round-trip cases, built an empty RLS probe permission set, + minted no persona for any declared position, and printed `✓ verify passed`. + + That is the most expensive place in the platform for a false green: `verify`'s + entire job is to be the thing that notices. A missing collection is at least + missing — zero coverage dressed as a passing run is not. + + The four reads now resolve through `resolveArtifactPackageOrder` + (`@objectstack/core`, ADR-0130 D4+D5), **flattened top level first**: + + - `deriveCrudCases` — the objects it derives cases for, and the datasource-by- + name map behind ADR-0015's double write gate. Both, because objects alone + would leave a write-opted-in federated object judged against an empty + datasource map and reported read-only, i.e. skipped by a verifier that says it + covered it. + - `declaredPositionNames` — one RLS persona per declared position. + - `rlsProbePermissionSet` — the object grants and the owner-scoped narrowing + that are what make an RLS run a probe rather than a report about the object + gate. + + The top-level read still answers first and is returned untouched, so an app on + today's additive artifact gets a bit-identical answer, and a stack that declares + an empty collection (`objects: []` is truthy) still gets an empty one. Only a + top level that does not carry the key at all consults `packages[]`. A malformed + `packages` array now surfaces `resolveArtifactPackageOrder`'s ADR-0112 refusal + instead of reading as "this app declares nothing". +- Updated dependencies [fe0d9a4] +- Updated dependencies [ecd2158] +- Updated dependencies [429ec1e] +- Updated dependencies [f2b5e46] +- Updated dependencies [2ed6be6] +- Updated dependencies [ed7243d] +- Updated dependencies [6ba0db4] +- Updated dependencies [625b0c3] +- Updated dependencies [233222e] +- Updated dependencies [dcad825] +- Updated dependencies [6136293] +- Updated dependencies [07f40e5] +- Updated dependencies [6573af9] +- Updated dependencies [54bb2f1] +- Updated dependencies [fd014b1] +- Updated dependencies [ceb4877] +- Updated dependencies [e9fcd6b] +- Updated dependencies [90e7e6d] +- Updated dependencies [98191d2] +- Updated dependencies [ca326b5] +- Updated dependencies [f1a1028] +- Updated dependencies [8f404a5] +- Updated dependencies [954cb0b] +- Updated dependencies [159dbad] +- Updated dependencies [7079694] +- Updated dependencies [7783738] +- Updated dependencies [a56baa2] +- Updated dependencies [d4c2cb1] +- Updated dependencies [c1eafe6] +- Updated dependencies [ac9376a] +- Updated dependencies [a775510] +- Updated dependencies [60c0f61] +- Updated dependencies [68437d4] +- Updated dependencies [8b67272] +- Updated dependencies [44c849c] +- Updated dependencies [abb140c] +- Updated dependencies [2e6a2ea] +- Updated dependencies [8333a6c] +- Updated dependencies [3e3ecb0] +- Updated dependencies [8e500f2] +- Updated dependencies [4b3955e] +- Updated dependencies [d5d8d50] +- Updated dependencies [4e090ec] +- Updated dependencies [68f8f77] +- Updated dependencies [e08892d] +- Updated dependencies [ae05f2e] +- Updated dependencies [b548e43] +- Updated dependencies [c463d03] +- Updated dependencies [64bd6a3] +- Updated dependencies [13c48c2] +- Updated dependencies [d30ccb9] +- Updated dependencies [b0529e1] +- Updated dependencies [66dc6ab] +- Updated dependencies [6f94458] +- Updated dependencies [6e67b86] +- Updated dependencies [132742f] +- Updated dependencies [81919a7] +- Updated dependencies [85a2459] +- Updated dependencies [e89fa92] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8976ea1] +- Updated dependencies [fb447b4] +- Updated dependencies [56fe8c2] +- Updated dependencies [acabd24] +- Updated dependencies [ab50c8f] +- Updated dependencies [4bc9821] +- Updated dependencies [6491463] +- Updated dependencies [da1cffb] +- Updated dependencies [89cf4d6] +- Updated dependencies [ce8bfc9] +- Updated dependencies [21c5dcb] +- Updated dependencies [ddfbf04] +- Updated dependencies [6d4d5d3] +- Updated dependencies [ed5d557] +- Updated dependencies [65846bc] +- Updated dependencies [bca21f7] +- Updated dependencies [e9fcd6b] +- Updated dependencies [316a20f] +- Updated dependencies [2025b1f] +- Updated dependencies [33388f9] +- Updated dependencies [1a7a7c9] +- Updated dependencies [e9fcd6b] +- Updated dependencies [cbca47d] +- Updated dependencies [acf4d38] +- Updated dependencies [5a91387] +- Updated dependencies [ef3a138] +- Updated dependencies [68d5dfd] +- Updated dependencies [4cfc93b] +- Updated dependencies [859ded3] +- Updated dependencies [fa125f3] +- Updated dependencies [74628d9] +- Updated dependencies [a646120] +- Updated dependencies [6f1ce7d] +- Updated dependencies [7778115] +- Updated dependencies [2c753fe] +- Updated dependencies [52804cd] +- Updated dependencies [3f89967] +- Updated dependencies [53cf263] +- Updated dependencies [21aabbc] +- Updated dependencies [9c270bb] +- Updated dependencies [76c8c5a] +- Updated dependencies [fa85759] +- Updated dependencies [61b4eb3] +- Updated dependencies [cfb64a6] +- Updated dependencies [5f7fa1d] +- Updated dependencies [088f761] +- Updated dependencies [a84e1ce] +- Updated dependencies [a84e1ce] +- Updated dependencies [65846bc] +- Updated dependencies [bf1054a] +- Updated dependencies [d8d2776] +- Updated dependencies [3e7ef9c] +- Updated dependencies [222dc0f] +- Updated dependencies [e9fcd6b] +- Updated dependencies [32c917d] +- Updated dependencies [f9a3c32] +- Updated dependencies [41cbc54] +- Updated dependencies [f502898] +- Updated dependencies [51ae731] +- Updated dependencies [3e560da] +- Updated dependencies [af7edfe] +- Updated dependencies [25a3d91] +- Updated dependencies [6615a02] +- Updated dependencies [9f39897] +- Updated dependencies [b60f48b] +- Updated dependencies [c78c918] +- Updated dependencies [be92d46] +- Updated dependencies [7bf96cf] +- Updated dependencies [142c01c] +- Updated dependencies [4ca358d] +- Updated dependencies [cf9bda4] +- Updated dependencies [784cb92] +- Updated dependencies [7629f4d] +- Updated dependencies [3bd9b34] +- Updated dependencies [51df9fd] +- Updated dependencies [a7da4de] +- Updated dependencies [de0bcdd] +- Updated dependencies [70f7d6d] +- Updated dependencies [d0ee598] +- Updated dependencies [e9fcd6b] +- Updated dependencies [48b0fcf] +- Updated dependencies [c677cda] +- Updated dependencies [6acb37e] +- Updated dependencies [7797102] +- Updated dependencies [f2f6684] +- Updated dependencies [554a160] +- Updated dependencies [7f745c3] +- Updated dependencies [0a038cc] +- Updated dependencies [26144c2] +- Updated dependencies [e9fcd6b] +- Updated dependencies [a83482c] +- Updated dependencies [9e9f03a] +- Updated dependencies [5eb24f8] +- Updated dependencies [2a3decc] +- Updated dependencies [c64e65f] +- Updated dependencies [cc00df2] +- Updated dependencies [cc00df2] +- Updated dependencies [9fa5775] +- Updated dependencies [ac6213e] +- Updated dependencies [d770b3e] +- Updated dependencies [f4e6adf] +- Updated dependencies [a4816a7] +- Updated dependencies [d5c4022] +- Updated dependencies [4db3c61] +- Updated dependencies [5ca314a] +- Updated dependencies [06c762e] +- Updated dependencies [e0af1a8] +- Updated dependencies [11f848e] +- Updated dependencies [4771bd9] +- Updated dependencies [414c1fc] +- Updated dependencies [c930f85] +- Updated dependencies [0db2947] +- Updated dependencies [e13ede8] +- Updated dependencies [7d7ca6c] +- Updated dependencies [65ec530] +- Updated dependencies [92b5d7f] +- Updated dependencies [613bfbd] +- Updated dependencies [abae16a] +- Updated dependencies [e6279dc] +- Updated dependencies [53cbad9] +- Updated dependencies [9b459b7] +- Updated dependencies [f5cc78b] +- Updated dependencies [1e43386] +- Updated dependencies [7370989] +- Updated dependencies [289bb43] +- Updated dependencies [094b8fd] +- Updated dependencies [46803fa] +- Updated dependencies [c7aca0d] +- Updated dependencies [1d73d45] +- Updated dependencies [8a12067] +- Updated dependencies [de75e40] +- Updated dependencies [e9fcd6b] +- Updated dependencies [401e50a] +- Updated dependencies [ee32e1c] +- Updated dependencies [8341ed2] +- Updated dependencies [4b0508e] +- Updated dependencies [b31ebfe] +- Updated dependencies [4177ed3] +- Updated dependencies [4c0b22b] +- Updated dependencies [c1d8f98] +- Updated dependencies [8744de9] +- Updated dependencies [a646120] +- Updated dependencies [e9fcd6b] +- Updated dependencies [8e0b297] +- Updated dependencies [d4f9b2a] +- Updated dependencies [5f7fa1d] +- Updated dependencies [2024eca] +- Updated dependencies [6b8c677] +- Updated dependencies [2e35765] +- Updated dependencies [87f0ccc] +- Updated dependencies [aedbaef] +- Updated dependencies [a727043] +- Updated dependencies [c5d6803] +- Updated dependencies [10d05bb] +- Updated dependencies [69602e5] +- Updated dependencies [7936b29] +- Updated dependencies [46803fa] +- Updated dependencies [c2a336c] +- Updated dependencies [9f890d3] +- Updated dependencies [0bb2318] +- Updated dependencies [f7db8f4] +- Updated dependencies [1ecee3e] +- Updated dependencies [0cf0867] +- Updated dependencies [4c31f02] +- Updated dependencies [5964124] +- Updated dependencies [9408b7f] +- Updated dependencies [1375344] +- Updated dependencies [ec0a6e7] +- Updated dependencies [3890244] +- Updated dependencies [1157e7b] +- Updated dependencies [2bb0614] +- Updated dependencies [b3820c3] +- Updated dependencies [e9fcd6b] +- Updated dependencies [9bcd9be] +- Updated dependencies [3e9065c] +- Updated dependencies [b398ad2] +- Updated dependencies [ce478db] +- Updated dependencies [6c439f2] +- Updated dependencies [eddd612] +- Updated dependencies [99261a7] +- Updated dependencies [81b426f] +- Updated dependencies [001af1c] +- Updated dependencies [fb77aa5] +- Updated dependencies [3d3f60e] +- Updated dependencies [581d8f8] +- Updated dependencies [f81afe3] +- Updated dependencies [40a44b9] +- Updated dependencies [f89812e] +- Updated dependencies [6d7d740] +- Updated dependencies [f7ffbd6] +- Updated dependencies [7a7fb03] +- Updated dependencies [d61d6e3] +- Updated dependencies [021a735] +- Updated dependencies [7bdb163] + - @objectstack/spec@17.4.0 + - @objectstack/runtime@17.4.0 + - @objectstack/core@17.4.0 + - @objectstack/objectql@17.4.0 + - @objectstack/service-analytics@17.4.0 + - @objectstack/service-automation@17.4.0 + - @objectstack/platform-objects@17.4.0 + - @objectstack/plugin-auth@17.4.0 + - @objectstack/service-settings@17.4.0 + - @objectstack/service-datasource@17.4.0 + - @objectstack/rest@17.4.0 + - @objectstack/plugin-hono-server@17.4.0 + - @objectstack/types@17.4.0 + - @objectstack/plugin-security@17.4.0 + - @objectstack/plugin-sharing@17.4.0 + ## 17.3.0 ### Minor Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index 3ebe106911..b696b0e112 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.3.0", + "version": "17.4.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",