Skip to content
Merged
30 changes: 20 additions & 10 deletions docs/qa/platform-checklist/areas/ai.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@
"title": "MCP stdio auto-start fails closed without OS_MCP_STDIO_API_KEY; with a member-bound key, reads AND aggregates honor RLS/FLS and revocation bites on the next call",
"since": "v16",
"status": "active",
"revision": 4,
"revision": 5,
"priority": "P0",
"surface": "cli",
"personas": ["restricted member API key (for the RLS half — on showcase, a contributor-bound member)", "a second restricted member API key for the FLS half (on showcase, a client_liaison-bound member — see fixtures.requires)", "admin API key (for the full-row comparison)"],
Expand All @@ -226,7 +226,8 @@
"run the same two reads over REST as the same member (Bearer the same key) and as the admin key; compare",
"aggregate parity: over MCP stdio as the member, aggregate_records the OWD-private object with a count grouped by a readable field ({ aggregations: [{ function: 'count', alias: 'n' }], groupBy: ['<readable field>'] }); run the SAME aggregate over REST as the member and as the admin; compare the per-group counts",
"FLS aggregate fail-closed: over MCP stdio as the member, aggregate_records with an aggregation (or a groupBy) naming the field the member cannot read (the masked field above); capture the refusal and confirm no numeric result is returned",
"revoke the member key while the stdio session is up; issue one more read; capture the refusal",
"revoke the member key while the stdio session is up (as the admin, PATCH /api/v1/data/sys_api_key/<key id> {\"revoked\": true}); issue one more read on the SAME session; capture the tool result and parse its error envelope (code and status, not only the prose)",
"start once more with stdio enabled and the key revoked in the previous step; capture the refusal to start (the lifecycle arm of the up-front admission)",
"start once more with stdio NOT enabled and no key set; confirm a clean boot (the key is only demanded when stdio is on)"
],
"acceptance": [
Expand All @@ -237,10 +238,10 @@
"evidence": "the thrown message / startup log"
},
{
"clause": "an unknown/revoked/expired key is rejected up front — 'OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). Refusing to start stdio (ADR-0101).' — never a fall-back to an anonymous-but-serving session",
"clause": "an unknown/revoked/expired key is rejected up front — never a fall-back to an anonymous-but-serving session. A key that resolves to nobody (unknown, owner-less) is refused with 'OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). Refusing to start stdio (ADR-0101).'; a key its own lifecycle refuses (revoked, expired, inactive) is refused naming its code since #23367: '[MCP] OS_MCP_STDIO_API_KEY is refused (API_KEY_REVOKED): … Refusing to start stdio (ADR-0101).', the elided middle being the key's own lifecycle message, whose wording this item does not pin",
"oracle": "log",
"verify": "startup with osk_unknown throws /did not resolve to a valid identity/",
"evidence": "the thrown message"
"verify": "startup with osk_unknown throws /did not resolve to a valid identity/; startup with the key revoked in the revocation step throws /is refused \\(API_KEY_REVOKED\\)/ (packages/mcp/src/plugin.ts, the initial admission's isApiKeyLifecycleRefusalError arm); neither process serves",
"evidence": "the two thrown messages"
},
{
"clause": "a valid key binds the transport to a REAL principal and says so: '[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity <userId> (RLS/FLS/tenant applied)'",
Expand All @@ -267,10 +268,10 @@
"evidence": "the refusal + a control aggregate over a readable field"
},
{
"clause": "revocation is honored on the NEXT read (the identity is re-resolved per call), failing with 'MCP stdio identity is no longer valid (key revoked or expired)'",
"oracle": "log",
"verify": "the post-revocation read errors with that message instead of serving stale authority",
"evidence": "the refusal"
"clause": "revocation is honored on the NEXT read (the identity is re-resolved per call) and fails CLOSED with the key's own lifecycle refusal: the post-revocation call returns no row and is a tool ERROR whose envelope carries code API_KEY_REVOKED and status 403. The envelope's message is the key's own lifecycle refusal and its wording is NOT pinned here: the code is what a client branches on, and the lifecycle owner rewords that text (#23944, now that restore_api_key can bring a revoked key back). Since #23367 resolvePrincipal raises that branded ADR-0112 envelope (ApiKeyLifecycleRefusalError) instead of the generic 'MCP stdio identity is no longer valid (key revoked or expired)' error, which now answers only an identity that resolves to nobody at all",
"oracle": "api",
"verify": "on the SAME live stdio session: a query_records before the revoke returns rows (the control that the session was serving); after PATCH /api/v1/data/sys_api_key/<key id> {\"revoked\": true} the next tools/call query_records answers result.isError true, and its content text parses to {error: {code: 'API_KEY_REVOKED', status: 403, message}} with no record in it (packages/mcp/src/plugin.ts resolvePrincipal; the coded tool error is errorResultWithCode in packages/mcp/src/mcp-http-tools.ts). Score the code, the status and the absence of rows, never the message's wording",
"evidence": "the pre-revocation rows, the revoke write, and the post-revocation tool result (isError, code, status, no rows)"
},
{
"clause": "stdio disabled ⇒ no key demanded: the boot completes and logs '[MCP] Transport not auto-started …' (the HTTP surface is served per-request regardless)",
Expand All @@ -283,12 +284,15 @@
"an invalid/revoked key falling back to an anonymous-but-serving session is THE fail this item exists for — any served read without a resolved principal is a P0 FAIL",
"a member-keyed MCP read returning rows the same member's REST read hides is a FAIL (MCP must not be a side door around RLS)",
"an MCP aggregate count that equals the ADMIN's total (i.e. counts rows the same member's REST read hides) is a P0 FAIL — an RLS-bypassing count is the sys_attachment-total-leak class (#2976): a bypass leaks row existence and statistics even when the individual rows stay hidden",
"an aggregate over an FLS-masked field returning a number instead of failing closed is a FAIL — the input gate is the only place the leak is stopped, since output masking cannot see through an alias"
"an aggregate over an FLS-masked field returning a number instead of failing closed is a FAIL — the input gate is the only place the leak is stopped, since output masking cannot see through an alias",
"a post-revocation read that serves any row is a P0 FAIL (stale authority); a post-revocation refusal that carries no API_KEY_REVOKED code is a FAIL of the #23367 contract — a client branches on the code, never on prose"
],
"traps": ["wrong-persona"],
"automated": { "kind": "unit", "ref": "packages/mcp/src/__tests__/plugin.test.ts" },
"source": [
"packages/mcp/src/plugin.ts (the three refusal strings + per-call re-resolution, ADR-0101)",
"packages/core/src/security/api-key.ts#lifecycleRefusalOf (#23367 — the API_KEY_REVOKED / API_KEY_EXPIRED / API_KEY_INACTIVE refusals, 403, and the located message naming the key's prefix; raised as ApiKeyLifecycleRefusalError out of the stdio door's per-call resolvePrincipal and its up-front admission)",
"packages/mcp/src/mcp-http-tools.ts#errorResultWithCode (the data tools render a thrown coded refusal as {error: {code, message, status}} in an isError tool result)",
"packages/mcp/src/__tests__/plugin.test.ts ('stdio principal admission — fail-closed')",
"packages/runtime/src/domains/mcp.ts#callData (the aggregate bridge routes through callData('aggregate') → the ObjectQL engine so RLS + the FLS aggregate gate always run; the raw driver is deliberately NOT passed)",
"packages/mcp/src/mcp-http-tools.ts#registered (aggregate_records registration: 'Runs under the caller's permissions, row-level security and field-level security'; registered only when the bridge exposes aggregate)",
Expand All @@ -305,6 +309,12 @@
"date": "2026-10-01",
"change": "checklist-accuracy findings of QA run #21056: no single showcase persona carries both RLS-hidden rows and an FLS-masked field on one object, so the item now names two (contributor for RLS, client_liaison for FLS); acceptance[5] no longer expects details.forbiddenFields on the wire, which neither transport serializes; steps[0] names `os start` as the host and says why `os dev` corrupts the stdio channel (its banner goes to stdout — 15 lines measured, against 0 for `os start`)",
"ref": "#21060"
},
{
"revision": 5,
"date": "2026-10-16",
"change": "stale clause re-pointed (#23950, from QA run #23915): acceptance[6] pinned the generic text 'MCP stdio identity is no longer valid (key revoked or expired)', but since #23367 (API-key lifecycle) resolvePrincipal raises the key's branded ADR-0112 refusal, so a revoked key's next stdio call is an isError tool result carrying code API_KEY_REVOKED, status 403 and the key's own lifecycle message. The clause now scores that code (and pins none of the message's wording, which the lifecycle owner rewords under #23944), no row served after the revoke (the fail-closed property it exists for, unchanged) and a pre-revoke control read on the same session; its oracle is the tool result (api), not a log line. Same ruling, same item: acceptance[1] attributed one message to unknown, revoked and expired keys alike, while the up-front admission now refuses a lifecycle-refused key with '[MCP] OS_MCP_STDIO_API_KEY is refused (API_KEY_REVOKED): …', so it names both messages and gains a leg (a new step starts stdio with the just-revoked key). A negative pins both failure directions",
"ref": "#23950"
}
]
},
Expand Down
Loading
Loading