Skip to content

Commit 8e94a19

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-lzvklr
# Conflicts: # packages/runtime/src/domains/packages.ts
2 parents 0fba995 + c77dd17 commit 8e94a19

115 files changed

Lines changed: 2014 additions & 390 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/runtime': patch
3+
---
4+
5+
fix(runtime)!: `PATCH /api/v1/packages/:id/disable` refuses while an enabled package's validation rule reads a field the package adds through `objectExtensions`
6+
7+
Clause-②: no (narrowing) — the package disable door, and the install door's `enableOnInstall: false` arm on an overwrite of an enabled package, refuse (`409` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`) a disable they accepted before: one that would withhold a field an active validation rule reads. Nothing is widened: no export, request shape, response member or authorable key is added, and the code is the one #30724 registered.
8+
Metadata-protocol: none
9+
Skills-docs: updated here — `content/docs/api/metadata-api.mdx` (the disable door's entry), `content/docs/deployment/marketplace-handbook.mdx` (the Enable-and-disable step and the Disabling-a-module list) and `content/docs/api/error-catalog.mdx` (the code's 409 entry) name the refusal; `skills/**` teaches no package disable door (grep for `packages/:id/disable`, `disableable`, `WRITABLE_PACKAGE_REQUIRED`: none), so no skill or eval moves.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of the package root changes: a disable request that would have left an object refusing every write is refused before anything changes, with an error code already registered. Nothing at rest records that answer, so there is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** accept-set narrowing on the package disable door and the install door's `enableOnInstall: false` arm on an overwrite of an enabled package (`@objectstack/runtime`, `domains/packages.ts`), for one case only.
14+
15+
- **Before.** A disabled package's `objectExtensions` fields are withheld from the object they extend. When a validation rule of another package that stayed enabled read one of those fields, the disable answered `200`, and every write to that object was then refused (`400 VALIDATION_FAILED`, naming the rule) for as long as the package stayed off.
16+
- **Now.** The door refuses such a disable with `409` and `error.code` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, after the read-only check and before anything changes: the package row, the served objects and the environment's stored disable state stay as they were. The message names every reading rule as `package · object · rule (reads FIELD)`, one or several. The refusal carries no other `error.details`.
17+
- **Who is scanned.** Any enabled package, declared dependent or not (no `manifest.dependencies` entry is read), an app's `<base>.custom` pair (where tenant-written rules live) included, at every severity. Each rule is judged as the object will serve it once the disable lands: one rule per name (a later layer's copy replaces an earlier one), with a tenant overlay's `message` and `active` on it, so a packaged rule the tenant switched off through the overlay does not refuse the disable. The message names each rule after the layer whose copy is served (`tenant overlay` for a rule the overlay declares itself). A rule reads a field when its predicate names it on the rule's own object or one hop through a reference field of that object (`record.account.FIELD`), or when it is a `state_machine` / `format` / `json_schema` rule on that field.
18+
- **The install door too, on an overwrite of an enabled package.** `POST /api/v1/packages` with `overwrite` and `enableOnInstall: false`, over a package that is enabled before the install, asks the same scan before it disables: such an overwrite still lands, the package stays enabled, and the door answers the same `409` instead of a `201` claiming a disable. A fresh install (or a reinstall after a delete) and an overwrite of a disabled package withhold nothing a rule is served against, and land disabled as asked, as before.
19+
- **Unchanged.** A formula over the field is no reason to refuse (it answers `null`), nor is a rule switched off (`active: false`) or a rule of a package that is itself disabled. Every other disable answers as before.
20+
- **Who moves.** An administrator or client disabling such a package, or overwriting it, while enabled, with `enableOnInstall: false`: a refusal naming the rules, instead of a disable that left the object refusing every write.
21+
22+
**The remedy.** Switch off or change each named rule first, or disable the package that owns it, then disable the package; or keep the package enabled.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/cloud-connection': patch
4+
'@objectstack/service-push': patch
5+
---
6+
7+
fix(cloud-connection): name the `cloud-binding` service once, in its contract, and read an empty `controlPlaneUrl` one way
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
`@objectstack/spec/contracts` now exports `CLOUD_BINDING_SERVICE` (`'cloud-binding'`) beside `ICloudBindingService`, the way the sibling contracts export their service names. `@objectstack/cloud-connection` registers the binding under it and `@objectstack/service-push`'s relay sender reads it, so the registrar and the reader can no longer spell the name two ways. `@objectstack/service-push` keeps exporting `CLOUD_BINDING_SERVICE`, now a re-export of the contract's constant with the same value.
13+
14+
`CloudConnectionPlugin`'s routes now take their control-plane URL from the `cloud-binding` service they register, so the routes and the binding read the `controlPlaneUrl` option the same way:
15+
16+
- An explicit empty (or blank) `controlPlaneUrl` means "cloud not configured" for both: the routes answer `CLOUD_UNCONFIGURED` as before, and `controlPlaneUrl()` now answers `''` instead of the production default.
17+
- A declined `OS_CLOUD_URL` (`off`, `none`, `local`, `disabled`) under an absent option is "cloud not configured" for the routes too, as it already was for the binding.
18+
- An absent option falls through to `OS_CLOUD_URL`, then the ObjectStack-operated default, for the routes too, as it already did for the binding and for the marketplace plugins.
19+
20+
`objectstack serve` and the showcase app are unchanged: they compose the plugin only with `resolveCloudUrl()`'s non-empty answer, which both readings return as is.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os lint`'s `naming/namespace-prefix` no longer asks a package to rename the `guest_portal` permission set
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The public form doors' submit context requests the permission set named exactly `guest_portal`, and the result masker resolves a set registered under that name. A package that declares it is plugging into that door. Renaming it to `<namespace>_guest_portal`, which the warning used to suggest, would leave the masker with nothing to find. `os lint` now leaves that one name alone in `permissions`. The same spelling in any other collection, or any other permission set name, is still judged.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(mcp)!: the MCP `delete_record` tool refuses a call without `confirm: true`, with `run_action`'s confirmation refusal
7+
8+
Clause-②: yes (narrowing) — the MCP `delete_record` tool refuses every call that does not carry `confirm: true`, where it deleted the record. The confirmation contract in `@objectstack/spec/contracts` (`AIActionConfirmation`, `ActionConfirmationRequiredDetails`) gains its second door in its docblock, and the error-code ledger lists `ACTION_CONFIRMATION_REQUIRED` under `@objectstack/mcp` as a second emitter; no shape changes.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and nothing at rest records a tool call: what moves is the answer an MCP client gets for a delete_record call that does not carry the confirmation member. The fix is in the calling client, which adds one member to the call; there is nothing for a metadata migration to rewrite. -->
12+
13+
**BREAKING** accept-set narrowing at the MCP `delete_record` tool (`@objectstack/mcp`), over both transports (Streamable HTTP `/api/v1/mcp` and stdio): every MCP client that deletes is refused until it sends the confirmation member.
14+
15+
- **Before.** `delete_record({ objectName, recordId })` deleted the record. A delete is hard and permanent, and the tool's only safety signal was `annotations.destructiveHint`, a hint a client may ignore. A call carrying `confirm` was refused as an unknown key.
16+
- **Now.** A call without `confirm`, or with `confirm: false`, is refused before the data bridge is reached, and nothing is read or deleted. A non-boolean `confirm` (the string `"true"`, `1`) is refused before that by the tool's closed input schema, with no code and no `details`, exactly as on `run_action`. The answer to an absent or `false` member is the refusal `run_action` gives an action whose author declared `ai.requiresConfirmation: true`: an `isError` result whose text is `{ "error": { "code": "ACTION_CONFIRMATION_REQUIRED", "message", "status": 428, "details": { "actionName": "delete_record", "objectName", "confirmationMember": "confirm" } } }`. `delete_record({ objectName, recordId, confirm: true })` deletes as before, under the same exposure, credential and permission checks. The tool schema advertises `confirm` as an optional boolean, and the tool description tells the agent to ask the human first, then retry.
17+
- **Who moves.** An MCP client or agent that calls `delete_record`. A client that already handles `run_action`'s `ACTION_CONFIRMATION_REQUIRED` (read `details.confirmationMember`, ask the human, resend the same call with that member set to `true`) needs no change.
18+
19+
**The remedy.** Ask the human in the loop, then send the same `delete_record` call with `confirm: true` added beside `objectName` and `recordId`. The flag asserts an approval; it does not obtain one, and the gate makes forgetting loud without proving a human.
20+
21+
**The bounds, as decided.**
22+
23+
- REST `DELETE /api/v1/data/:object/:id` (the human path) is outside the gate and unchanged.
24+
- `update_record` is not gated.
25+
- No object is exempt: every object a caller may delete through `delete_record` takes the gate.
26+
- Any future bulk-delete tool takes the same gate.
27+
28+
The member, the code, the status and the `details` shape are the contract `run_action` already serves (`AI_ACTION_CONFIRMATION_MEMBER` and `ActionConfirmationRequiredDetails` in `@objectstack/spec/contracts`), so an agent learns one retry for both tools. The MCP server's own skill (`GET /api/v1/mcp/skill`) teaches the member on `delete_record`.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,13 @@ drive this MCP server. Same word, different layer.
376376
an action as a code-review-worthy act — that's the real security boundary.
377377
- An action can also declare `ai.requiresConfirmation`; destructive-looking
378378
actions (`confirmText`, danger variants) default to requiring it.
379+
- **`delete_record` needs `confirm: true` on every call.** A record delete over
380+
MCP is permanent, so the tool refuses a call without it with `run_action`'s
381+
refusal (`428` `ACTION_CONFIRMATION_REQUIRED`, its `details.actionName`
382+
naming `delete_record`) and deletes nothing. The agent asks its human, then retries
383+
with `confirm: true`; the flag asserts an approval, it does not obtain one.
384+
REST `DELETE /api/v1/data/:object/:id` and `update_record` take no
385+
confirmation.
379386

