Skip to content

Commit 4af758d

Browse files
claude[bot]claude
andauthored
refactor(runtime,mcp): the last two admission doors classify the tenancy rejection through the shared classifier (#17683)
* refactor(runtime,mcp): fold the last two admission tenancy-posture copies onto the shared classifier WIP checkpoint before the verification lap. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> * chore: changeset for the admission tenancy-posture fold Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cea85fd commit 4af758d

5 files changed

Lines changed: 242 additions & 36 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/mcp': patch
4+
---
5+
6+
refactor(runtime,mcp): the last two admission doors classify the `tenancy` rejection through the shared `classifyAdmissionTenancyPosture` (#17114)
7+
8+
`@objectstack/core`'s `classifyAdmissionTenancyPosture` is the one place the
9+
#13906 decision 1 option A classification lives: a branded "never registered"
10+
rejection is the supported no-tenancy composition and answers a quiet
11+
`undefined`, while every other rejection becomes
12+
`AuthzStoreUnavailableError('tenancy', err)` — ADR-0112 `SERVICE_UNAVAILABLE` /
13+
503 — because the posture is an authorization INPUT and admission was never
14+
decided.
15+
16+
Two admission doors were still hand-writing that classification, out of the
17+
declared scope of the fold that extracted it:
18+
19+
- `@objectstack/runtime`'s `resolveExecutionContext` — the REST/dispatcher
20+
entry-point identity resolver;
21+
- `@objectstack/mcp`'s `resolveStdioTenancyPosture` — the stdio door's **async
22+
kernel** leg.
23+
24+
Both now call the shared function. ⛔ **No behaviour changes at either door.**
25+
Tenancy posture decides which rows a caller may see, so a divergence between
26+
copies would be two answers to "whose data is this", and the copies are the
27+
stale ones by construction — the shared version is the one that will be
28+
maintained.
29+
30+
**The resolution stayed at each seam, deliberately.** The extractable part is
31+
the classification, not the resolution: each door keeps its own accessor guard
32+
and hands its own former accessor expression in as the thunk, so the helper
33+
never learns *how* a seam reaches the service. A helper that owned the wiring
34+
too would be wrong for one seam or grow a flag per seam.
35+
36+
**One neighbouring leg is deliberately NOT folded.** The stdio door's **sync**
37+
fallback is taken only on a `KernelBase`-shaped host with no `getServiceAsync`,
38+
whose accessor reports its one possible fault — nothing registered under that
39+
name — **unbranded**. Routing it through the shared classification would mint a
40+
503 outage out of a supported composition, so its bare `catch` remains that
41+
seam's recorded decision. A test arm now fails if that leg is ever folded.
42+
43+
Shipped rather than `skip-changeset`: both packages publish `files[]: ["dist"]`,
44+
and the built `dist` of each carries the new call (2 files each, measured after
45+
a real build, with a symbol known-absent scoring 0 and
46+
`isServiceNotRegisteredError` scoring 4 in `runtime/dist` as the lit control).
47+
`@objectstack/mcp`'s `dist` no longer mentions `isServiceNotRegisteredError` at
48+
all.

packages/mcp/src/plugin.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import {
55
assembleExecutionContext,
66
resolveAuthzContext,
77
resolveLocalizationContext,
8-
// [#15348] The three symbols this door's tenancy-posture read is built from:
9-
// the posture reader itself, plus the two halves of the classification
10-
// decision 1 option A requires (#13906) — the registry's "never registered"
11-
// brand, and the loud outage every other rejection has to become.
8+
// [#15348 / #16013 / #17114] The two symbols this door's tenancy-posture
9+
// read is built from. `classifyAdmissionTenancyPosture` is the ONE shared
10+
// classification decision 1 option A requires (#13906) — branded "never
11+
// registered" ⇒ quiet `undefined`, every other rejection ⇒ the loud
12+
// `AuthzStoreUnavailableError('tenancy', err)` — and it is reachable only
13+
// from the ASYNC accessor, which is the only leg that raises the brand.
14+
// `effectiveTenancyPosture` stays because the SYNC leg below is a
15+
// deliberately different shape, not a copy of that classification.
16+
classifyAdmissionTenancyPosture,
1217
effectiveTenancyPosture,
13-
isServiceNotRegisteredError,
14-
AuthzStoreUnavailableError,
1518
type EntryLocalization,
1619
type TenancyPostureSource,
1720
} from '@objectstack/core';
@@ -61,14 +64,24 @@ import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';
6164
* admitting on it is exactly the permissive-on-failure defect #13906 exists
6265
* to repair, and the reason this seam is not a one-liner.
6366
*
67+
* [#17114] That classification is no longer hand-written here — it is
68+
* `classifyAdmissionTenancyPosture` (`@objectstack/core`), the shared function
69+
* #16013 extracted and which this seam was one of the two left outside. ⛔ The
70+
* RESOLUTION is NOT shared: which of this door's two accessors may be asked is
71+
* this file's own fact (the `getServiceAsync` presence test below), so the
72+
* accessor expression is handed in as a thunk and the helper never learns it.
73+
*
6474
* Only the ASYNC accessor carries that discriminator — the branded rejection is
6575
* raised by `PluginLoader.getService`, which the sync accessor never reaches.
66-
* The sync leg below is taken only on a host whose `getKernel()` yields no
67-
* `getServiceAsync` (a `KernelBase`-shaped host, and the duck-typed contexts
68-
* this package's own tests build). Such a host instantiates no service
69-
* factories at all, so "nothing is registered under that name" is the only
70-
* fault its accessor can report, and absorbing it is the SAME classification
71-
* rather than a second collapse of it.
76+
* ⛔ **So only the async leg is the shared classification.** The sync leg below
77+
* is taken only on a host whose `getKernel()` yields no `getServiceAsync` (a
78+
* `KernelBase`-shaped host, and the duck-typed contexts this package's own
79+
* tests build). Such a host instantiates no service factories at all, so
80+
* "nothing is registered under that name" is the only fault its accessor can
81+
* report, and absorbing it is the SAME classification rather than a second
82+
* collapse of it — which is why its bare `catch` is this seam's recorded
83+
* decision and ⛔ must NOT be folded onto the helper. Routing it there would
84+
* mint a 503 outage out of the one fault that host shape can report.
7285
*
7386
* ## ⚠️ Read PER CALL — deliberately not hoisted into `start()`
7487
*
@@ -90,13 +103,12 @@ import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';
90103
async function resolveStdioTenancyPosture(ctx: PluginContext): Promise<TenancyPosture | undefined> {
91104
const kernel = typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined;
92105
if (kernel && typeof kernel.getServiceAsync === 'function') {
93-
try {
94-
return effectiveTenancyPosture(await kernel.getServiceAsync<TenancyPostureSource>('tenancy'));
95-
} catch (err) {
96-
if (!isServiceNotRegisteredError(err)) throw new AuthzStoreUnavailableError('tenancy', err);
97-
return undefined;
98-
}
106+
return classifyAdmissionTenancyPosture(
107+
() => kernel.getServiceAsync<TenancyPostureSource>('tenancy'),
108+
);
99109
}
110+
// ⛔ NOT the classification above — see the "only the async leg" paragraph in
111+
// this function's doc comment. This leg's bare `catch` is its decision.
100112
try {
101113
return effectiveTenancyPosture(ctx.getService<TenancyPostureSource>('tenancy'));
102114
} catch {

packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,3 +564,91 @@ describe('[#15348] §5 — the posture and the membership are both re-read per c
564564
await expect(getRecord(OBJECT, 'u_a1')).rejects.toThrow(/no longer valid/);
565565
});
566566
});
567+
568+
// ---------------------------------------------------------------------------
569+
// §6 [#17114] — the fold onto `classifyAdmissionTenancyPosture`, measured at
570+
// the two edges a fold can get wrong.
571+
//
572+
// §1 and §4 already pin the discrimination itself on the registry's own
573+
// rejections; what these arms pin is that the fold moved the CLASSIFICATION
574+
// and nothing else:
575+
//
576+
// - the accessor is invoked INSIDE the shared classification, so a
577+
// `getServiceAsync` that throws SYNCHRONOUSLY is classified exactly as its
578+
// rejecting twin. A fold that resolved the service FIRST and handed the
579+
// helper a settled value would need a `catch` of its own to get there —
580+
// the per-seam copy this card deletes — and this arm is where that shows;
581+
// - ⛔ the SYNC leg is NOT folded. Its host shape (`KernelBase` — no
582+
// `getServiceAsync` at all) reports its one fault UNBRANDED, so routing it
583+
// through the shared classification would mint a 503 outage out of a
584+
// supported composition. That is the fence the card records, as a measured
585+
// arm rather than a comment.
586+
//
587+
// Both rejection values are read out of a REAL kernel and then re-raised, so
588+
// neither arm is a hand-built brand at the seam under measurement (fixture
589+
// principle 4 above).
590+
// ---------------------------------------------------------------------------
591+
592+
describe('[#17114] §6 — the classification is shared; the resolution is still this door\'s', () => {
593+
/** Settle a real registry read to its rejection VALUE. */
594+
const rejectionValue = (wiring: TenancyWiring): Promise<unknown> =>
595+
makeKernel(makeFixture().engine, wiring)
596+
.getServiceAsync('tenancy')
597+
.then(() => undefined, (e: unknown) => e);
598+
599+
/**
600+
* Boot with a host whose `getKernel()` answers a facade. Only
601+
* `getServiceAsync`'s PRESENCE is read by the seam (`plugin.ts` calls
602+
* `ctx.getKernel()` at exactly one place), so the facade carries the sync
603+
* accessor plus whichever async shape the arm is measuring.
604+
*/
605+
async function bootWithHost(
606+
rawKey: string,
607+
host: (kernel: ObjectKernel) => Record<string, unknown>,
608+
) {
609+
process.env.OS_MCP_STDIO_API_KEY = rawKey;
610+
const fixture = makeFixture();
611+
const kernel = makeKernel(fixture.engine, { kind: 'unregistered' });
612+
const ctx = { ...makeCtx(kernel), getKernel: vi.fn(() => host(kernel)) };
613+
return { fixture, start: () => startStdio(ctx) };
614+
}
615+
616+
it('a `getServiceAsync` that throws the branded rejection SYNCHRONOUSLY stays quiet — same answer as the rejected one', async () => {
617+
const branded = await rejectionValue({ kind: 'unregistered' });
618+
expect(isServiceNotRegisteredError(branded), 'fixture: the registry did not brand its miss').toBe(true);
619+
const h = await bootWithHost(RAW_EXMEMBER_KEY, (kernel) => ({
620+
getService: <T>(name: string): T => kernel.getService<T>(name),
621+
getServiceAsync: () => { throw branded; },
622+
}));
623+
// The §4 CONTRAST answer, reached through a synchronous throw.
624+
const { bridge } = await h.start();
625+
expect((await readAll(bridge)).total).toBe(2);
626+
});
627+
628+
it('a `getServiceAsync` that throws an UNBRANDED failure SYNCHRONOUSLY is the 503 outage, not a quiet admit', async () => {
629+
const unbranded = await rejectionValue({ kind: 'factory-throws' });
630+
expect(isServiceNotRegisteredError(unbranded), 'fixture: the failed build was branded').toBe(false);
631+
const h = await bootWithHost(RAW_EXMEMBER_KEY, (kernel) => ({
632+
getService: <T>(name: string): T => kernel.getService<T>(name),
633+
getServiceAsync: () => { throw unbranded; },
634+
}));
635+
const err = await h.start().then(() => undefined, (e) => e);
636+
expect(err, 'the door STARTED — a synchronous outage escaped the classification').toBeInstanceOf(Error);
637+
expect((err as { code?: unknown }).code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
638+
expect((err as { status?: unknown }).status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
639+
expect((err as { object?: unknown }).object).toBe('tenancy');
640+
});
641+
642+
it('⛔ FENCE: a host with NO `getServiceAsync` takes the SYNC leg and stays quiet on its UNBRANDED miss', async () => {
643+
// `ObjectKernelBase`'s sync accessor throws a plain `[Kernel] Service
644+
// 'tenancy' not found` — unbranded, because it never reaches
645+
// `PluginLoader.getService`. Folding this leg onto the shared
646+
// classification would turn that one reportable fault into a 503 and break
647+
// every embedder on a `KernelBase`-shaped host. This arm reddens if it is.
648+
const h = await bootWithHost(RAW_EXMEMBER_KEY, (kernel) => ({
649+
getService: <T>(name: string): T => kernel.getService<T>(name),
650+
}));
651+
const { bridge } = await h.start();
652+
expect((await readAll(bridge)).total).toBe(2);
653+
});
654+
});

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44

5-
import { ObjectKernel, isAuthzStoreUnavailableError } from '@objectstack/core';
5+
import { ObjectKernel, isAuthzStoreUnavailableError, isServiceNotRegisteredError } from '@objectstack/core';
66

77
import { resolveExecutionContext } from './resolve-execution-context.js';
88
import { hashApiKey } from './api-key.js';
@@ -739,4 +739,61 @@ describe('[#13906 decision 1 A, runtime door] the tenancy posture seam tells "ne
739739
});
740740
expect(ctx.userId).toBe('u_exmember');
741741
});
742+
743+
// -------------------------------------------------------------------------
744+
// [#17114] The classification above is now `classifyAdmissionTenancyPosture`
745+
// (`@objectstack/core`) rather than a hand-written copy of it — one of the
746+
// two seams #16013 left behind. The RESOLUTION stayed here: this facade's
747+
// `opts.getService` is handed in as the thunk.
748+
//
749+
// These two arms are the pins the WRONG fold fails. A fold that awaited the
750+
// service OUTSIDE the shared classification — resolving first and handing it
751+
// an already-settled value — would need a `catch` of its own to get there,
752+
// which is the per-seam copy this card deletes. The observable difference is
753+
// a lookup that throws SYNCHRONOUSLY: `KernelServiceLookup` declares
754+
// `Promise<any> | any`, so a facade that throws rather than rejecting is
755+
// within its contract, and both rejection classes must classify exactly as
756+
// their asynchronous twins above do.
757+
//
758+
// Both errors are the REGISTRY's own — read out of a real kernel and then
759+
// re-raised synchronously — so neither arm is the fixture asserting itself.
760+
// -------------------------------------------------------------------------
761+
762+
/** The registry's own branded "never registered" rejection, as a value. */
763+
const brandedNotRegistered = async (): Promise<unknown> =>
764+
kernelWith('unregistered').getServiceAsync('tenancy').then(() => undefined, (e) => e);
765+
766+
/** The registry's own UNBRANDED rejection for a factory that threw. */
767+
const unbrandedBuildFailure = async (): Promise<unknown> =>
768+
kernelWith('factory-throws').getServiceAsync('tenancy').then(() => undefined, (e) => e);
769+
770+
const throwingSync = (err: unknown, headers: Record<string, string>) => ({
771+
getService: (name: string) => {
772+
if (name === 'tenancy') throw err;
773+
return undefined;
774+
},
775+
getQl: async () => qlWith(),
776+
request: { headers },
777+
});
778+
779+
it('[#17114] a SYNCHRONOUSLY thrown branded "never registered" is absorbed exactly like the rejected one — quiet, admitted', async () => {
780+
const branded = await brandedNotRegistered();
781+
expect(isServiceNotRegisteredError(branded), 'fixture: the kernel did not brand its miss').toBe(true);
782+
const ctx = await resolveExecutionContext(throwingSync(branded, { 'x-api-key': RAW_EXMEMBER }) as any);
783+
expect(ctx.userId).toBe('u_exmember');
784+
expect(ctx.tenantId).toBe('org_A');
785+
});
786+
787+
it('[#17114] and a SYNCHRONOUSLY thrown UNBRANDED failure is the 503 outage — the two still answer differently', async () => {
788+
const unbranded = await unbrandedBuildFailure();
789+
expect(isServiceNotRegisteredError(unbranded), 'fixture: the failed build was branded').toBe(false);
790+
const err: any = await rejectionOf(
791+
resolveExecutionContext(throwingSync(unbranded, { 'x-api-key': RAW_EXMEMBER }) as any),
792+
);
793+
expect(err, 'the resolver RESOLVED — a synchronous outage escaped the classification').toBeDefined();
794+
expect(isAuthzStoreUnavailableError(err)).toBe(true);
795+
expect(err.code).toBe('SERVICE_UNAVAILABLE');
796+
expect(err.status).toBe(503);
797+
expect(err.object).toBe('tenancy');
798+
});
742799
});

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,13 @@ import {
3535
resolveLocalizationContext,
3636
assembleExecutionContextOrGuest,
3737
type EntryLocalization,
38-
effectiveTenancyPosture,
39-
// [#13906 decision 1 A] The loud answer for an authorization input that
40-
// exists and could not be read, and the REGISTRY's own "never registered"
41-
// brand that lets the tenancy seam absorb the supported no-tenancy
42-
// composition while every other rejection stays loud. Never message text
43-
// (#13905).
44-
AuthzStoreUnavailableError,
45-
isServiceNotRegisteredError,
38+
// [#13906 decision 1 A / #16013] The ONE classification this door applies to
39+
// the `tenancy` service's rejection: the REGISTRY's own "never registered"
40+
// brand absorbs the supported no-tenancy composition (quiet `undefined`)
41+
// while every other rejection becomes the loud `AuthzStoreUnavailableError`
42+
// for an authorization input that exists and could not be read. Never
43+
// message text (#13905). ⛔ The RESOLUTION stays here — see the call site.
44+
classifyAdmissionTenancyPosture,
4645
} from '@objectstack/core';
4746

