Skip to content

Commit ddbdb76

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-l8wnbr
2 parents e307d87 + 56fdd56 commit ddbdb76

117 files changed

Lines changed: 4398 additions & 142 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/trigger-record-change': patch
6+
'@objectstack/service-automation': patch
7+
'@objectstack/cli': patch
8+
'@objectstack/lint': patch
9+
---
10+
11+
feat: a hook (shipped body included) and a record-triggered flow can set the save notice the create / update answer carries (#28251)
12+
13+
Clause-②: yes (widening: `HookContext` gains the optional `notice` member, `FlowNodeAction` gains `save_notice`, the `flows` translation group gains `notices`, and `AutomationContext` gains the optional `saveNotice`; nothing an existing stack writes is refused)
14+
Metadata-protocol: added
15+
16+
A single-record create or update has answered with an optional `notice` since #28247, but only an in-process caller that passed `onNotice` could set it. A shipped hook body could not reach that listener, and neither could a flow, so an app that wanted to tell the saver "Sent Acme to your manager for approval" had to fake it with a `record:alert` banner. Now there is one channel with two faces:
17+
18+
- **Hooks: `ctx.notice(message)`.** `HookContextSchema.notice` is the member. The engine binds it at every insert / update dispatch (`beforeInsert`, `afterInsert`, `beforeUpdate`, `afterUpdate`), so every dispatch site gets it, and it calls the write's own `onNotice` listener. A write nobody listens on (a batch, a seed, an import, a flow node's own write) sets nothing, and the call is a no-op there. Anything but a non-empty string is refused with a `TypeError` naming the call, and the write with it. Delete and read dispatches carry no member. The sandbox marshals the member into a shipped body (QuickJS), and no capability is needed to call it.
19+
- **Flows: the `save_notice` node.** `{ type: 'save_notice', config: { message } }` renders its `{{ }}` message and sets it on the write that started the run. The record-change trigger runs the flow inside that write and hands the run the write's channel (`AutomationContext.saveNotice`). The flow parse refuses the node, at any region depth, in a flow whose start is not a create or update record trigger (`record-(before|after)-(create|insert|update|write)`), because no write waits on such a run. At run time the node is refused by name where the parse cannot see it: a leg resumed after an approval or a wait, a subflow or map child (they get no channel), and a run started any other way.
20+
- **Translatable where it is rendered.** A flow notice is picked from `flows.<flow>.notices.<node_id>.message` (`flowNoticeMessageKey`) in the writer's locale before its holes are filled, and the authored template renders when nothing is picked. `os i18n extract` scaffolds the key, the coverage gate demands it, and `objectstack validate` refuses a key over an unknown flow, an unknown node, or a node that is not a `save_notice`. The answer's `notice` stays a plain string: the sentence arrives already rendered.
21+
22+
Scope: single-record create and update only. A batch, a clone and a write a flow node makes pass no listener, so they carry no notice.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
feat(lint): `os validate`, `os build` and `os lint` warn when a package's `manifest.namespace` is too long to have a paired customization namespace (`namespace-no-customization-pair`)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
ADR-0146 gives every installed base package one paired customization package, `<id>.custom`, whose namespace is the base namespace followed by `_custom`. A manifest namespace admits at most 20 characters, so a base namespace of 14 characters or more has no pair namespace: the ensure door refuses to create the pair, and the package's installers can never customize it. Until now its author learned this only when a customer first tried.
11+
12+
- **Now.** A new advisory rule reports such a package at `manifest.namespace` (or `packages[N].manifest.namespace` in a multi-package artifact) with severity `warning`, naming the namespace and the `_custom` pair namespace it cannot have. `os explain namespace-no-customization-pair` prints the reasoning.
13+
- **One reader.** The verdict is `customizationNamespace` from `@objectstack/spec/kernel`, the function the ensure door creates the pair with, and the sentence is built from `MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH` and `CUSTOMIZATION_NAMESPACE_SUFFIX`. Nothing restates the limit.
14+
- **Not judged.** A package that is itself a `.custom` pair (it is never paired again), a package that declares no namespace, and a namespace the manifest grammar refuses for another reason, which `os validate` reports.
15+
- **Who sees it.** No shipped package or example in this repository: the longest namespace among them is `feature_flags`, 13 characters.
16+
17+
Nothing is refused: the rule never emits an error, and it does not run at the runtime publish door, because a package manifest is not a metadata item.
18+
19+
**The remedy.** Shorten `manifest.namespace` to 13 characters or fewer, and rename the object names it prefixes to match.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
feat(client): `client.auth.mePermissions()` reads the caller's effective permissions, typed `MePermissionsResponse`
6+
7+
Clause-②: yes (widening) — one new SDK method over an existing route; no wire change and no new route.
8+
9+
Metadata-protocol: none
10+
11+
- **Before.** `GET /auth/me/permissions` had a strict response contract in `@objectstack/spec/api` (`MePermissionsResponseSchema`) but no client method, so a consumer fetched the route by hand and typed the body itself.
12+
- **Now.** `client.auth.mePermissions()` GETs `/api/v1/auth/me/permissions` and resolves `MePermissionsResponse`. Discriminate on `authenticated`: a signed-out caller resolves `{ authenticated: false }` instead of throwing, unlike `client.auth.me()`.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
"@objectstack/core": patch
4+
---
5+
6+
fix(service-automation): a stored connector under a host plugin connector's name is skipped at start, as it is while the platform runs
7+
8+
Clause-②: yes (widening) — `@objectstack/core` adds one read-only public member, `dispatchesBootPhaseHooks()`, on `ObjectKernel` and `ObjectKernelBase` (so `LiteKernel`); no accepted value changes.
9+
10+
Metadata-protocol: none
11+
12+
- **Now.** A provider-bound connector saved in the environment (through the metadata door or Studio) under the name of a connector that a plugin on the host provides is treated at start exactly as it is while the platform runs: the plugin's connector is served under that name, and the saved connector is not served, with the same log record naming it. Connectors saved in the environment are now materialized once every plugin has started, still before the server accepts requests.
13+
- **Unchanged.** A connector the host's own stack declares, and an installed package's, keep their start behavior, including when their name collides with a plugin's connector.
14+
- **`@objectstack/core`.** `ObjectKernel.dispatchesBootPhaseHooks()` answers whether `bootstrap()` dispatches `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` once every plugin has started (`false` exactly when the `bootPhaseHooks` option is `false`); `LiteKernel` always answers `true`. A plugin that leaves part of its start to one of those hooks reads it during its own `start()`. On a kernel that does not dispatch them, or a host that does not say, saved connectors are materialized during the automation plugin's own start, as before.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/mcp": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(mcp): `validate_expression` refuses the `label()` call the object save door refuses
7+
8+
The object save door refuses a formula whose `label(record.FIELD)` names a field that declares no options (422 `expression-invalid`, code `label-field-no-options`). The MCP `validate_expression` tool judges with the same validator, but the object schema it read carried field names and types only, so it answered `ok: true` for that formula and the agent learned of the refusal at save.
9+
10+
Clause-②: yes (widening) — the published `McpDataBridge` interface gains one optional member, `optionFieldsOf(name)`. A bridge that omits it behaves exactly as before. No tool's input or output shape changes: `describe_object` serves the same projection, and `validate_expression`'s new verdict is an `errors[]` entry with an existing code.
11+
12+
Metadata-protocol: none (nothing a metadata author can write is added, refused or changed in meaning; the door already refused this formula)
13+
14+
- **Now.** On the `formula` site, `validate_expression` hands the validator the object's option-bearing fields (`ExprSchemaHint.optionFields`), so `label(record.first)` on a text field answers `ok: false` with one `label-field-no-options` error naming `first`, in the sentence the door carries.
15+
- **Unchanged.** `label()` on a field with inline `options`, or bound to a shared `picklist`, reads ok. An unknown field keeps its `unknown-field` refusal and gets no second one. The `validation`, `flow_condition` and `template` sites answer as before: the door judges `label()` targets on a formula field's `expression` only.
16+
- **How.** Both bridges (the stdio bridge and the runtime's HTTP bridge) answer `optionFieldsOf` from the object `describeObject` reads, the protocol's served object, through `@objectstack/formula`'s `declaresOptions`: on the served object a picklist-bound field already carries its list's resolved `options`.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os build` runs the `__c` extension-field reservation `os validate` runs (ADR-0146 D10, #28727)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
**BREAKING** accept-set narrowing at `os build` (`@objectstack/cli`): a package source whose field breaks the `__c` reservation now fails the build instead of emitting an artifact.
11+
12+
`os build` now calls `findExtensionFieldSuffixRefusals` beside the override write gate, the same walk over the save and install doors' own judge (`judgeExtensionFieldSuffix`) that `os validate`, the save, restore and publish doors and the marketplace install ask. A field spelled `__c` outside a base's paired `.custom` package, and an `objectExtensions[].fields` key of a `.custom` pair without the suffix, are refused at build with the rows `os validate` prints (`VALIDATION_ERROR`, exit 1), and nothing is written to `dist/`. Before, the build emitted that artifact and the marketplace install or a save or publish door refused it later with the same judge; what any of those doors accepts is unchanged. The fix is the judge's own sentence: rename a plain package's `x__c` field, or suffix the `.custom` extension field (`region` to `region__c`).
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: `os build` now runs the `__c` extension-field reservation `os validate`, the save, restore and publish doors and the marketplace install already run, with the same judge (`judgeExtensionFieldSuffix`) and the same rows. An artifact it now refuses was already refused wherever it landed, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the remedy is the judge's own sentence (rename the field, or add the suffix). -->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
**BREAKING** fix(metadata-protocol): the adopt-orphans door refuses to rebind a package-less object carrying a `__c` field into a plain package (ADR-0146 D10, #28735)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — no file under `skills/**` teaches the adopt-orphans door or a field spelled `__c` (0 hits for `adopt-orphans`, `reassignOrphanedMetadata` and `[a-z0-9]__c`).
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) The narrowing refuses one more door into the `__c` reservation #28564 declared: an adoption (`POST /api/v1/packages/:id/adopt-orphans`) that would rebind a package-less active object row carrying a field spelled `__c` into a package that is not a base's paired `.custom`. No metadata key, accepted value of a key, export or error code is removed or renamed, and no stored shape changes. The remedy is a rename of the field, or adopting into the base's `.custom` pair, an authoring choice a person makes; there is no mechanical conversion between two metadata versions for `objectstack migrate meta` to apply. Measured at the PR's base: 0 fields spelled `[a-z0-9]__c` in `examples/**` and `packages/*/src` outside tests (every textual hit is a doc comment). The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no `packages/spec/**` file is touched, so no schema, type or runtime interface moves (not type-surface-only, not runtime-interface-only); and no export is retired (not runtime-export-retired). -->
12+
13+
`reassignOrphanedMetadata` (REST `POST /api/v1/packages/:id/adopt-orphans`) rebinds every active package-unbound row into the target package. The save door does not judge a package-unbound object against the `__c` reservation (a row bound to no package is the environment's own), so an object carrying `x__c` is stored package-less; adopted into a plain package, `x__c` became that package's own field, the case (a) every other door refuses.
14+
15+
The door now asks the save door's judge (`judgeExtensionFieldSuffix`) for every active `object` row it would rebind, judged against the TARGET package id, before the first storage update. Any refused field refuses the adoption whole: `400 VALIDATION_ERROR`, the message case (a)'s sentence ("the `__c` suffix is reserved to a base's paired `.custom` package (ADR-0146 D10); rename the field."), `issues[]` locating each field at `objects.OBJECT.fields.FIELD`. No row moves and nothing is written through to the registry. An adoption into the base's `.custom` pair is admitted, since its objects are the pair's own. An adopted draft is judged when it is promoted, by the promotion's own call to the same judge. No new error code.
16+
17+
Also, the draft promotion's suffix block is no longer asked of sealed managed content (an object a code package ships), like the two promotion blocks above it: such a draft gets the store's one `NOT_OVERRIDABLE` / 403 refusal instead of "rename the field".
18+
19+
**Migration.** None needed for this repository: 0 fields affected (measured above). An environment holding a package-less object with a `__c` field renames the field before adopting into a plain package, or adopts into the base's `.custom` pair.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/connector-rest': patch
3+
---
4+
5+
fix(connector-rest): a `baseUrl` that carries a query string sends each action to its own path
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A REST connector whose `baseUrl` carries a fixed query parameter (`https://api.example.com/v1?region=eu`, `?api-version=2024-01`) used to put every action's path inside that query: the base and the path were joined as text, so `/items` became `…/v1?region=eu/items` and the request went to `/v1`. The request URL is now built from the parsed base: the path joins the base's path with one slash between, the base's query parameters stay the query, and the action's own parameters (written inline in `path`, or in `query`, an api-key `paramName` included) come after them. **On a clash the action's value wins**: it is the more specific intent, and refusing would break a working action the day its base gains the parameter.
11+
12+
A base without a query sends exactly the URL it sent before, and a path still only ever lands under the base's origin (a path like `//other.example` or an absolute URL stays a path segment). A base fragment keeps the place the text join gave it.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): the ObjectQL strategy serves two dataset dimensions over one column
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
A dataset that exposes one column under two dimension names (`region`, and `zone` declared over `region`) and is queried with both is now served by the ObjectQL strategy with one group per value, keyed by both names, as the native strategy and the draft preview answer it. This holds on the strategy's direct path, beside a bucketed time dimension, and beside a cross-object dimension such as `account.name`. Before, the strategy handed the engine a `groupBy` naming the column twice and answered `INVALID_QUERY` / 400, measured on real SQLite.
11+
12+
The strategy now names each group key once: the same column, or the same column bucketed at the same granularity. The engine's duplicate-column refusal is unchanged, and it still answers a selection that asks for two values under one name, such as a column raw beside its own month bucket. Nothing is refused that was served, and no export, key, error code or status is added.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/metadata-protocol': patch
5+
---
6+
7+
fix(mcp,runtime)!: the MCP data tools refuse to filter, sort, group or aggregate on a field declared `internal: true`, with the data door's own 400 INVALID_FIELD, on the stdio transport and on the HTTP bridge's aggregate_records
8+
9+
Clause-②: no (narrowing) — the MCP stdio transport's `query_records` and `aggregate_records`, and the HTTP MCP bridge's `aggregate_records`, refuse a query that evaluates a field declared `internal: true`, which they served before. Nothing is widened.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no metadata an author writes changes meaning: a query position the REST data door already refuses is now refused by the MCP data tools too, with the door's own refusal. The fix for a caller is to query another field, which the refusal says; there is nothing for a migration to rewrite and no tombstone. -->
13+
14+
**BREAKING** accept-set narrowing at the MCP data tools (`@objectstack/mcp`, `@objectstack/runtime`), on the gate the data door already applies (`@objectstack/metadata-protocol`).
15+
16+
- **Before.** A field declared `internal: true` was omitted from every row on every door, and the REST data door (and the HTTP bridge's `query_records`, which reaches it) refused to evaluate one: `400 INVALID_FIELD`. Three MCP mouths hand a caller's query to the engine without reaching that door, and evaluated it: the stdio transport's `query_records` answered a `where` on such a field (`$eq`, `$startsWith`, any operator) and an `orderBy` by it; the stdio transport's `aggregate_records` and the HTTP bridge's `aggregate_records` answered a `where` or an aggregation's own `filter` on it. A group key or an aggregation operand on it was refused by the engine there, in the engine's words.
17+
- **Now.** Each of the three asks the data door's gate (`assertNoInternalFieldEvaluated`, now public on the protocol service) before the engine, for every principal, administrators included, and answers what the door answers for the same query: `400 INVALID_FIELD`, naming the object, the field and the position, in the door's sentence. On MCP the tool result carries that `code`, `status` and message. No row is read.
18+
- **Unchanged.** A query over ordinary fields is served as before; the flagged field is still omitted from every row; a field the caller may not read is still the security middleware's `403 PERMISSION_DENIED`. The engine's privileged lookups that match on such a field (key, token and share-link verification) do not pass through these doors and are untouched. A host that registers no protocol service has no data door for these tools to agree with and serves them as before.
19+
- **Who sees it.** An MCP client that filters, sorts, groups or aggregates on a field its object declares `internal: true`. The built-in objects that declare one are platform `sys_*` objects, which the MCP data tools refuse by default (`allowSystemObjects`); where a host serves them, the same refusal now applies.
20+
21+
**The remedy.** Filter, sort, group and aggregate on another field. A field that must be queryable is not one to declare `internal: true`.

0 commit comments

Comments
 (0)