380387
## Verify the connection
381388

‎content/docs/api/error-catalog.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ navTitle: Error Code Catalog
44
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
55
---
66

7-
ObjectStack uses a structured error system of **error categories** and **57 error codes reachable on the wire**, catalogued in full below (that count is pinned by `packages/spec/src/api/error-catalog-docs.test.ts` against the wire face). Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
7+
ObjectStack uses a structured error system of **error categories** and **58 error codes reachable on the wire**, catalogued in full below (that count is pinned by `packages/spec/src/api/error-catalog-docs.test.ts` against the wire face). Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
88

99
This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
1010
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
@@ -546,6 +546,11 @@ spelling never crosses HTTP.
546546
**Fix:** Wait for the lock to be released, or contact the lock holder.
547547
**Retry:** `retry_backoff`
548548

549+
### `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
550+
**Cause:** `PATCH /api/v1/packages/:id/disable` was refused because a validation rule of an enabled package (declared dependent or not, an app's `.custom` pair included, any severity) reads a field this package adds to another object through `objectExtensions`. Disabling it would withhold that field, and every write to the object would then fail the rule. Each rule is judged as the object serves it: one rule per name (the copy a later layer declares wins), with a tenant overlay's switch honoured. The message names each rule as `package · object · rule (reads FIELD)`, one or several, after the layer whose copy is served (`tenant overlay` for a rule the overlay declares itself). Nothing changed. `POST /api/v1/packages` answers the same code for an overwrite of an enabled package with `enableOnInstall: false` that would leave such a package disabled: the overwrite itself lands and the package stays enabled. A fresh install with `enableOnInstall: false` lands disabled, as before.
551+
**Fix:** Switch off or change each named rule first, or disable the package that owns it, then disable this package; or keep it enabled. A formula over the field is no reason to refuse: it answers `null`.
552+
**Retry:** `no_retry`
553+
549554
---
550555

551556
## Request Errors (405/428)

‎content/docs/api/metadata-api.mdx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ Enable a disabled package.
182182

183183
Disable a package without uninstalling.
184184

185+
A package that adds fields to another object through `objectExtensions` is
186+
refused `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` while a validation
187+
rule of any enabled package reads one of those fields (declared dependent or
188+
not, an app's `.custom` pair included, any severity): withheld, the field could
189+
not be read, and every write to that object would fail the rule. The message
190+
names each rule as `package · object · rule (reads FIELD)`, and nothing changes.
191+
A rule is judged as the object serves it: one rule per name, a tenant overlay's
192+
switch honoured. Switch off or change each named rule first, or disable the
193+
package that owns it. A formula over the field does not refuse the disable; it
194+
answers `null`. `POST /packages` answers the same `409` for an overwrite of an
195+
enabled package with `enableOnInstall: false` that would leave such a package
196+
disabled; the overwrite lands and the package stays enabled. A fresh install
197+
with `enableOnInstall: false` lands disabled, as before.
198+
185199
---
186200

187201
## See also

‎content/docs/deployment/marketplace-handbook.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,9 @@ the detail.
228228
For every other package provided by code or by an installed app (the app
229229
itself, a plugin, a platform package), the runtime answers
230230
`PATCH /api/v1/packages/:id/disable` with `422 WRITABLE_PACKAGE_REQUIRED`.
231+
A disable that would leave a validation rule reading a field the package
232+
adds is refused with `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
233+
([below](#disabling-a-module)).
231234
To take an installed app away, uninstall it.
232235
8. **Upgrading.** You publish a new version; nothing running changes. An
233236
environment installed through the cloud shows **Update available → v…** and
@@ -302,6 +305,21 @@ its data is kept.
302305
- **Restart.** The choice is kept per environment and survives a restart.
303306
- **Authoring.** A disabled module is still read-only: edit, publish, revert
304307
and delete keep answering `422 WRITABLE_PACKAGE_REQUIRED`.
308+
- **A rule that reads its fields.** A module that adds fields to another
309+
object through `objectExtensions` cannot be disabled while a validation rule
310+
of any enabled package reads one of those fields: declared dependent or not,
311+
your app's `.custom` pair (where the customer's own rules live) included, at
312+
any severity. The disable door answers
313+
`409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, and its message names
314+
each rule as `package · object · rule (reads FIELD)`; nothing changes.
315+
Withheld, the field could not be read, and every write to that object would
316+
fail the rule. Switch off or change each named rule first (a rule the
317+
customer switched off on their copy of the object counts as off), or disable
318+
the package that owns it. A formula over the field does not block the
319+
disable: it answers `null` while the module is off. An overwrite of the
320+
enabled module with `enableOnInstall: false` that would leave it disabled in
321+
that state answers the same `409`, and the module stays enabled; a fresh
322+
install with `enableOnInstall: false` lands disabled, as before.
305323

306324
**What your app's own code meets.** A hook of your app that reads or writes a
307325
module's object through `ctx.api.object('<object>')` runs inside the server,

‎content/docs/ui/doc-pages.mdx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@ src/docs/crm_user_guide.md ✅ package namespace is "crm"
7474
src/docs/user_guide.md ❌ bare name — lint error (docs/namespace-prefix)
7575
```
7676

77-
This is the same `namespace-prefix` lint the platform applies to other
78-
named metadata for readable, globally-unique filenames. It is no longer
79-
**load-bearing for uniqueness**, however: per **ADR-0048**, doc
80-
resolution is **package-scoped**. Packaged items are stored under a
77+
Docs have their own rule id, but the contract is the one `os lint`'s
78+
`naming/namespace-prefix` warning applies to every other package-authored
79+
name: each one carries its package namespace, so that no two installed
80+
packages hold the same name. The runtime does not rely on the prefix to
81+
keep docs apart, however: per **ADR-0048**, doc resolution is
82+
**package-scoped**. Packaged items are stored under a
8183
composite `<packageId>:<name>` registry key, and each retains its
8284
`_packageId`, so two installed packages may each ship a doc with the same
8385
bare name and coexist — neither silently overwrites the other.

0 commit comments

Comments
 (0)