4847
/**
@@ -203,15 +202,17 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
203202
// capability PROBE, `resolveService`) still reads as absent — which is why
204203
// `HttpDispatcher.resolveRequestScope` hands THIS read the classified
205204
// rejection rather than the probe's collapsed answer.
206-
let tenancyPosture;
207-
try {
208-
tenancyPosture = effectiveTenancyPosture(await opts.getService('tenancy'));
209-
} catch (err) {
210-
if (!isServiceNotRegisteredError(err)) {
211-
throw new AuthzStoreUnavailableError('tenancy', err);
212-
}
213-
tenancyPosture = undefined;
214-
}
205+
//
206+
// [#17114] The CLASSIFICATION above is `classifyAdmissionTenancyPosture`'s,
207+
// not a hand-written copy of it: this seam was one of the two that #16013
208+
// left behind, and a copy of this decision is by construction the stale one.
209+
// ⛔ The RESOLUTION is still this door's own and must stay so — `opts.getService`
210+
// is this facade's lookup (the dispatcher hands it the registry's own async
211+
// accessor; other callers hand it a probe), so it is handed in as the thunk
212+
// and the helper never learns how this seam reaches the service.
213+
const tenancyPosture = await classifyAdmissionTenancyPosture(
214+
() => opts.getService('tenancy'),
215+
);
215216

216217
const authz = await resolveAuthzContext({
217218
ql,

0 commit comments

Comments
 (0)