Full ai area run of the checklist-test skill — all 7 items driven against a live showcase (opus subagent, isolated boot + file DB). Text-only per RUNNER.md.
Result: 4 PASS · 3 FAIL.
Environment — framework 92f26f75 (branch claude/platform-test-checklist-ocwugl) · vendored console 6314e87f · showcase app · isolated port + file DB · admin persona.
✅ PASS — 4 items
agent-tool-skill-metadata-roundtrip (6/6) — the valid agent/tool/skill trio builds clean through the real build gate, and all ten retired-key tombstone probes fail the build with the key named, its prescription, and the os migrate meta --from 16 line, with no artifact emitted for any. The registry serves the singular kinds with zero plural variants; round-trip diffs show source + exactly the documented defaults; runtime draft PUT works for skill/tool while agent is correctly refused 403 NOT_CREATABLE (code-only per ADR-0063); anonymous meta read 401. "Authored ⇒ callable" was deliberately not ticked — executing the tool 501s (no AI service in open edition).
mcp-run-action-exposure-gate (5/5) — an action without ai:{exposed:true} is refused over MCP with the exact actionAiExposureError text and the target record is byte-identical afterwards (same updated_at); list_actions advertises only the ai-declared action, so the two doors agree; the exposed action runs and its result reconciles with direct REST counts (14/5/12); the audit log names the calling user for a trusted-context body; sys_* actions (ban_user, set_user_role) are refused at both the object gate and the invoke-time guard with the target row unchanged. Trap wrong-persona ruled out deliberately: run as admin and the gate still refused — so it is the ai.exposed declaration, not a permission artifact.
skill-instructions-mcp-prompts (5/5) — prompts capability declared and the authored name/title/description projected; prompts/get returns the instructions byte-identical; an instruction-less skill and an inactive skill are both absent, and flipping the inactive one to active in the artifact makes it appear while the instruction-less one stays absent (absence proven then reversed, not merely asserted); prompts/get for an unknown/non-projected name errors -32602, never an empty success. Pins 12/12.
mcp-validate-expression (4/4) — a valid formula returns ok:true with inferredType and an inScope block listing the object's real fields/roots/functions; the three diagnostic tiers do not blur (bare-reference ERROR with the record. prescription · unknown-field ERROR with a did-you-mean · text-in-arithmetic ok:true with a type-mismatch WARNING); site-awareness proven by a paired call — the same bare expression is ok under flow_condition and an error under formula, and template flips inScope.dialect; unknown and system objects are refused with no field list or schema leaked.
🔴 FAIL — 3 items
1. mcp-stdio-fail-closed — the stdio transport starts, then is permanently deaf
The fail-closed half is perfect (clauses 0–2, 7): stdio enabled with no key → plugin start throws, kernel rolls back, port closed; an unknown/revoked key → same refusal with no anonymous-but-serving fallback; a real member key → principal-bound to that member, not admin; stdio off → clean boot with an explanatory line and HTTP still served.
Clause 3 fails: the started transport never answers a single JSON-RPC request. initialize, tools/list, resources/list, resources/read all time out with zero bytes on stdout, across 4 separate runs; malformed input draws no error either. Clauses 4–6 are blocked as a consequence.
Reproduction rule
- Spawn
node packages/cli/bin/run.js serve -p <port> --dev with stdio:['pipe','pipe','pipe'], OS_MCP_STDIO_ENABLED=true and a valid osk_ key.
- Wait for
[MCP] Server started (transport: stdio).
- Write a JSON-RPC
initialize line to the child's stdin.
Expected an initialize result. Actual nothing, ever.
Root cause (located, with a captured stack). An instrumented probe 40 s after boot reports listeners(data)=1 readableFlowing=false isPaused=true bytesRead=0 — the data listener is attached but process.stdin is paused. oclif's argument parser opens a readline Interface over process.stdin and closes it on its abort signal, and Interface.close() calls stdin.pause():
at Interface.pause (node:internal/readline/interface:539)
at Interface.close (node:internal/readline/interface:525)
at signal.addEventListener.once (@oclif/core/lib/parser/parse.js:64)
Two controls isolate it to the host process, not the plugin: (a) a bare @modelcontextprotocol/sdk McpServer + StdioServerTransport answers the identical bytes, and (b) ObjectStack's own MCPServerRuntime({transport:'stdio'}) answers too — in a plain node process. So every objectstack serve/dev stdio transport is started-but-deaf: OS_MCP_STDIO_ENABLED=true advertises and logs a principal-bound stdio server that no MCP client can ever talk to. The 17/17 unit pins are green while the real CLI surface is deaf — the gap is above the plugin.
2. mcp-http-surface — two defects (incl. the stock showcase cannot serve MCP at all)
Passing: the skill doc is served anonymously as text/markdown carrying this environment's own URL; anonymous POST /mcp → 401 with an RFC 9728 WWW-Authenticate whose resource_metadata URL actually resolves; keyed initialize + tools/list return exactly the documented 11 tools with annotations; the config-off 404 and the implementation-missing 501 were both observed and correctly told apart. (The OAuth-scope clause is blocked(environment) — client_credentials tokens are not accepted as request principals anywhere, so the 403 insufficient_scope branch needs an interactive PKCE consent; the scope pins were run instead, 9/9.)
(a) 405 envelope drift. POST /api/v1/mcp/skill → 405 with a hand-rolled body {error,code,message,method,path,allowed} instead of the standard {success:false,error:{code,message,httpStatus}} and the documented "Method not allowed — use GET". Root cause: dispatcher-plugin.ts registers the skill route with server.get() only (unlike /mcp, mounted for POST+GET+DELETE), so a method mismatch never reaches the dispatcher — Hono routes it to notFound and the hono adapter's unmatchedResponse() answers with its own envelope. The domain's 405 branch (routed through buildApiError by #3842) is dead code on this adapter.
(b) The stock showcase cannot serve MCP at all. The boot banner advertises "MCP server — connect a coding agent: Endpoint …/api/v1/mcp", yet both /mcp and /mcp/skill answer 501 "MCP server is not available".
Reproduction rule — boot the stock showcase; curl the advertised endpoint. Expected the MCP surface the banner promises. Actual 501.
Root cause. serve.ts auto-adds mcp to requires, and CAPABILITY_PROVIDERS.mcp declares nameMatch: ['mcp-server','MCPServerPlugin','mcp']. hasPluginMatching does substring matching over loaded plugin names, and the showcase loads com.objectstack.connector.mcp — the outbound MCP client connector, which contains mcp — so the resolver concludes the capability is already provided and never loads MCPServerPlugin. Proven: removing only ConnectorMcpPlugin from a scratch copy makes [MCP] Plugin initialized appear and /mcp/skill answer 200; @objectstack/mcp was resolvable from the CLI the whole time. A substring match between a provider and a consumer plugin name is the general hazard here.
3. open-edition-honest-degradation — the anonymous-deny ordering is inverted on /ai/**
The degradation itself is honest and passes: /ai/models, /conversations, /usage, /chat all 501 with the Cloud/EE remedy sentence (mounted-but-unimplemented, never 404/503); /ai/agents returns the declared envelope with the relocated payload; and /discovery's services.ai message is string-identical to the 501 body.
Clause 3 fails. An unauthenticated GET /api/v1/ai/models answers 501 (leaking the Cloud/EE remedy sentence) instead of 401, and anonymous GET /ai/agents answers 200.
Reproduction rule — on a stock boot, with no Authorization header: GET /api/v1/ai/models → expected 401, actual 501; GET /api/v1/ai/agents → expected 401, actual 200. Reproduced twice. Control: anonymous GET /api/v1/data/showcase_task is 401 on the same boot, so anonymous-deny is otherwise live.
Root cause. runtime/src/domains/ai.ts handleAIRequest returns the agents courtesy + capabilityUnavailable() at the top of the handler, while the shouldDenyAnonymous gate lives inside the per-route loop further down — reachable only when the AI service is serveable. So on every open-edition deployment the whole /ai/** family answers unauthenticated callers. Disclosure impact is limited to two static strings, but the ordering contract this item exists to pin is inverted.
Smaller observations
GET /api/v1/meta/skill returns a skill twice after a runtime PUT — the store-override row and the package row are both listed, not merged/deduped by name. Related: the runtime metadata API's PUT /meta/skill/<n> {active:true} → 200 does not reach the prompt bridge, because listSkills calls metadataService.list('skill') (registry/package rows) while the HTTP meta list goes through protocol.getMetaItems (store overrides merged).
?preview=draft self-inflicts an invalid diagnostic — reading a draft injects _draft:true into the item and then validates the item with that key, so the response carries _diagnostics.valid:false "Unrecognized key(s) … _draft".
Full
aiarea run of thechecklist-testskill — all 7 items driven against a live showcase (opus subagent, isolated boot + file DB). Text-only per RUNNER.md.Result: 4 PASS · 3 FAIL.
Environment — framework
92f26f75(branchclaude/platform-test-checklist-ocwugl) · vendored console6314e87f· showcase app · isolated port + file DB · admin persona.✅ PASS — 4 items
agent-tool-skill-metadata-roundtrip(6/6) — the valid agent/tool/skill trio builds clean through the real build gate, and all ten retired-key tombstone probes fail the build with the key named, its prescription, and theos migrate meta --from 16line, with no artifact emitted for any. The registry serves the singular kinds with zero plural variants; round-trip diffs show source + exactly the documented defaults; runtime draft PUT works for skill/tool while agent is correctly refused 403 NOT_CREATABLE (code-only per ADR-0063); anonymous meta read 401. "Authored ⇒ callable" was deliberately not ticked — executing the tool 501s (no AI service in open edition).mcp-run-action-exposure-gate(5/5) — an action withoutai:{exposed:true}is refused over MCP with the exactactionAiExposureErrortext and the target record is byte-identical afterwards (sameupdated_at);list_actionsadvertises only the ai-declared action, so the two doors agree; the exposed action runs and its result reconciles with direct REST counts (14/5/12); the audit log names the calling user for a trusted-context body;sys_*actions (ban_user,set_user_role) are refused at both the object gate and the invoke-time guard with the target row unchanged. Trapwrong-personaruled out deliberately: run as admin and the gate still refused — so it is theai.exposeddeclaration, not a permission artifact.skill-instructions-mcp-prompts(5/5) — prompts capability declared and the authored name/title/description projected;prompts/getreturns the instructions byte-identical; an instruction-less skill and an inactive skill are both absent, and flipping the inactive one toactivein the artifact makes it appear while the instruction-less one stays absent (absence proven then reversed, not merely asserted);prompts/getfor an unknown/non-projected name errors-32602, never an empty success. Pins 12/12.mcp-validate-expression(4/4) — a valid formula returnsok:truewithinferredTypeand aninScopeblock listing the object's real fields/roots/functions; the three diagnostic tiers do not blur (bare-reference ERROR with therecord.prescription · unknown-field ERROR with a did-you-mean · text-in-arithmeticok:truewith a type-mismatch WARNING); site-awareness proven by a paired call — the same bare expression is ok underflow_conditionand an error underformula, andtemplateflipsinScope.dialect; unknown and system objects are refused with no field list or schema leaked.🔴 FAIL — 3 items
1.
mcp-stdio-fail-closed— the stdio transport starts, then is permanently deafThe fail-closed half is perfect (clauses 0–2, 7): stdio enabled with no key → plugin start throws, kernel rolls back, port closed; an unknown/revoked key → same refusal with no anonymous-but-serving fallback; a real member key → principal-bound to that member, not admin; stdio off → clean boot with an explanatory line and HTTP still served.
Clause 3 fails: the started transport never answers a single JSON-RPC request.
initialize,tools/list,resources/list,resources/readall time out with zero bytes on stdout, across 4 separate runs; malformed input draws no error either. Clauses 4–6 are blocked as a consequence.Reproduction rule
node packages/cli/bin/run.js serve -p <port> --devwithstdio:['pipe','pipe','pipe'],OS_MCP_STDIO_ENABLED=trueand a validosk_key.[MCP] Server started (transport: stdio).initializeline to the child's stdin.Expected an
initializeresult. Actual nothing, ever.Root cause (located, with a captured stack). An instrumented probe 40 s after boot reports
listeners(data)=1 readableFlowing=false isPaused=true bytesRead=0— the data listener is attached butprocess.stdinis paused. oclif's argument parser opens areadlineInterface overprocess.stdinand closes it on its abort signal, andInterface.close()callsstdin.pause():Two controls isolate it to the host process, not the plugin: (a) a bare
@modelcontextprotocol/sdkMcpServer+StdioServerTransportanswers the identical bytes, and (b) ObjectStack's ownMCPServerRuntime({transport:'stdio'})answers too — in a plain node process. So everyobjectstack serve/devstdio transport is started-but-deaf:OS_MCP_STDIO_ENABLED=trueadvertises and logs a principal-bound stdio server that no MCP client can ever talk to. The 17/17 unit pins are green while the real CLI surface is deaf — the gap is above the plugin.2.
mcp-http-surface— two defects (incl. the stock showcase cannot serve MCP at all)Passing: the skill doc is served anonymously as
text/markdowncarrying this environment's own URL; anonymousPOST /mcp→ 401 with an RFC 9728WWW-Authenticatewhoseresource_metadataURL actually resolves; keyedinitialize+tools/listreturn exactly the documented 11 tools with annotations; the config-off 404 and the implementation-missing 501 were both observed and correctly told apart. (The OAuth-scope clause isblocked(environment)— client_credentials tokens are not accepted as request principals anywhere, so the 403insufficient_scopebranch needs an interactive PKCE consent; the scope pins were run instead, 9/9.)(a) 405 envelope drift.
POST /api/v1/mcp/skill→ 405 with a hand-rolled body{error,code,message,method,path,allowed}instead of the standard{success:false,error:{code,message,httpStatus}}and the documented "Method not allowed — use GET". Root cause:dispatcher-plugin.tsregisters the skill route withserver.get()only (unlike/mcp, mounted for POST+GET+DELETE), so a method mismatch never reaches the dispatcher — Hono routes it tonotFoundand the hono adapter'sunmatchedResponse()answers with its own envelope. The domain's 405 branch (routed throughbuildApiErrorby #3842) is dead code on this adapter.(b) The stock showcase cannot serve MCP at all. The boot banner advertises "MCP server — connect a coding agent: Endpoint …/api/v1/mcp", yet both
/mcpand/mcp/skillanswer 501 "MCP server is not available".Reproduction rule — boot the stock showcase;
curlthe advertised endpoint. Expected the MCP surface the banner promises. Actual 501.Root cause.
serve.tsauto-addsmcptorequires, andCAPABILITY_PROVIDERS.mcpdeclaresnameMatch: ['mcp-server','MCPServerPlugin','mcp'].hasPluginMatchingdoes substring matching over loaded plugin names, and the showcase loadscom.objectstack.connector.mcp— the outbound MCP client connector, which containsmcp— so the resolver concludes the capability is already provided and never loadsMCPServerPlugin. Proven: removing onlyConnectorMcpPluginfrom a scratch copy makes[MCP] Plugin initializedappear and/mcp/skillanswer 200;@objectstack/mcpwas resolvable from the CLI the whole time. A substring match between a provider and a consumer plugin name is the general hazard here.3.
open-edition-honest-degradation— the anonymous-deny ordering is inverted on/ai/**The degradation itself is honest and passes:
/ai/models,/conversations,/usage,/chatall 501 with the Cloud/EE remedy sentence (mounted-but-unimplemented, never 404/503);/ai/agentsreturns the declared envelope with the relocated payload; and/discovery'sservices.aimessage is string-identical to the 501 body.Clause 3 fails. An unauthenticated
GET /api/v1/ai/modelsanswers 501 (leaking the Cloud/EE remedy sentence) instead of 401, and anonymousGET /ai/agentsanswers 200.Reproduction rule — on a stock boot, with no
Authorizationheader:GET /api/v1/ai/models→ expected 401, actual 501;GET /api/v1/ai/agents→ expected 401, actual 200. Reproduced twice. Control: anonymousGET /api/v1/data/showcase_taskis 401 on the same boot, so anonymous-deny is otherwise live.Root cause.
runtime/src/domains/ai.tshandleAIRequestreturns the agents courtesy +capabilityUnavailable()at the top of the handler, while theshouldDenyAnonymousgate lives inside the per-route loop further down — reachable only when the AI service is serveable. So on every open-edition deployment the whole/ai/**family answers unauthenticated callers. Disclosure impact is limited to two static strings, but the ordering contract this item exists to pin is inverted.Smaller observations
GET /api/v1/meta/skillreturns a skill twice after a runtime PUT — the store-override row and the package row are both listed, not merged/deduped by name. Related: the runtime metadata API'sPUT /meta/skill/<n> {active:true}→ 200 does not reach the prompt bridge, becauselistSkillscallsmetadataService.list('skill')(registry/package rows) while the HTTP meta list goes throughprotocol.getMetaItems(store overrides merged).?preview=draftself-inflicts an invalid diagnostic — reading a draft injects_draft:trueinto the item and then validates the item with that key, so the response carries_diagnostics.valid:false"Unrecognized key(s) …_draft".