From db51778477abc17e9e6221df95af063bbd1ab543 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:00:08 +0000 Subject: [PATCH 1/4] wip: normalise computeExecCtx provider seams --- packages/rest/src/rest-server.ts | 74 +++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b58d2056f5..c600acc496 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -824,6 +824,68 @@ export interface MountedRoute extends RouteEntry { readonly source: MountedRouteSource; } +/** + * Reach a HOST-WIRED provider seam so that a SYNCHRONOUS throw and a REJECTED + * promise reach the SAME answer. + * + * ## Why this exists + * + * `provider(environmentId).catch(() => undefined)` attaches its handler to the + * promise the call RETURNS, so the handler can only ever see a *rejection*. A + * provider that throws BEFORE returning a promise — an ordinary non-`async` + * function, which the seam's own type (`(environmentId?: string) => + * Promise`) does not and cannot prevent a host from wiring — throws while + * the expression is still being evaluated, so there is no promise to attach to + * and the `.catch` is never reached. The throw escapes to + * {@link RestServer.computeExecCtx}'s outer `catch`, which discards the ENTIRE + * execution context, identity included. + * + * Measured at one and the same seam (`settingsServiceProvider`), on a real + * `RestServer` with a real `registerPackageRoutes`, both callers holding a + * valid session and identical grants — the fault differing ONLY in how the + * provider fails: + * + * | `settingsServiceProvider` | `GET /api/v1/packages` (before) | + * |:--|:--| + * | `async () =>` throws (a rejecting promise) | **200** — caller keeps `manage_metadata` + `studio.access` | + * | `() =>` throws (synchronous) | **401 UNAUTHENTICATED** | + * + * ⇒ the wire answer was decided by whether the host happened to declare its + * provider `async`. That is the defect: not which of the two answers is right, + * but that one fault had two. + * + * ## What this normalisation does and does NOT decide + * + * It makes the SYNCHRONOUS path agree with the path the `.catch` already + * defines — absorb, resolve `undefined`, let the caller degrade. It does NOT + * touch `computeExecCtx`'s outer `catch`, and it does not re-decide whether a + * 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. + * + * ⚠️ `call` is invoked SYNCHRONOUSLY (an async function body runs to its first + * `await` synchronously), so this changes when a provider *fails*, never when + * it is *called* — deliberately not `Promise.resolve().then(call)`, which + * would defer every provider by a microtask for no gain. + * + * ⛔ Not for the seams that carry NO `.catch` at all — `kernelManager + * .getOrCreate()` and `authService.getApi()` lose the context in BOTH + * directions, so they are already symmetric and giving them a swallow would be + * a new policy rather than a normalisation. + */ +async function seamOrUndefined(call: () => T | PromiseLike): Promise { + try { + return await call(); + } catch { + return undefined; + } +} + export class RestServer { private protocol: RestProtocol; private config: NormalizedRestServerConfig; @@ -1978,7 +2040,7 @@ export class RestServer { let authEnvironmentId: string | undefined; if (environmentId && environmentId !== 'platform' && this.kernelManager) { kernel = await this.kernelManager.getOrCreate(environmentId); - authService = await kernel.getServiceAsync('auth').catch(() => undefined); + authService = await seamOrUndefined(() => kernel.getServiceAsync('auth')); if (authService) authEnvironmentId = environmentId; } if (!authService && this.defaultEnvironmentIdProvider && this.kernelManager) { @@ -1986,7 +2048,7 @@ export class RestServer { const def = this.defaultEnvironmentIdProvider(); if (def) { kernel = await this.kernelManager.getOrCreate(def); - authService = await kernel.getServiceAsync('auth').catch(() => undefined); + authService = await seamOrUndefined(() => kernel.getServiceAsync('auth')); // ⚠️ The CROSS-ENVIRONMENT branch. The request resolved to // `environmentId`, but the credential is being checked // against `def`'s auth service — so a session minted in the @@ -2001,7 +2063,7 @@ export class RestServer { // the plugin wired an `authServiceProvider` that hits the // local kernel directly. if (!authService && this.authServiceProvider) { - authService = await this.authServiceProvider(environmentId).catch(() => undefined); + authService = await seamOrUndefined(() => this.authServiceProvider!(environmentId)); // The provider is asked FOR this environment and answers for it // (`rest-api-plugin` wires it to the lone local kernel), so the // credential is anchored where the request resolved. @@ -2036,8 +2098,8 @@ export class RestServer { // Resolve the data engine for this scope (shared by the resolver below). const ql: any = kernel - ? await kernel.getServiceAsync('objectql').catch(() => undefined) - : (this.objectQLProvider ? await this.objectQLProvider(environmentId).catch(() => undefined) : undefined); + ? await seamOrUndefined(() => kernel.getServiceAsync('objectql')) + : (this.objectQLProvider ? await seamOrUndefined(() => this.objectQLProvider!(environmentId)) : undefined); // Delegate ALL identity + role/permission/RLS aggregation to the SINGLE // shared resolver (`resolveAuthzContext`, @objectstack/core) — the same one @@ -2067,7 +2129,7 @@ export class RestServer { if (!authz.userId) return undefined; const settings = this.settingsServiceProvider - ? await this.settingsServiceProvider(environmentId).catch(() => undefined) + ? await seamOrUndefined(() => this.settingsServiceProvider!(environmentId)) : undefined; const localization = await resolveLocalizationContext({ ql, From 66ad42a5a845d2de6623baef50b825eaeb4556b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:04:23 +0000 Subject: [PATCH 2/4] test(rest): invert the seam-asymmetry pins onto the repair --- ...ge-door-execctx-fault-reachability.test.ts | 156 +++++++++++++++--- 1 file changed, 134 insertions(+), 22 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 f95f75d146..168bdb5f7b 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -88,6 +88,33 @@ * 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403. * Both are recorded here as measurements, exactly as before. * + * ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair + * + * Section 7 was filed as a finding of its own: at one and the same provider + * seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole + * execution context — `settingsServiceProvider` answered **200** when it + * returned a rejecting promise and **401** when it threw synchronously, both + * callers holding a valid session and identical grants. The wire answer was + * decided by whether the host happened to declare its provider `async`. + * + * `computeExecCtx` now reaches its seams through `seamOrUndefined` + * (`rest-server.ts`), so a sync throw and a rejection reach the same answer. + * Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded + * text is quoted beside it. Verifying the card's table also turned up a + * SECOND divergent seam it had not measured: `objectQLProvider`, 403 when + * rejecting and 401 when throwing synchronously; it now agrees at 403. + * + * ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`. + * Whether a post-identity fault SHOULD discard identity is a behaviour change + * on a public door — the second of the two directions the finding recorded, + * and still unruled. Normalising the seams is decision-independent: under + * ANY answer to that question, one fault yielding 200 or 401 depending on how + * the host spelled its provider is a defect. + * + * ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2 + * class table — it is no longer a context-lost class. See the block that + * replaces it there before concluding that coverage was dropped. + * * ## Reading discipline * * Every class is driven beside a POSITIVE CONTROL that is the same wiring with @@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [ faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }), ctx: 'lost', read: DENY, write: DENY, }, - { - id: 'SETTINGS_PROVIDER_SYNC_THROW', - what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated', - faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }), - ctx: 'lost', read: DENY, write: DENY, - }, + // ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its + // removal from this table is the repair, not a gap in it. The row read: + // + // id: 'SETTINGS_PROVIDER_SYNC_THROW', + // what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated', + // faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }), + // ctx: 'lost', read: DENY, write: DENY, + // + // i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the + // whole execution context and the authenticated caller was answered 401 — + // while the SAME seam rejecting asynchronously was absorbed and served 200. + // The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the + // sync throw is absorbed exactly as the rejection always was: this is no + // longer a CONTEXT-LOST class at all, and a table of degraded classes is the + // wrong home for it. Its measurement did not disappear — it MOVED to + // section 7, which now pins the two shapes as EQUAL rather than recording + // them as divergent. ⛔ Do not re-add it here to "restore coverage": section + // 6's "no degraded class is ever served" would then be asserting that a + // repaired seam is still broken. { id: 'PERMISSION_STORE_DOWN', what: 'identity resolves, then every permission-store read throws', @@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a }); // --------------------------------------------------------------------------- -// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a -// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole -// identity. Same fault, two different wire answers. +// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and +// now the SAME answer whichever way the provider fails. +// +// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section +// RECORDED a divergence and asserted it, under the heading "sync-throw and +// rejection do not agree": +// +// it('a REJECTING settings provider is absorbed … the caller is still served') +// -> expect(captured.status).toBe(200) +// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401') +// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS) +// +// Both callers held a valid session and identical grants; the wire answer +// was decided by whether the host happened to declare its provider `async`. +// `computeExecCtx` now reaches every one of these seams through +// `seamOrUndefined`, so the two shapes agree — the assertions are inverted +// rather than deleted, which is what keeps this a regression pin on the +// repair instead of a rubber stamp. +// +// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely +// "both are 200". Two of these seams do not agree at 200, and asserting a +// bare equality would let a future blanket-swallow regression — every seam +// degrading to a served 200 — pass this section unchanged. // --------------------------------------------------------------------------- -describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => { - it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => { - const captured = await drive( - mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })), - 'GET', PKGS, - ); - expect(captured.status).toBe(200); +describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => { + /** The same seam, failed both ways; the door's answer to each. */ + const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => { + const rejecting = await drive( + mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS); + const syncThrowing = await drive( + mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS); + return { rejecting, syncThrowing }; + }; + + it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => { + const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider'); + // The agreed value, named: identity survives a settings fault, because + // localization has nothing to do with authorization. + expect(rejecting.status).toBe(200); + expect(syncThrowing.status).toBe(200); + expect(syncThrowing.body?.success).toBe(true); + // ⭐ The card's headline, as an equality rather than a table: 401 vs 200 + // was the defect, and this is the assertion that fails if it returns. + expect(syncThrowing.status).toBe(rejecting.status); }); - it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => { - const captured = await drive( - mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })), - 'GET', PKGS, + it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', 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. + const { rejecting, syncThrowing } = await bothShapes('objectQLProvider'); + expect(rejecting.status).toBe(403); + expect(syncThrowing.status).toBe(403); + expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN'); + expect(syncThrowing.status).toBe(rejecting.status); + }); + + it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => { + // ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its + // `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth + // provider yields `undefined`, and the next line is `if (!authService) + // return undefined`. Pinned precisely because it must NOT move — it is the + // control showing the normalisation did not turn every seam into a 200. + const { rejecting, syncThrowing } = await bothShapes('authServiceProvider'); + expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS); + expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS); + expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); + 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 () => { + // 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. + const answers = await Promise.all( + (['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const) + .map(async (seam) => (await bothShapes(seam)).syncThrowing.status), ); - expect(captured.status).toBe(ANONYMOUS_DENY_STATUS); - expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); + expect(answers).toEqual([200, 403, 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. + 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 d89c570f2b33be215e491e64ffd312fa5f1a56e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:17:23 +0000 Subject: [PATCH 3/4] changeset: patch grade for the seam normalisation --- ...est-provider-seam-sync-throw-normalised.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .changeset/rest-provider-seam-sync-throw-normalised.md diff --git a/.changeset/rest-provider-seam-sync-throw-normalised.md b/.changeset/rest-provider-seam-sync-throw-normalised.md new file mode 100644 index 0000000000..ecd6d0d921 --- /dev/null +++ b/.changeset/rest-provider-seam-sync-throw-normalised.md @@ -0,0 +1,52 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280) + +`RestServer.computeExecCtx` reached its host-wired providers as +`provider(environmentId).catch(() => undefined)`. That handler is attached to +the promise the call RETURNS, so it can only ever see a *rejection*. A provider +that throws BEFORE returning a promise — an ordinary non-`async` function, +which the seam's own type (`(environmentId?: string) => Promise`) cannot +stop a host from wiring — threw while the expression was still being +evaluated, so there was no promise to attach to and the `.catch` was never +reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards +the ENTIRE execution context, identity included. + +Measured on a real `RestServer` with a real `registerPackageRoutes`, both +callers holding a valid session and identical grants, the fault differing ONLY +in how the provider fails: + +| seam | fails as | before | after | +|:--|:--|:--|:--| +| `settingsServiceProvider` | rejecting promise | 200 | 200 | +| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** | +| `objectQLProvider` | rejecting promise | 403 | 403 | +| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** | +| `authServiceProvider` | either | 401 | 401 | + +⇒ the wire answer was decided by whether the host happened to declare its +provider `async`. A localization/settings fault, occurring AFTER identity had +already resolved and having nothing to do with authorization, told an +authenticated administrator "Authentication is required to access this +endpoint." + +`computeExecCtx` now reaches those seams through one helper that invokes the +provider inside a `try`, so a synchronous throw and a rejected promise reach +the same answer. Each seam still degrades according to what it supplies — the +three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned. + +⚠️ This IS an observable wire-behaviour change for one fault shape +(a synchronously-throwing post-identity provider: 401 → 200). It is graded +`patch` because it is a defect repair with no surface change: no export is +added, removed or renamed, no authorable key or schema moves, and the built +`dist/index.d.ts` is byte-identical with and without it (measured by building +the package twice at the same commit; `dist/index.js` differs, which is the +control proving the rebuild saw the change). No host can reasonably have +depended on a settings outage revoking its callers' identity. + +⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a +post-identity fault SHOULD discard identity is a separate, unruled behaviour +decision on a public door; this change only makes the two ways of failing +agree, which is correct under either answer to that question. From 689001179d5d865db799ac6fa88cfb497b61391c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:31:28 +0000 Subject: [PATCH 4/4] docs(permissions): re-anchor the system-context census after the seam helper shifted rest-server.ts by 62 lines --- 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 2242da20b7..69a6ba1621 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:1240`, `:1269`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), 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:1272` | +| 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` | ### 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:4408`, `:5771`, `:6019`, `:6450`, `:6643` | +| 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` | | 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:1451` (#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:1240`, `:1269`; `domains/actions.ts:404` | +| "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` | ---