From 50079b658ea18d9d3de4f03360d9aca7e8f0870c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:02:04 +0000 Subject: [PATCH 1/6] wip(rest): data-engine seam distinguishes unresolvable from unwired (#13476) --- packages/rest/src/rest-server.ts | 115 +++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c600acc496..902319cdf0 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5,6 +5,10 @@ import { // [#13279] Re-raise a permission-store OUTAGE through the fail-closed nets // below instead of degrading it into an anonymous/denied answer. rethrowAuthzStoreUnavailable, + // [#13476] Raised HERE too, at the data-engine seam: an engine that cannot + // be RESOLVED leaves the caller's permissions equally undetermined, so it + // takes the same loud answer rather than the quiet 403 it used to wear. + AuthzStoreUnavailableError, effectiveTenancyPosture, assembleExecutionContext, normalizeAuthGate, type AuthGate, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, @@ -862,11 +866,17 @@ export interface MountedRoute extends RouteEntry { * post-identity fault SHOULD discard identity; that is a behaviour change on a * public door and is deliberately left unruled here. * - * ⚠️ Absorbing here cannot weaken the #13279 loud path: an - * `AuthzStoreUnavailableError` has exactly one construction site - * (`tryFind`, in `@objectstack/core`'s `resolve-authz-context.ts`), which is - * reached from `resolveAuthzContext` — never from a provider seam — so no - * branded outage travels through this helper in either direction. + * ⚠️ Absorbing here cannot weaken the #13279 loud path, but the reason is + * no longer "one construction site" — [#13476] added a SECOND one, at the + * data-engine seam below ({@link wiredEngineOrLoud}). The invariant that + * matters is narrower and is what this helper actually needs: no branded + * outage is ever CONSTRUCTED INSIDE a `seamOrUndefined` call, and none is in + * flight through one. `tryFind`'s (`@objectstack/core`'s + * `resolve-authz-context.ts`) is reached from `resolveAuthzContext`, which + * this helper never wraps; `wiredEngineOrLoud`'s is raised by a DIFFERENT + * helper, deliberately, precisely because this one would swallow it. + * ⛔ Do not route the data-engine seam back through this helper to "make the + * seams uniform" — uniformity there IS the defect #13476 repaired. * * ⚠️ `call` is invoked SYNCHRONOUSLY (an async function body runs to its first * `await` synchronously), so this changes when a provider *fails*, never when @@ -886,6 +896,83 @@ async function seamOrUndefined(call: () => T | PromiseLike): Promise Promise`) declaring absence, not failing. Only a THROW or a REJECTION is + * the outage — which is why this helper, like {@link seamOrUndefined}, invokes + * `call` synchronously so a non-`async` provider that throws before returning a + * promise reaches the same answer as one that rejects (#13280). + * + * ⚠️ RESIDUE, deliberately not repaired here and filed separately — do not + * read this helper as covering it. The KERNEL branch of the seam resolves + * through `kernel.getServiceAsync('objectql')`, which rejects with a bare + * `Error` BOTH when the service was never registered (the supported no-data- + * plane shape) and when it was registered and failed to construct. Those two + * facts are not distinguishable at this transport, so making that branch loud + * would refuse service to a correctly-configured embedder. Separating them + * needs the SERVICE REGISTRY to stop conflating them, which is a + * `@objectstack/core` contract change and its own card. + */ +async function wiredEngineOrLoud( + wired: boolean, + call: () => T | PromiseLike, +): Promise { + if (!wired) return undefined; + try { + return await call(); + } catch (err) { + // The engine is the store the grants live in. It was wired, it was + // asked, and it did not answer — so the permissions were never + // determined. `cause` keeps the driver's own diagnostic, which is the + // part an operator actually needs. + throw new AuthzStoreUnavailableError('objectql', err); + } +} + export class RestServer { private protocol: RestProtocol; private config: NormalizedRestServerConfig; @@ -2097,9 +2184,25 @@ export class RestServer { } // Resolve the data engine for this scope (shared by the resolver below). + // + // [#13476] The PROVIDER branch reaches the seam through + // `wiredEngineOrLoud`, so "no engine is wired" and "the engine could + // not be resolved" stop arriving at `resolveAuthzContext` as the same + // `undefined`. The wiring fact is the provider's PRESENCE — asked + // here, once — and never inferred from what it returned. + // + // ⚠️ The KERNEL branch deliberately still absorbs. Not an oversight + // and not symmetry for its own sake: `getServiceAsync` rejects the + // same way for a service that was never registered as for one that + // failed to construct, so the two facts are not separable at this + // transport and making it loud would refuse every embedder running a + // kernel with no data plane. See `wiredEngineOrLoud`'s RESIDUE note. const ql: any = kernel ? await seamOrUndefined(() => kernel.getServiceAsync('objectql')) - : (this.objectQLProvider ? await seamOrUndefined(() => this.objectQLProvider!(environmentId)) : undefined); + : await wiredEngineOrLoud( + Boolean(this.objectQLProvider), + () => this.objectQLProvider!(environmentId), + ); // Delegate ALL identity + role/permission/RLS aggregation to the SINGLE // shared resolver (`resolveAuthzContext`, @objectstack/core) — the same one From 32d9f686f91d59676416e40425340cac4aa3f80c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:04:18 +0000 Subject: [PATCH 2/6] test(rest): invert DATA_ENGINE_UNRESOLVABLE in place; pin unresolvable vs unwired (#13476) --- ...ge-door-execctx-fault-reachability.test.ts | 166 +++++++++++++++--- 1 file changed, 139 insertions(+), 27 deletions(-) diff --git a/packages/rest/src/package-door-execctx-fault-reachability.test.ts b/packages/rest/src/package-door-execctx-fault-reachability.test.ts index 168bdb5f7b..1f3b0f99ea 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -29,7 +29,8 @@ * anonymous floor decides: **401 `UNAUTHENTICATED`**, "Authentication is * required to access this endpoint." The caller may hold a valid session; * the fault is elsewhere. - * - `GRANTS LOST` (two classes) — identity survives and the CAPABILITY + * - `GRANTS LOST` (two classes, ⭐ both since repaired — #13279 and + * [#13476]) — identity survives and the CAPABILITY * aggregation is what faulted, so the door answered **403 `FORBIDDEN`**, * "Reading packages requires the `studio.access` or `setup.access` * capability." An authenticated administrator was told they lack a @@ -45,12 +46,24 @@ * `PERMISSION_STORE_DOWN` class therefore answers **503 * `SERVICE_UNAVAILABLE`** and is no longer byte-identical to its innocent * twin — section 5's assertion is INVERTED IN PLACE, with the superseded - * text quoted beside it. ⚠️ The SECOND grants-lost class, - * `DATA_ENGINE_UNRESOLVABLE`, is UNCHANGED and still answers 403: it - * reaches an empty grant set through `tryFind`'s `!ql` guard (there is no - * engine to read) rather than through its `catch` (a read that failed), so - * the ruling's landing point does not see it. That residue is asserted - * explicitly in section 3 rather than left to be rediscovered. + * text quoted beside it. + * + * ⭐ **[#13476] The SECOND grants-lost class is now repaired too, and the + * GRANTS-LOST disguise has no members left on this door.** The superseded + * text read: "`DATA_ENGINE_UNRESOLVABLE` is UNCHANGED and still answers + * 403: it reaches an empty grant set through `tryFind`'s `!ql` guard + * (there is no engine to read) rather than through its `catch` (a read + * that failed), so the ruling's landing point does not see it." That was + * COVERAGE of an already-ruled class rather than a fresh trade-off: an + * engine that cannot be RESOLVED leaves the caller's permissions just as + * undetermined as a read that threw, so it takes the same 503. The seam + * that used to collapse the two facts is `wiredEngineOrLoud` + * (`rest-server.ts`). + * + * ⚠️ What did NOT change, and is pinned so it cannot: an embedder that + * wires NO engine at all is a supported shape and still resolves quietly + * to an empty-but-valid envelope, answered 403. "Unresolvable" and + * "unwired" are now two answers; section 3 drives both side by side. * - **Is a fault ever served as ANONYMOUS ACCESS, or as a silent success? NO.** * Every degraded class is REFUSED on every wire-reachable method of all * four routes. The swallow fails CLOSED. (Section 6.) @@ -85,8 +98,10 @@ * * ⛔ The other half is still not repaired and still not asserted as a verdict: * `CONTEXT LOST` remains byte-identical to a genuine anonymous caller (section - * 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403. - * Both are recorded here as measurements, exactly as before. + * 5's first case is unchanged) and is recorded here as a measurement, exactly + * as before. ⭐ [#13476] `DATA_ENGINE_UNRESOLVABLE` is no longer the second + * half of that sentence — it is repaired, and its assertions are regression + * pins now rather than measurements of a defect. * * ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair * @@ -396,7 +411,22 @@ const CLASSES: FaultClass[] = [ id: 'DATA_ENGINE_UNRESOLVABLE', what: 'identity resolves, then the data engine cannot be resolved at all', faulted: () => ({ ...healthy(), objectQLProvider: async () => { throw new Error('datasource unavailable'); } }), - ctx: 'grants', read: FORBID, write: FORBID, + // ⭐ [#13476] INVERTED IN PLACE, not re-baselined. Until this card the row + // read `ctx: 'grants', read: FORBID, write: FORBID` — an authenticated + // administrator whose engine was simply GONE, told they lack a capability. + // It was the LAST surviving member of the GRANTS-LOST disguise on this + // door: #13279 made a read that was ISSUED and threw fail loud, and this + // class never issues a read, so that ruling's landing point could not see + // it. The engine seam now keeps "no engine is wired" and "the engine could + // not be resolved" apart (`wiredEngineOrLoud`, `rest-server.ts`), so this + // class takes the SAME loud answer #13279 chose, for the same reason: + // nothing was read, so no capability judgement was ever reached. + // + // ⚠️ Its innocent twin is NOT here and must never be: an embedder that + // wires no engine at all is a SUPPORTED shape and still resolves quietly to + // an empty-but-valid envelope (403). Section 3's dedicated pin drives both + // and asserts they now DIFFER — that pair is the whole card. + ctx: 'loud', read: UNAVAILABLE, write: UNAVAILABLE, }, ]; @@ -492,15 +522,75 @@ describe('[#13255] consequence — the door\'s answer for each fault class', () // taught to be loud while its siblings kept the disguise. expect(loud.length).toBeGreaterThan(0); expect(loud.every((s) => s === AUTHZ_STORE_UNAVAILABLE_STATUS)).toBe(true); - // ⚠️ The classes the ruling did NOT reach still answer a refusal, and that - // residue is deliberately left visible rather than asserted away: - // `DATA_ENGINE_UNRESOLVABLE` reaches an empty grant set through `tryFind`'s - // `!ql` guard (no engine to read) rather than through its `catch` (a read - // that failed), so the ruling's landing point does not see it. + // ⚠️ [#13476] The residue this comment used to record is GONE, and the + // superseded text is kept so the change is legible rather than silent: + // + // > `DATA_ENGINE_UNRESOLVABLE` reaches an empty grant set through + // > `tryFind`'s `!ql` guard (no engine to read) rather than through its + // > `catch` (a read that failed), so the ruling's landing point does not + // > see it. + // + // That class is now in the `loud` cohort above. What remains `quiet` is the + // CONTEXT-LOST family (#13255), still unruled and still measured, never + // asserted away — so this half keeps its original reading and this test + // stays a regression pin rather than a rubber stamp. expect(quiet.filter((s) => s >= 500)).toEqual([]); expect(quiet.every((s) => s === ANONYMOUS_DENY_STATUS || s === 403)).toBe(true); }); + it('⭐ [#13476] UNRESOLVABLE vs UNWIRED — the two facts the door used to answer identically', async () => { + // THE card, as one assertion. Both wirings reach `resolveAuthzContext` with + // no usable engine; only one of them is a fact the door may report as a + // capability verdict. + // + // - UNWIRED — the embedder never configured a data plane. "This caller + // holds nothing" is TRUE. A supported shape; stays quiet. + // - FAILED — the engine was wired and could not be resolved. "This + // caller holds nothing" is UNKNOWN, and was being asserted. + const unwired = await drive(mount(serverWith({ authServiceProvider: AUTH_OK })), 'GET', PKGS); + const failed = await drive( + mount(serverWith({ ...healthy(), objectQLProvider: async () => { throw new Error('datasource unavailable'); } })), + 'GET', PKGS, + ); + + // The repair: the answers DIFFER. Before this card both were + // `403 FORBIDDEN` with a byte-identical body. + expect(failed.status).not.toBe(unwired.status); + expect(JSON.stringify(failed.body)).not.toEqual(JSON.stringify(unwired.body)); + + // …and each is the RIGHT one, named — a bare inequality would also be + // satisfied by breaking the innocent shape instead of repairing the guilty. + expect(failed.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect(failed.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); + expect(unwired.status).toBe(403); + expect(unwired.body?.error?.code).toBe('FORBIDDEN'); + + // ⭐ POSITIVE CONTROL on the UNWIRED leg specifically. Its 403 must still be + // the CAPABILITY refusal an authenticated caller gets, not a fault wearing + // the same number: an unwired embedder still RESOLVES an identity. + const ctx = await serverWith({ authServiceProvider: AUTH_OK }) + .resolvePackageRouteExecutionContext({ params: {}, headers: {}, method: 'GET', path: PKGS }); + expect(ctx?.userId).toBe('u_admin'); + expect(ctx?.systemPermissions ?? []).toEqual([]); + + // ⭐ CONTROL that the harness can still produce the SERVED answer, so the + // two refusals above are read as caused by their faults. + expect((await drive(mount(serverWith(healthy())), 'GET', PKGS)).status).toBe(200); + }); + + it('⭐ [#13476] a provider that RESOLVES `undefined` is "no engine", not a fault', async () => { + // The seam contract is `(environmentId?) => Promise`. + // A provider DECLARING absence must stay on the quiet path — otherwise the + // repair would refuse service to every embedder that wires a provider and + // legitimately has no engine for this environment. ⚠️ This is the pin that + // fails if `wiredEngineOrLoud` is ever "simplified" into treating any falsy + // resolution as a failure. + const captured = await drive( + mount(serverWith({ ...healthy(), objectQLProvider: async () => undefined })), 'GET', PKGS); + expect(captured.status).toBe(403); + expect(captured.body?.error?.code).toBe('FORBIDDEN'); + }); + it('the capability clause, isolated from the anonymous floor, refuses the lost context too', async () => { // `method: 'OPTIONS'` is the one input that makes `shouldDenyAnonymous` // yield without authenticating — used here ONLY to separate the two @@ -705,15 +795,24 @@ describe('[#13280] at a post-identity provider seam, sync-throw and rejection AG expect(syncThrowing.status).toBe(rejecting.status); }); - it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => { + it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 503', async () => { // [#13280] Not in the card's table, found while verifying it: this seam - // diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the - // engine is unresolvable either way, so the caller reaches an EMPTY grant - // set and is refused on capability, NOT on identity. + // diverged too, 403 (reject) vs 401 (sync throw). It agrees — and ⭐ + // [#13476] MOVED THE AGREED VALUE, 403 → 503. The superseded reading: + // + // > It agrees at 403 — the engine is unresolvable either way, so the + // > caller reaches an EMPTY grant set and is refused on capability, NOT + // > on identity. + // + // "Reaches an EMPTY grant set" was the defect: a WIRED engine that failed + // is not an empty grant set, it is an UNDETERMINED one. Both shapes are now + // the outage they are. ⚠️ #13280's property is untouched and is what this + // test still exists for — the two shapes AGREE; only the value they agree + // on moved, and it moved for both together. const { rejecting, syncThrowing } = await bothShapes('objectQLProvider'); - expect(rejecting.status).toBe(403); - expect(syncThrowing.status).toBe(403); - expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN'); + expect(rejecting.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect(syncThrowing.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect(syncThrowing.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); expect(syncThrowing.status).toBe(rejecting.status); }); @@ -730,24 +829,37 @@ describe('[#13280] at a post-identity provider seam, sync-throw and rejection AG expect(syncThrowing.status).toBe(rejecting.status); }); - it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => { + it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 503 / 401, so agreement is not a blanket swallow', async () => { // The guard against the rival repair. "Every seam absorbs everything" // would satisfy each per-seam pin above; it would NOT satisfy this. Each // seam still degrades according to what it supplies. + // + // ⭐ [#13476] The middle value moved, 403 → 503; the PROPERTY this test + // asserts did not. Superseded: `toEqual([200, 403, ANONYMOUS_DENY_STATUS])`. + // ⚠️ A blanket-loud regression — every seam raising the outage — is now as + // much a hazard as the blanket swallow, and this same three-way inequality + // catches both: settings must still be SERVED and auth must still be 401. const answers = await Promise.all( (['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const) .map(async (seam) => (await bothShapes(seam)).syncThrowing.status), ); - expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]); + expect(answers).toEqual([200, AUTHZ_STORE_UNAVAILABLE_STATUS, ANONYMOUS_DENY_STATUS]); expect(new Set(answers).size).toBe(3); }); it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => { // The regression that would matter most: `seamOrUndefined` swallows at the // seam, so a reader must be able to see that the branded outage still - // travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind` - // inside `resolveAuthzContext`, downstream of every seam here, so no - // normalised seam is on its path. + // travels. It does — this one is raised by `tryFind` inside + // `resolveAuthzContext`, downstream of every seam here, so no normalised + // seam is on its path. + // + // ⚠️ [#13476] `AuthzStoreUnavailableError` now has a SECOND raise site, at + // the data-engine seam itself (`wiredEngineOrLoud`). That does not weaken + // this pin: this case fails the store with a REACHABLE engine whose reads + // throw (`qlDown`), so the engine seam RESOLVES here and the error can only + // have come from `tryFind`. The two raise sites are driven apart by the + // section-3 pin, which fails the ENGINE instead of the reads. const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS); expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); From b8c2612d05dd0a35b9232bc3cb3c880f8b59b6db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:18:15 +0000 Subject: [PATCH 3/6] docs(changeset): engine resolution failure fails loud (#13476) --- .changeset/engine-unresolvable-fails-loud.md | 65 ++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .changeset/engine-unresolvable-fails-loud.md diff --git a/.changeset/engine-unresolvable-fails-loud.md b/.changeset/engine-unresolvable-fails-loud.md new file mode 100644 index 0000000000..910d980b03 --- /dev/null +++ b/.changeset/engine-unresolvable-fails-loud.md @@ -0,0 +1,65 @@ +--- +"@objectstack/rest": minor +--- + +fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — the last surviving GRANTS-LOST disguise at the package door (#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. + +`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`, wired the +way `rest-api-plugin.ts` wires it: + +| wiring | before | after | +|:--|:--|:--| +| healthy engine granting the capabilities | 200 | 200 | +| no engine wired at all (supported shape) | 403 | 403 — unchanged | +| the engine cannot be resolved | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** | + +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. + +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 separately rather than left implicit: 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 +and its own card. + +**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 and `HttpStatusErrorCodeMap` already maps it to 503, +so the wire vocabulary is unchanged; only which declared code this condition +selects. From 52b9017fecdabc6fa1494e5f4a76dc7fee62529e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:23:48 +0000 Subject: [PATCH 4/6] docs(permissions): re-anchor the system-context census after the rest-server line shift (#13476) --- content/docs/permissions/system-context.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 4d3290fefa..97f6ba643a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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: @@ -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 @@ -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` | @@ -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` | --- From c5908bf6592820d0274933079745b711a82a1099 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:38:51 +0000 Subject: [PATCH 5/6] test(rest): assert the unwired refusal through the shared FORBID constant (#13476) --- ...kage-door-execctx-fault-reachability.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/rest/src/package-door-execctx-fault-reachability.test.ts b/packages/rest/src/package-door-execctx-fault-reachability.test.ts index 1f3b0f99ea..7f4fa27de6 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -336,6 +336,15 @@ interface FaultClass { } const DENY = { status: ANONYMOUS_DENY_STATUS, code: ANONYMOUS_DENY_CODE }; +/** + * The capability refusal. ⭐ [#13476] No fault CLASS carries this any more — the + * two that did are both repaired (#13279, #13476) — but it is deliberately kept + * rather than deleted: it is now the answer the INNOCENT shapes must keep, and + * section 3's `UNRESOLVABLE vs UNWIRED` pin asserts it through this constant so + * "what an unwired embedder gets" and "what a genuine capability denial gets" + * stay one spelling. ⛔ Do not inline it back into literals — that is how the + * two answers drift apart unnoticed. + */ const FORBID = { status: 403, code: 'FORBIDDEN' }; /** * [#13279] The LOUD cohort — a permission-store outage, answered as the outage @@ -562,8 +571,8 @@ describe('[#13255] consequence — the door\'s answer for each fault class', () // satisfied by breaking the innocent shape instead of repairing the guilty. expect(failed.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); expect(failed.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); - expect(unwired.status).toBe(403); - expect(unwired.body?.error?.code).toBe('FORBIDDEN'); + expect(unwired.status).toBe(FORBID.status); + expect(unwired.body?.error?.code).toBe(FORBID.code); // ⭐ POSITIVE CONTROL on the UNWIRED leg specifically. Its 403 must still be // the CAPABILITY refusal an authenticated caller gets, not a fault wearing @@ -587,8 +596,8 @@ describe('[#13255] consequence — the door\'s answer for each fault class', () // resolution as a failure. const captured = await drive( mount(serverWith({ ...healthy(), objectQLProvider: async () => undefined })), 'GET', PKGS); - expect(captured.status).toBe(403); - expect(captured.body?.error?.code).toBe('FORBIDDEN'); + expect(captured.status).toBe(FORBID.status); + expect(captured.body?.error?.code).toBe(FORBID.code); }); it('the capability clause, isolated from the anonymous floor, refuses the lost context too', async () => { From 701af469a615d9d65c1e4ef6f5bfce6b2e0af1d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 23:54:29 +0000 Subject: [PATCH 6/6] =?UTF-8?q?docs(changeset):=20scope=20the=20fix's=20re?= =?UTF-8?q?ach=20=E2=80=94=20own-provider=20hosts=20only,=20shipped=20wiri?= =?UTF-8?q?ng=20still=20403=20until=20#13904=20(#13476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The at-tier contract review (PR #13910) found the changeset overstating reach: it declared the disguise ended, named only the kernel-branch residue, and 'wired the way rest-api-plugin.ts wires it' read as shipped-path coverage while the shipped provider absorbs resolution failure to a resolved `undefined` (rest-api-plugin.ts:289) that the seam — by pinned contract — keeps quiet at 403. - lead with the reach boundary: repair is live for hosts wiring their own objectql provider; shipped single-kernel wiring sees no change until #13904 - name both residues by number: #13904 (shipped provider absorb) and #13905 (kernel-branch registry conflation) - add the shipped-wiring row to the measurement table and drop the misleading wiring phrase - state HttpStatusErrorCodeMap in its real direction (status→code, 503: 'SERVICE_UNAVAILABLE'; the code→status pin is the ledger's SERVICE_UNAVAILABLE: 503) No code, test, or version-grade change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk --- .changeset/engine-unresolvable-fails-loud.md | 56 ++++++++++++++------ 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/.changeset/engine-unresolvable-fails-loud.md b/.changeset/engine-unresolvable-fails-loud.md index 910d980b03..1e1b44e378 100644 --- a/.changeset/engine-unresolvable-fails-loud.md +++ b/.changeset/engine-unresolvable-fails-loud.md @@ -2,12 +2,24 @@ "@objectstack/rest": minor --- -fix(rest): a data engine that cannot be RESOLVED no longer answers `403 FORBIDDEN` — the last surviving GRANTS-LOST disguise at the package door (#13476) +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 @@ -22,20 +34,25 @@ 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`, wired the -way `rest-api-plugin.ts` wires it: +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 | -| the engine cannot be resolved | **403 FORBIDDEN** | **503 SERVICE_UNAVAILABLE** | +| 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. +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 @@ -48,18 +65,27 @@ a read, so the ruling's landing point could not see it. 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 separately rather than left implicit: 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 -and its own card. +⚠️ 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 and `HttpStatusErrorCodeMap` already maps it to 503, -so the wire vocabulary is unchanged; only which declared code this condition -selects. +`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.