Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .changeset/engine-unresolvable-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
---
"@objectstack/rest": minor
---

fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — for hosts that wire their own objectql provider (#13476)

Runtime behaviour change on a public REST door, shipped as `minor` under the
repo's launch-window convention — the same convention #13279's changeset names
for the identical class of change.

**Reach — read this first if you are deciding whether this release fixes
*your* 403.** The repair lands at `RestServer`'s provider seam, so it takes
effect in deployments that wire their **own** `objectql` provider — one that
throws or rejects when the engine cannot be resolved. The **shipped
single-kernel wiring is not yet covered**: the provider `rest-api-plugin.ts`
hands over absorbs the failure one layer earlier (`catch { return undefined }`),
so an engine that fails to resolve still reaches the seam as a *resolved*
`undefined` — indistinguishable there from the supported "no engine wired"
shape — and the package door still answers `403 FORBIDDEN`. That absorb is
filed as #13904; until it lands, deployments on the shipped single-kernel
wiring see **no behaviour change** from this fix.

`RestServer.computeExecCtx` resolved the data engine through the seam helper
that absorbs any failure to `undefined`. So an engine that could not be
**resolved** and an embedder that had wired **no engine at all** arrived at
`resolveAuthzContext` as the same value. The resolver then took `tryFind`'s
`!ql` guard — correctly, for its own contract, because "no engine is wired" is a
supported embedder shape that must keep resolving to an empty-but-valid envelope
— and the package-management door answered `403 FORBIDDEN`: "Reading packages
requires the `studio.access` or `setup.access` capability."

Two different facts had collapsed into one value:

- an embedder that never configured a data plane — zero capabilities is **true**;
- a deployment whose engine resolution **failed** — zero capabilities is **unknown**.

Measured on a real `RestServer` with a real `registerPackageRoutes`. The fault
leg was driven with an injected provider that rejects — the shape a host
wiring its own provider produces, and one the shipped plugin's provider never
does (it absorbs to a resolved `undefined`):

| wiring | before | after |
|:--|:--|:--|
| healthy engine granting the capabilities | 200 | 200 |
| no engine wired at all (supported shape) | 403 | 403 — unchanged |
| own provider: engine resolution throws or rejects | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** |
| shipped single-kernel wiring: failure absorbed to `undefined` (#13904) | 403 | 403 — unchanged, pinned |

The provider branch of the seam now takes the wiring fact from the provider's
**presence** rather than inferring it from what the provider returned, and a
wired seam that fails raises the existing `AuthzStoreUnavailableError`. A
provider that *resolves* `undefined` still means "no engine", quietly and
unchanged — that is the seam contract declaring absence, not failing. That same
quiet path is what keeps the shipped wiring out of reach until #13904: its
provider declares absence where it has only observed failure.

This is **coverage of #13279's already-ruled class**, not a new trade-off. That
ruling (2026-08-30, verbatim 「第一批其余同意」) settled the direction: a
permission-store read that fails must fail loud rather than resolve as an
authenticated principal holding zero capabilities. `tryFind` implemented it for
a read that was issued and threw; an engine that cannot be resolved never issues
a read, so the ruling's landing point could not see it.

⚠️ The direction is **conservative in both readings**: the unknown was already
answered as a refusal (403) and is now answered as the outage it is (503).
Nothing that was refused becomes served, and no route changes who may reach it.

⚠️ Not repaired here, and filed rather than left implicit — the same disguise
survives in two named places:

- **#13904** — the shipped single-kernel provider in `rest-api-plugin.ts`
absorbs resolution failure into a resolved `undefined` one layer before this
seam (the reach boundary above). `ctx.getService` throws for three
distinguishable conditions and only one of them means "no engine is wired",
so which of them the provider should re-raise is its own judgement, filed
rather than folded in.
- **#13905** — the **kernel** branch of the same seam resolves through
`kernel.getServiceAsync('objectql')`, which rejects identically whether the
service was never registered (the supported no-data-plane shape) or was
registered and failed to construct. Separating those two needs the service
registry to stop conflating them, which is a `@objectstack/core` contract
change.

**ADR-0087 disposition.** This change touches no metadata surface in either
direction — no Zod schema, no spec declaration, no authorable key, no stored
row, no object definition — so `objectstack migrate meta` has nothing to visit
and no tombstone exists to mint. `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode` member; `HttpStatusErrorCodeMap` — the status→code map —
already carries `503: 'SERVICE_UNAVAILABLE'`, and the error-code ledger pins
the code→status direction (`SERVICE_UNAVAILABLE: 503`). The wire vocabulary is
unchanged; only which declared code this condition selects.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand Up@@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand DownExpand Up@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(rest): a data engine that cannot be RESOLVED no longer answers 403 FORBIDDEN (#13476) by claude[bot] · Pull Request #13910 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .changeset/engine-unresolvable-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
---
"@objectstack/rest": minor
---

fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — for hosts that wire their own objectql provider (#13476)

Runtime behaviour change on a public REST door, shipped as `minor` under the
repo's launch-window convention — the same convention #13279's changeset names
for the identical class of change.

**Reach — read this first if you are deciding whether this release fixes
*your* 403.** The repair lands at `RestServer`'s provider seam, so it takes
effect in deployments that wire their **own** `objectql` provider — one that
throws or rejects when the engine cannot be resolved. The **shipped
single-kernel wiring is not yet covered**: the provider `rest-api-plugin.ts`
hands over absorbs the failure one layer earlier (`catch { return undefined }`),
so an engine that fails to resolve still reaches the seam as a *resolved*
`undefined` — indistinguishable there from the supported "no engine wired"
shape — and the package door still answers `403 FORBIDDEN`. That absorb is
filed as #13904; until it lands, deployments on the shipped single-kernel
wiring see **no behaviour change** from this fix.

`RestServer.computeExecCtx` resolved the data engine through the seam helper
that absorbs any failure to `undefined`. So an engine that could not be
**resolved** and an embedder that had wired **no engine at all** arrived at
`resolveAuthzContext` as the same value. The resolver then took `tryFind`'s
`!ql` guard — correctly, for its own contract, because "no engine is wired" is a
supported embedder shape that must keep resolving to an empty-but-valid envelope
— and the package-management door answered `403 FORBIDDEN`: "Reading packages
requires the `studio.access` or `setup.access` capability."

Two different facts had collapsed into one value:

- an embedder that never configured a data plane — zero capabilities is **true**;
- a deployment whose engine resolution **failed** — zero capabilities is **unknown**.

Measured on a real `RestServer` with a real `registerPackageRoutes`. The fault
leg was driven with an injected provider that rejects — the shape a host
wiring its own provider produces, and one the shipped plugin's provider never
does (it absorbs to a resolved `undefined`):

| wiring | before | after |
|:--|:--|:--|
| healthy engine granting the capabilities | 200 | 200 |
| no engine wired at all (supported shape) | 403 | 403 — unchanged |
| own provider: engine resolution throws or rejects | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** |
| shipped single-kernel wiring: failure absorbed to `undefined` (#13904) | 403 | 403 — unchanged, pinned |

The provider branch of the seam now takes the wiring fact from the provider's
**presence** rather than inferring it from what the provider returned, and a
wired seam that fails raises the existing `AuthzStoreUnavailableError`. A
provider that *resolves* `undefined` still means "no engine", quietly and
unchanged — that is the seam contract declaring absence, not failing. That same
quiet path is what keeps the shipped wiring out of reach until #13904: its
provider declares absence where it has only observed failure.

This is **coverage of #13279's already-ruled class**, not a new trade-off. That
ruling (2026-08-30, verbatim 「第一批其余同意」) settled the direction: a
permission-store read that fails must fail loud rather than resolve as an
authenticated principal holding zero capabilities. `tryFind` implemented it for
a read that was issued and threw; an engine that cannot be resolved never issues
a read, so the ruling's landing point could not see it.

⚠️ The direction is **conservative in both readings**: the unknown was already
answered as a refusal (403) and is now answered as the outage it is (503).
Nothing that was refused becomes served, and no route changes who may reach it.

⚠️ Not repaired here, and filed rather than left implicit — the same disguise
survives in two named places:

- **#13904** — the shipped single-kernel provider in `rest-api-plugin.ts`
absorbs resolution failure into a resolved `undefined` one layer before this
seam (the reach boundary above). `ctx.getService` throws for three
distinguishable conditions and only one of them means "no engine is wired",
so which of them the provider should re-raise is its own judgement, filed
rather than folded in.
- **#13905** — the **kernel** branch of the same seam resolves through
`kernel.getServiceAsync('objectql')`, which rejects identically whether the
service was never registered (the supported no-data-plane shape) or was
registered and failed to construct. Separating those two needs the service
registry to stop conflating them, which is a `@objectstack/core` contract
change.

**ADR-0087 disposition.** This change touches no metadata surface in either
direction — no Zod schema, no spec declaration, no authorable key, no stored
row, no object definition — so `objectstack migrate meta` has nothing to visit
and no tombstone exists to mint. `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode` member; `HttpStatusErrorCodeMap` — the status→code map —
already carries `503: 'SERVICE_UNAVAILABLE'`, and the error-code ledger pins
the code→status direction (`SERVICE_UNAVAILABLE: 503`). The wire vocabulary is
unchanged; only which declared code this condition selects.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand Up@@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand DownExpand Up@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): a data engine that cannot be RESOLVED no longer answers 403 FORBIDDEN (#13476) by claude[bot] · Pull Request #13910 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .changeset/engine-unresolvable-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
---
"@objectstack/rest": minor
---

fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — for hosts that wire their own objectql provider (#13476)

Runtime behaviour change on a public REST door, shipped as `minor` under the
repo's launch-window convention — the same convention #13279's changeset names
for the identical class of change.

**Reach — read this first if you are deciding whether this release fixes
*your* 403.** The repair lands at `RestServer`'s provider seam, so it takes
effect in deployments that wire their **own** `objectql` provider — one that
throws or rejects when the engine cannot be resolved. The **shipped
single-kernel wiring is not yet covered**: the provider `rest-api-plugin.ts`
hands over absorbs the failure one layer earlier (`catch { return undefined }`),
so an engine that fails to resolve still reaches the seam as a *resolved*
`undefined` — indistinguishable there from the supported "no engine wired"
shape — and the package door still answers `403 FORBIDDEN`. That absorb is
filed as #13904; until it lands, deployments on the shipped single-kernel
wiring see **no behaviour change** from this fix.

`RestServer.computeExecCtx` resolved the data engine through the seam helper
that absorbs any failure to `undefined`. So an engine that could not be
**resolved** and an embedder that had wired **no engine at all** arrived at
`resolveAuthzContext` as the same value. The resolver then took `tryFind`'s
`!ql` guard — correctly, for its own contract, because "no engine is wired" is a
supported embedder shape that must keep resolving to an empty-but-valid envelope
— and the package-management door answered `403 FORBIDDEN`: "Reading packages
requires the `studio.access` or `setup.access` capability."

Two different facts had collapsed into one value:

- an embedder that never configured a data plane — zero capabilities is **true**;
- a deployment whose engine resolution **failed** — zero capabilities is **unknown**.

Measured on a real `RestServer` with a real `registerPackageRoutes`. The fault
leg was driven with an injected provider that rejects — the shape a host
wiring its own provider produces, and one the shipped plugin's provider never
does (it absorbs to a resolved `undefined`):

| wiring | before | after |
|:--|:--|:--|
| healthy engine granting the capabilities | 200 | 200 |
| no engine wired at all (supported shape) | 403 | 403 — unchanged |
| own provider: engine resolution throws or rejects | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** |
| shipped single-kernel wiring: failure absorbed to `undefined` (#13904) | 403 | 403 — unchanged, pinned |

The provider branch of the seam now takes the wiring fact from the provider's
**presence** rather than inferring it from what the provider returned, and a
wired seam that fails raises the existing `AuthzStoreUnavailableError`. A
provider that *resolves* `undefined` still means "no engine", quietly and
unchanged — that is the seam contract declaring absence, not failing. That same
quiet path is what keeps the shipped wiring out of reach until #13904: its
provider declares absence where it has only observed failure.

This is **coverage of #13279's already-ruled class**, not a new trade-off. That
ruling (2026-08-30, verbatim 「第一批其余同意」) settled the direction: a
permission-store read that fails must fail loud rather than resolve as an
authenticated principal holding zero capabilities. `tryFind` implemented it for
a read that was issued and threw; an engine that cannot be resolved never issues
a read, so the ruling's landing point could not see it.

⚠️ The direction is **conservative in both readings**: the unknown was already
answered as a refusal (403) and is now answered as the outage it is (503).
Nothing that was refused becomes served, and no route changes who may reach it.

⚠️ Not repaired here, and filed rather than left implicit — the same disguise
survives in two named places:

- **#13904** — the shipped single-kernel provider in `rest-api-plugin.ts`
absorbs resolution failure into a resolved `undefined` one layer before this
seam (the reach boundary above). `ctx.getService` throws for three
distinguishable conditions and only one of them means "no engine is wired",
so which of them the provider should re-raise is its own judgement, filed
rather than folded in.
- **#13905** — the **kernel** branch of the same seam resolves through
`kernel.getServiceAsync('objectql')`, which rejects identically whether the
service was never registered (the supported no-data-plane shape) or was
registered and failed to construct. Separating those two needs the service
registry to stop conflating them, which is a `@objectstack/core` contract
change.

**ADR-0087 disposition.** This change touches no metadata surface in either
direction — no Zod schema, no spec declaration, no authorable key, no stored
row, no object definition — so `objectstack migrate meta` has nothing to visit
and no tombstone exists to mint. `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode` member; `HttpStatusErrorCodeMap` — the status→code map —
already carries `503: 'SERVICE_UNAVAILABLE'`, and the error-code ledger pins
the code→status direction (`SERVICE_UNAVAILABLE: 503`). The wire vocabulary is
unchanged; only which declared code this condition selects.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand Up@@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand DownExpand Up@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): a data engine that cannot be RESOLVED no longer answers 403 FORBIDDEN (#13476) by claude[bot] · Pull Request #13910 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .changeset/engine-unresolvable-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
---
"@objectstack/rest": minor
---

fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — for hosts that wire their own objectql provider (#13476)

Runtime behaviour change on a public REST door, shipped as `minor` under the
repo's launch-window convention — the same convention #13279's changeset names
for the identical class of change.

**Reach — read this first if you are deciding whether this release fixes
*your* 403.** The repair lands at `RestServer`'s provider seam, so it takes
effect in deployments that wire their **own** `objectql` provider — one that
throws or rejects when the engine cannot be resolved. The **shipped
single-kernel wiring is not yet covered**: the provider `rest-api-plugin.ts`
hands over absorbs the failure one layer earlier (`catch { return undefined }`),
so an engine that fails to resolve still reaches the seam as a *resolved*
`undefined` — indistinguishable there from the supported "no engine wired"
shape — and the package door still answers `403 FORBIDDEN`. That absorb is
filed as #13904; until it lands, deployments on the shipped single-kernel
wiring see **no behaviour change** from this fix.

`RestServer.computeExecCtx` resolved the data engine through the seam helper
that absorbs any failure to `undefined`. So an engine that could not be
**resolved** and an embedder that had wired **no engine at all** arrived at
`resolveAuthzContext` as the same value. The resolver then took `tryFind`'s
`!ql` guard — correctly, for its own contract, because "no engine is wired" is a
supported embedder shape that must keep resolving to an empty-but-valid envelope
— and the package-management door answered `403 FORBIDDEN`: "Reading packages
requires the `studio.access` or `setup.access` capability."

Two different facts had collapsed into one value:

- an embedder that never configured a data plane — zero capabilities is **true**;
- a deployment whose engine resolution **failed** — zero capabilities is **unknown**.

Measured on a real `RestServer` with a real `registerPackageRoutes`. The fault
leg was driven with an injected provider that rejects — the shape a host
wiring its own provider produces, and one the shipped plugin's provider never
does (it absorbs to a resolved `undefined`):

| wiring | before | after |
|:--|:--|:--|
| healthy engine granting the capabilities | 200 | 200 |
| no engine wired at all (supported shape) | 403 | 403 — unchanged |
| own provider: engine resolution throws or rejects | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** |
| shipped single-kernel wiring: failure absorbed to `undefined` (#13904) | 403 | 403 — unchanged, pinned |

The provider branch of the seam now takes the wiring fact from the provider's
**presence** rather than inferring it from what the provider returned, and a
wired seam that fails raises the existing `AuthzStoreUnavailableError`. A
provider that *resolves* `undefined` still means "no engine", quietly and
unchanged — that is the seam contract declaring absence, not failing. That same
quiet path is what keeps the shipped wiring out of reach until #13904: its
provider declares absence where it has only observed failure.

This is **coverage of #13279's already-ruled class**, not a new trade-off. That
ruling (2026-08-30, verbatim 「第一批其余同意」) settled the direction: a
permission-store read that fails must fail loud rather than resolve as an
authenticated principal holding zero capabilities. `tryFind` implemented it for
a read that was issued and threw; an engine that cannot be resolved never issues
a read, so the ruling's landing point could not see it.

⚠️ The direction is **conservative in both readings**: the unknown was already
answered as a refusal (403) and is now answered as the outage it is (503).
Nothing that was refused becomes served, and no route changes who may reach it.

⚠️ Not repaired here, and filed rather than left implicit — the same disguise
survives in two named places:

- **#13904** — the shipped single-kernel provider in `rest-api-plugin.ts`
absorbs resolution failure into a resolved `undefined` one layer before this
seam (the reach boundary above). `ctx.getService` throws for three
distinguishable conditions and only one of them means "no engine is wired",
so which of them the provider should re-raise is its own judgement, filed
rather than folded in.
- **#13905** — the **kernel** branch of the same seam resolves through
`kernel.getServiceAsync('objectql')`, which rejects identically whether the
service was never registered (the supported no-data-plane shape) or was
registered and failed to construct. Separating those two needs the service
registry to stop conflating them, which is a `@objectstack/core` contract
change.

**ADR-0087 disposition.** This change touches no metadata surface in either
direction — no Zod schema, no spec declaration, no authorable key, no stored
row, no object definition — so `objectstack migrate meta` has nothing to visit
and no tombstone exists to mint. `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode` member; `HttpStatusErrorCodeMap` — the status→code map —
already carries `503: 'SERVICE_UNAVAILABLE'`, and the error-code ledger pins
the code→status direction (`SERVICE_UNAVAILABLE: 503`). The wire vocabulary is
unchanged; only which declared code this condition selects.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand Up@@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand DownExpand Up@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(rest): a data engine that cannot be RESOLVED no longer answers 403 FORBIDDEN (#13476) by claude[bot] · Pull Request #13910 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .changeset/engine-unresolvable-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
---
"@objectstack/rest": minor
---

fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — for hosts that wire their own objectql provider (#13476)

Runtime behaviour change on a public REST door, shipped as `minor` under the
repo's launch-window convention — the same convention #13279's changeset names
for the identical class of change.

**Reach — read this first if you are deciding whether this release fixes
*your* 403.** The repair lands at `RestServer`'s provider seam, so it takes
effect in deployments that wire their **own** `objectql` provider — one that
throws or rejects when the engine cannot be resolved. The **shipped
single-kernel wiring is not yet covered**: the provider `rest-api-plugin.ts`
hands over absorbs the failure one layer earlier (`catch { return undefined }`),
so an engine that fails to resolve still reaches the seam as a *resolved*
`undefined` — indistinguishable there from the supported "no engine wired"
shape — and the package door still answers `403 FORBIDDEN`. That absorb is
filed as #13904; until it lands, deployments on the shipped single-kernel
wiring see **no behaviour change** from this fix.

`RestServer.computeExecCtx` resolved the data engine through the seam helper
that absorbs any failure to `undefined`. So an engine that could not be
**resolved** and an embedder that had wired **no engine at all** arrived at
`resolveAuthzContext` as the same value. The resolver then took `tryFind`'s
`!ql` guard — correctly, for its own contract, because "no engine is wired" is a
supported embedder shape that must keep resolving to an empty-but-valid envelope
— and the package-management door answered `403 FORBIDDEN`: "Reading packages
requires the `studio.access` or `setup.access` capability."

Two different facts had collapsed into one value:

- an embedder that never configured a data plane — zero capabilities is **true**;
- a deployment whose engine resolution **failed** — zero capabilities is **unknown**.

Measured on a real `RestServer` with a real `registerPackageRoutes`. The fault
leg was driven with an injected provider that rejects — the shape a host
wiring its own provider produces, and one the shipped plugin's provider never
does (it absorbs to a resolved `undefined`):

| wiring | before | after |
|:--|:--|:--|
| healthy engine granting the capabilities | 200 | 200 |
| no engine wired at all (supported shape) | 403 | 403 — unchanged |
| own provider: engine resolution throws or rejects | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** |
| shipped single-kernel wiring: failure absorbed to `undefined` (#13904) | 403 | 403 — unchanged, pinned |

The provider branch of the seam now takes the wiring fact from the provider's
**presence** rather than inferring it from what the provider returned, and a
wired seam that fails raises the existing `AuthzStoreUnavailableError`. A
provider that *resolves* `undefined` still means "no engine", quietly and
unchanged — that is the seam contract declaring absence, not failing. That same
quiet path is what keeps the shipped wiring out of reach until #13904: its
provider declares absence where it has only observed failure.

This is **coverage of #13279's already-ruled class**, not a new trade-off. That
ruling (2026-08-30, verbatim 「第一批其余同意」) settled the direction: a
permission-store read that fails must fail loud rather than resolve as an
authenticated principal holding zero capabilities. `tryFind` implemented it for
a read that was issued and threw; an engine that cannot be resolved never issues
a read, so the ruling's landing point could not see it.

⚠️ The direction is **conservative in both readings**: the unknown was already
answered as a refusal (403) and is now answered as the outage it is (503).
Nothing that was refused becomes served, and no route changes who may reach it.

⚠️ Not repaired here, and filed rather than left implicit — the same disguise
survives in two named places:

- **#13904** — the shipped single-kernel provider in `rest-api-plugin.ts`
absorbs resolution failure into a resolved `undefined` one layer before this
seam (the reach boundary above). `ctx.getService` throws for three
distinguishable conditions and only one of them means "no engine is wired",
so which of them the provider should re-raise is its own judgement, filed
rather than folded in.
- **#13905** — the **kernel** branch of the same seam resolves through
`kernel.getServiceAsync('objectql')`, which rejects identically whether the
service was never registered (the supported no-data-plane shape) or was
registered and failed to construct. Separating those two needs the service
registry to stop conflating them, which is a `@objectstack/core` contract
change.

**ADR-0087 disposition.** This change touches no metadata surface in either
direction — no Zod schema, no spec declaration, no authorable key, no stored
row, no object definition — so `objectstack migrate meta` has nothing to visit
and no tombstone exists to mint. `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode` member; `HttpStatusErrorCodeMap` — the status→code map —
already carries `503: 'SERVICE_UNAVAILABLE'`, and the error-code ledger pins
the code→status direction (`SERVICE_UNAVAILABLE: 503`). The wire vocabulary is
unchanged; only which declared code this condition selects.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand Up@@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand DownExpand Up@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): a data engine that cannot be RESOLVED no longer answers 403 FORBIDDEN (#13476) by claude[bot] · Pull Request #13910 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .changeset/engine-unresolvable-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
---
"@objectstack/rest": minor
---

fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — for hosts that wire their own objectql provider (#13476)

Runtime behaviour change on a public REST door, shipped as `minor` under the
repo's launch-window convention — the same convention #13279's changeset names
for the identical class of change.

**Reach — read this first if you are deciding whether this release fixes
*your* 403.** The repair lands at `RestServer`'s provider seam, so it takes
effect in deployments that wire their **own** `objectql` provider — one that
throws or rejects when the engine cannot be resolved. The **shipped
single-kernel wiring is not yet covered**: the provider `rest-api-plugin.ts`
hands over absorbs the failure one layer earlier (`catch { return undefined }`),
so an engine that fails to resolve still reaches the seam as a *resolved*
`undefined` — indistinguishable there from the supported "no engine wired"
shape — and the package door still answers `403 FORBIDDEN`. That absorb is
filed as #13904; until it lands, deployments on the shipped single-kernel
wiring see **no behaviour change** from this fix.

`RestServer.computeExecCtx` resolved the data engine through the seam helper
that absorbs any failure to `undefined`. So an engine that could not be
**resolved** and an embedder that had wired **no engine at all** arrived at
`resolveAuthzContext` as the same value. The resolver then took `tryFind`'s
`!ql` guard — correctly, for its own contract, because "no engine is wired" is a
supported embedder shape that must keep resolving to an empty-but-valid envelope
— and the package-management door answered `403 FORBIDDEN`: "Reading packages
requires the `studio.access` or `setup.access` capability."

Two different facts had collapsed into one value:

- an embedder that never configured a data plane — zero capabilities is **true**;
- a deployment whose engine resolution **failed** — zero capabilities is **unknown**.

Measured on a real `RestServer` with a real `registerPackageRoutes`. The fault
leg was driven with an injected provider that rejects — the shape a host
wiring its own provider produces, and one the shipped plugin's provider never
does (it absorbs to a resolved `undefined`):

| wiring | before | after |
|:--|:--|:--|
| healthy engine granting the capabilities | 200 | 200 |
| no engine wired at all (supported shape) | 403 | 403 — unchanged |
| own provider: engine resolution throws or rejects | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** |
| shipped single-kernel wiring: failure absorbed to `undefined` (#13904) | 403 | 403 — unchanged, pinned |

The provider branch of the seam now takes the wiring fact from the provider's
**presence** rather than inferring it from what the provider returned, and a
wired seam that fails raises the existing `AuthzStoreUnavailableError`. A
provider that *resolves* `undefined` still means "no engine", quietly and
unchanged — that is the seam contract declaring absence, not failing. That same
quiet path is what keeps the shipped wiring out of reach until #13904: its
provider declares absence where it has only observed failure.

This is **coverage of #13279's already-ruled class**, not a new trade-off. That
ruling (2026-08-30, verbatim 「第一批其余同意」) settled the direction: a
permission-store read that fails must fail loud rather than resolve as an
authenticated principal holding zero capabilities. `tryFind` implemented it for
a read that was issued and threw; an engine that cannot be resolved never issues
a read, so the ruling's landing point could not see it.

⚠️ The direction is **conservative in both readings**: the unknown was already
answered as a refusal (403) and is now answered as the outage it is (503).
Nothing that was refused becomes served, and no route changes who may reach it.

⚠️ Not repaired here, and filed rather than left implicit — the same disguise
survives in two named places:

- **#13904** — the shipped single-kernel provider in `rest-api-plugin.ts`
absorbs resolution failure into a resolved `undefined` one layer before this
seam (the reach boundary above). `ctx.getService` throws for three
distinguishable conditions and only one of them means "no engine is wired",
so which of them the provider should re-raise is its own judgement, filed
rather than folded in.
- **#13905** — the **kernel** branch of the same seam resolves through
`kernel.getServiceAsync('objectql')`, which rejects identically whether the
service was never registered (the supported no-data-plane shape) or was
registered and failed to construct. Separating those two needs the service
registry to stop conflating them, which is a `@objectstack/core` contract
change.

**ADR-0087 disposition.** This change touches no metadata surface in either
direction — no Zod schema, no spec declaration, no authorable key, no stored
row, no object definition — so `objectstack migrate meta` has nothing to visit
and no tombstone exists to mint. `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode` member; `HttpStatusErrorCodeMap` — the status→code map —
already carries `503: 'SERVICE_UNAVAILABLE'`, and the error-code ledger pins
the code→status direction (`SERVICE_UNAVAILABLE: 503`). The wire vocabulary is
unchanged; only which declared code this condition selects.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand Up@@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand DownExpand Up@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): a data engine that cannot be RESOLVED no longer answers 403 FORBIDDEN (#13476) by claude[bot] · Pull Request #13910 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .changeset/engine-unresolvable-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
---
"@objectstack/rest": minor
---

fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — for hosts that wire their own objectql provider (#13476)

Runtime behaviour change on a public REST door, shipped as `minor` under the
repo's launch-window convention — the same convention #13279's changeset names
for the identical class of change.

**Reach — read this first if you are deciding whether this release fixes
*your* 403.** The repair lands at `RestServer`'s provider seam, so it takes
effect in deployments that wire their **own** `objectql` provider — one that
throws or rejects when the engine cannot be resolved. The **shipped
single-kernel wiring is not yet covered**: the provider `rest-api-plugin.ts`
hands over absorbs the failure one layer earlier (`catch { return undefined }`),
so an engine that fails to resolve still reaches the seam as a *resolved*
`undefined` — indistinguishable there from the supported "no engine wired"
shape — and the package door still answers `403 FORBIDDEN`. That absorb is
filed as #13904; until it lands, deployments on the shipped single-kernel
wiring see **no behaviour change** from this fix.

`RestServer.computeExecCtx` resolved the data engine through the seam helper
that absorbs any failure to `undefined`. So an engine that could not be
**resolved** and an embedder that had wired **no engine at all** arrived at
`resolveAuthzContext` as the same value. The resolver then took `tryFind`'s
`!ql` guard — correctly, for its own contract, because "no engine is wired" is a
supported embedder shape that must keep resolving to an empty-but-valid envelope
— and the package-management door answered `403 FORBIDDEN`: "Reading packages
requires the `studio.access` or `setup.access` capability."

Two different facts had collapsed into one value:

- an embedder that never configured a data plane — zero capabilities is **true**;
- a deployment whose engine resolution **failed** — zero capabilities is **unknown**.

Measured on a real `RestServer` with a real `registerPackageRoutes`. The fault
leg was driven with an injected provider that rejects — the shape a host
wiring its own provider produces, and one the shipped plugin's provider never
does (it absorbs to a resolved `undefined`):

| wiring | before | after |
|:--|:--|:--|
| healthy engine granting the capabilities | 200 | 200 |
| no engine wired at all (supported shape) | 403 | 403 — unchanged |
| own provider: engine resolution throws or rejects | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** |
| shipped single-kernel wiring: failure absorbed to `undefined` (#13904) | 403 | 403 — unchanged, pinned |

The provider branch of the seam now takes the wiring fact from the provider's
**presence** rather than inferring it from what the provider returned, and a
wired seam that fails raises the existing `AuthzStoreUnavailableError`. A
provider that *resolves* `undefined` still means "no engine", quietly and
unchanged — that is the seam contract declaring absence, not failing. That same
quiet path is what keeps the shipped wiring out of reach until #13904: its
provider declares absence where it has only observed failure.

This is **coverage of #13279's already-ruled class**, not a new trade-off. That
ruling (2026-08-30, verbatim 「第一批其余同意」) settled the direction: a
permission-store read that fails must fail loud rather than resolve as an
authenticated principal holding zero capabilities. `tryFind` implemented it for
a read that was issued and threw; an engine that cannot be resolved never issues
a read, so the ruling's landing point could not see it.

⚠️ The direction is **conservative in both readings**: the unknown was already
answered as a refusal (403) and is now answered as the outage it is (503).
Nothing that was refused becomes served, and no route changes who may reach it.

⚠️ Not repaired here, and filed rather than left implicit — the same disguise
survives in two named places:

- **#13904** — the shipped single-kernel provider in `rest-api-plugin.ts`
absorbs resolution failure into a resolved `undefined` one layer before this
seam (the reach boundary above). `ctx.getService` throws for three
distinguishable conditions and only one of them means "no engine is wired",
so which of them the provider should re-raise is its own judgement, filed
rather than folded in.
- **#13905** — the **kernel** branch of the same seam resolves through
`kernel.getServiceAsync('objectql')`, which rejects identically whether the
service was never registered (the supported no-data-plane shape) or was
registered and failed to construct. Separating those two needs the service
registry to stop conflating them, which is a `@objectstack/core` contract
change.

**ADR-0087 disposition.** This change touches no metadata surface in either
direction — no Zod schema, no spec declaration, no authorable key, no stored
row, no object definition — so `objectstack migrate meta` has nothing to visit
and no tombstone exists to mint. `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode` member; `HttpStatusErrorCodeMap` — the status→code map —
already carries `503: 'SERVICE_UNAVAILABLE'`, and the error-code ledger pins
the code→status direction (`SERVICE_UNAVAILABLE: 503`). The wire vocabulary is
unchanged; only which declared code this condition selects.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand Up@@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand DownExpand Up@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(rest): a data engine that cannot be RESOLVED no longer answers 403 FORBIDDEN (#13476) by claude[bot] · Pull Request #13910 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .changeset/engine-unresolvable-fails-loud.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
---
"@objectstack/rest": minor
---

fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — for hosts that wire their own objectql provider (#13476)

Runtime behaviour change on a public REST door, shipped as `minor` under the
repo's launch-window convention — the same convention #13279's changeset names
for the identical class of change.

**Reach — read this first if you are deciding whether this release fixes
*your* 403.** The repair lands at `RestServer`'s provider seam, so it takes
effect in deployments that wire their **own** `objectql` provider — one that
throws or rejects when the engine cannot be resolved. The **shipped
single-kernel wiring is not yet covered**: the provider `rest-api-plugin.ts`
hands over absorbs the failure one layer earlier (`catch { return undefined }`),
so an engine that fails to resolve still reaches the seam as a *resolved*
`undefined` — indistinguishable there from the supported "no engine wired"
shape — and the package door still answers `403 FORBIDDEN`. That absorb is
filed as #13904; until it lands, deployments on the shipped single-kernel
wiring see **no behaviour change** from this fix.

`RestServer.computeExecCtx` resolved the data engine through the seam helper
that absorbs any failure to `undefined`. So an engine that could not be
**resolved** and an embedder that had wired **no engine at all** arrived at
`resolveAuthzContext` as the same value. The resolver then took `tryFind`'s
`!ql` guard — correctly, for its own contract, because "no engine is wired" is a
supported embedder shape that must keep resolving to an empty-but-valid envelope
— and the package-management door answered `403 FORBIDDEN`: "Reading packages
requires the `studio.access` or `setup.access` capability."

Two different facts had collapsed into one value:

- an embedder that never configured a data plane — zero capabilities is **true**;
- a deployment whose engine resolution **failed** — zero capabilities is **unknown**.

Measured on a real `RestServer` with a real `registerPackageRoutes`. The fault
leg was driven with an injected provider that rejects — the shape a host
wiring its own provider produces, and one the shipped plugin's provider never
does (it absorbs to a resolved `undefined`):

| wiring | before | after |
|:--|:--|:--|
| healthy engine granting the capabilities | 200 | 200 |
| no engine wired at all (supported shape) | 403 | 403 — unchanged |
| own provider: engine resolution throws or rejects | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** |
| shipped single-kernel wiring: failure absorbed to `undefined` (#13904) | 403 | 403 — unchanged, pinned |

The provider branch of the seam now takes the wiring fact from the provider's
**presence** rather than inferring it from what the provider returned, and a
wired seam that fails raises the existing `AuthzStoreUnavailableError`. A
provider that *resolves* `undefined` still means "no engine", quietly and
unchanged — that is the seam contract declaring absence, not failing. That same
quiet path is what keeps the shipped wiring out of reach until #13904: its
provider declares absence where it has only observed failure.

This is **coverage of #13279's already-ruled class**, not a new trade-off. That
ruling (2026-08-30, verbatim 「第一批其余同意」) settled the direction: a
permission-store read that fails must fail loud rather than resolve as an
authenticated principal holding zero capabilities. `tryFind` implemented it for
a read that was issued and threw; an engine that cannot be resolved never issues
a read, so the ruling's landing point could not see it.

⚠️ The direction is **conservative in both readings**: the unknown was already
answered as a refusal (403) and is now answered as the outage it is (503).
Nothing that was refused becomes served, and no route changes who may reach it.

⚠️ Not repaired here, and filed rather than left implicit — the same disguise
survives in two named places:

- **#13904** — the shipped single-kernel provider in `rest-api-plugin.ts`
absorbs resolution failure into a resolved `undefined` one layer before this
seam (the reach boundary above). `ctx.getService` throws for three
distinguishable conditions and only one of them means "no engine is wired",
so which of them the provider should re-raise is its own judgement, filed
rather than folded in.
- **#13905** — the **kernel** branch of the same seam resolves through
`kernel.getServiceAsync('objectql')`, which rejects identically whether the
service was never registered (the supported no-data-plane shape) or was
registered and failed to construct. Separating those two needs the service
registry to stop conflating them, which is a `@objectstack/core` contract
change.

**ADR-0087 disposition.** This change touches no metadata surface in either
direction — no Zod schema, no spec declaration, no authorable key, no stored
row, no object definition — so `objectstack migrate meta` has nothing to visit
and no tombstone exists to mint. `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode` member; `HttpStatusErrorCodeMap` — the status→code map —
already carries `503: 'SERVICE_UNAVAILABLE'`, and the error-code ledger pins
the code→status direction (`SERVICE_UNAVAILABLE: 503`). The wire vocabulary is
unchanged; only which declared code this condition selects.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand Up@@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1334` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand DownExpand Up@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1514` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |

---

Expand Down
Loading
Loading