From acaafac00d9d190c24c0b70473ba3d9acc345354 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:19:45 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(qa):=20wave-3=20accuracy=20slice=20S6?= =?UTF-8?q?=20=E2=80=94=20api-backend=20and=20ai=20items=20(wip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01QUs3BvdUqqhcRdQJHjUSXA Co-authored-by: Claude --- docs/qa/platform-checklist/areas/ai.json | 53 +++++++++------ .../platform-checklist/areas/api-backend.json | 65 ++++++++++--------- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index f64f37fedc3..34dc9ccf21b 100644 --- a/docs/qa/platform-checklist/areas/ai.json +++ b/docs/qa/platform-checklist/areas/ai.json @@ -9,7 +9,7 @@ "title": "agent / tool / skill metadata kinds author, persist and list over the meta surface — retired keys reject with prescriptions, the closed agent kind has no runtime write door", "since": "v15", "status": "active", - "revision": 7, + "revision": 8, "priority": "P1", "surface": "api", "personas": ["admin"], @@ -38,7 +38,7 @@ "steps": [ "read the three Zod shapes FIRST and pin the requireds: AgentSchema needs name/label/role/instructions (packages/spec/src/ai/agent.zod.ts), ToolSchema needs name/label/description/parameters and is .strict() (tool.zod.ts), SkillSchema needs name/label/tools (skill.zod.ts) — if these have drifted, revise this item before running", "author one valid item per kind in the writable package: defineAgent({ name: 'qa_probe_agent', label, role, instructions, skills: ['qa_probe_skill'] }), defineTool({ name: 'qa_probe_tool', label, description, parameters: { type: 'object', properties: {} } }), defineSkill({ name: 'qa_probe_skill', label, tools: ['qa_probe_tool'], instructions: 'probe instructions' }); build — the build WARNS agent-authoring-withdrawn on the agent (ADR-0063 §2: the runtime filters a non-platform agent out of the agent catalog and refuses to load it), which is expected and no build failure. The agent still lists and reads over /api/v1/meta/agent with _provenance 'package': a meta listing is not the agent catalog", - "author the tombstone probes from `variants` (one broken copy per retired key, e.g. tool with `permissions: []`, agent with `tools: [{...}]`, skill with `triggerPhrases: ['x']`); capture each build/parse error text verbatim", + "author the tombstone probes from `variants` (one broken copy per retired key, e.g. tool with `permissions: []`, agent with `tools: [{...}]`, skill with `triggerPhrases: ['x']`), plus one agent with `lifecycle: {}`, the surviving agent tombstone; capture each build/parse error text verbatim", "boot; sign in as admin; GET /api/v1/meta (meta.getTypes) and GET /api/v1/meta/types — capture which kinds are registered", "GET /api/v1/meta/agent, /api/v1/meta/tool, /api/v1/meta/skill (route ledger: meta.getItems) and locate the three authored probes", "GET /api/v1/meta/skill/qa_probe_skill (meta.getItem) and field-diff against the authored source, noting applied defaults (surface:'ask', active:true)", @@ -50,7 +50,7 @@ { "clause": "each kind's valid probe parses at build and each tombstone probe is rejected LOUDLY with the retired key NAMED and its prescription attached — never silently stripped", "oracle": "build", - "verify": "tool.permissions error contains 'promised a capability gate on tool invocation that nothing ever enforced' (TOOL_RETIRED_KEY_GUIDANCE, tool.zod.ts); agent.tools error prescribes `skills` + ADR-0064 + 'os migrate meta --from 16' (retiredKey, agent.zod.ts); skill.triggerPhrases error says phrases were never matched and routes intent to triggerConditions (skill.zod.ts); the valid trio builds clean", + "verify": "tool.permissions error contains 'promised a capability gate on tool invocation that nothing ever enforced' (TOOL_RETIRED_KEY_GUIDANCE, tool.zod.ts — every tool probe still carries its own per-key prescription); skill.permissions and skill.trigger carry their guidance sentences (SkillSchema's guidance map, skill.zod.ts); agent.lifecycle is the surviving agent tombstone, its error naming the removal and 'os migrate meta --from 17' (retiredKey, agent.zod.ts). agent.tools, agent.knowledge and skill.triggerPhrases aged out of their tombstones (#25767): each is now a plain unrecognized key whose error names the key and carries the upgrade-guide pointer 'see the protocol upgrade guide: https://objectstack.ai/docs/protocol-upgrade' (RETIRED_KEY_UPGRADE_GUIDE_SENTENCE, packages/spec/src/shared/protocol-upgrade-guide.ts). That pointer is their prescription now — they no longer prescribe `skills`, ADR-0064 or triggerConditions — and a refusal missing it fails this clause as surely as a silent strip; the valid trio builds clean", "evidence": "per-variant error texts keyed by variant + the clean build output" }, { @@ -125,7 +125,8 @@ "date": "2026-10-26", "change": "source anchors re-pointed after the ≤16.x retirement kits aged out (#25767): `skill.triggerPhrases` and `agent.tools`/`agent.knowledge` are no longer declared — their probes are strict unknown keys whose refusal names the protocol upgrade guide, not tombstones; the skill anchor now cites SkillSchema and the agent line names the surviving `lifecycle` tombstone. The negative clause holds as written: a probe accepted with a clean parse is still a FAIL.", "ref": "#25767" - } + }, + { "revision": 8, "date": "2026-10-31", "change": "acceptance[0].verify and steps[2] follow the refusal texts at head. Checklist-accuracy finding of QA run #27392 (18.3.0 sweep, wave 3, at c4c515ad; sweep anchor #27377): the verify still expected agent.tools to prescribe `skills` + ADR-0064 + 'os migrate meta --from 16' and skill.triggerPhrases to route to triggerConditions, but since #25767 those keys (and agent.knowledge) are plain unrecognized keys whose refusal names the key and the protocol upgrade guide. Per-key prescriptions survive for the tool guidance map, skill.permissions / skill.trigger and the agent.lifecycle tombstone, which steps[2] now probes too. Re-read at base 488b3ed0: packages/spec/src/shared/suggestions.zod.ts (every strict refusal appends RETIRED_KEY_UPGRADE_GUIDE_SENTENCE), packages/spec/src/ai/agent.zod.ts lifecycle retiredKey, packages/spec/src/ai/tool.zod.ts TOOL_RETIRED_KEY_GUIDANCE, packages/spec/src/ai/skill.zod.ts SkillSchema guidance. The clause is unchanged: a probe accepted with a clean parse is still a FAIL", "ref": "#27392" } ] }, { @@ -133,7 +134,7 @@ "title": "MCP HTTP transport (/mcp) and the public /mcp/skill endpoint gate exactly as documented: 404 when opted out, 501 when unimplemented, 401 anonymous, 403 scopeless-OAuth, full tool list when keyed", "since": "v15", "status": "active", - "revision": 7, + "revision": 8, "priority": "P1", "surface": "api", "fixtures": { @@ -184,7 +185,7 @@ "evidence": "the 401 response incl. headers" }, { - "clause": "a keyed MCP client completes initialize + tools/list, and the tool set is the documented principal-bound surface: list_objects, describe_object, query_records, aggregate_records, get_record, create_record, update_record, delete_record, list_actions, run_action, resume_run, validate_expression", + "clause": "a keyed MCP client completes initialize + tools/list, and the tool set over /api/v1/mcp (the HTTP door) is the documented principal-bound surface: list_objects, describe_object, query_records, aggregate_records, get_record, create_record, update_record, delete_record, list_actions, run_action, resume_run, validate_expression. The stdio door on the same build serves 9 of these, without the action family (list_actions, run_action, resume_run): the tool set is a function of the bridge (wireBridgeTools, packages/mcp/src/mcp-http-tools.ts), and the stdio data bridge implements no action methods (createStdioDataBridge, packages/mcp/src/stdio-data-bridge.ts), so a stdio count of 9 is not a shortfall against this clause", "oracle": "network", "verify": "tools/list names against packages/mcp/src/mcp-http-tools.ts registrations (aggregate_records may be absent only when the bridge cannot route it, and resume_run only when the host cannot resume runs — both graceful degradation; record which)", "evidence": "the session trace + tool-name list" @@ -223,7 +224,8 @@ "date": "2026-10-28", "change": "automated.ref and variants[2] corrected. Checklist-accuracy findings of the 18.2.0 wave 2 (QA run #26468, Tier 1 half A, at 756220c4; wave anchor #25807, slice B). (1) The ref said no named file scores a clause alone, but mcp-skill-method-not-allowed.hono.integration.test.ts asserts acceptance[2] in full (status, Allow: GET, the exact envelope message) over a real Hono socket and the real dispatcher, and a 405 never reaches the faked services; the ref now says it scores that clause. (2) The ref gains packages/cli/test/serve-mcp-capability-collision.e2e.test.ts as corroboration (spawned server, osk_ key, initialize + tools/list, /mcp/skill 200) and says the scopes test's fake bridge has no resumeRun, so it counts 11 tools. (3) variants[2] omitted resume_run, which the same actions:execute scope grants. Re-read at base fd246c22: packages/mcp/src/mcp-http-tools.ts registerActionTools (canResume, 'the same actions:execute scope grants both'), mcp-http-tools.scopes.test.ts ACTION_TOOLS. No clause changed", "ref": "#25807" - } + }, + { "revision": 8, "date": "2026-10-31", "change": "acceptance[4] names its transport. Checklist-accuracy finding of QA run #27409 (18.3.0 sweep, wave 3, Tier 1 half A, at c4c515ad; sweep anchor #27377): the HTTP door listed the twelve, while the stdio door on the same build listed 9 with no action family, and the clause did not say which door 'the documented twelve' meant. It now names /api/v1/mcp and says why stdio serves 9. Re-read at base 488b3ed0: packages/mcp/src/mcp-http-tools.ts wireBridgeTools (the action pair registers only when the bridge implements listActions and runAction), packages/mcp/src/stdio-data-bridge.ts createStdioDataBridge (an McpDataBridge, no action methods). The HTTP assertion is unchanged", "ref": "#27409" } ] }, { @@ -365,7 +367,7 @@ "title": "MCP run_action requires ai.exposed (fail-closed with the prescription), filters list_actions to exposed+permitted, refuses system objects, and logs the trusted-elevation audit line", "since": "v15.1", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "api", "fixtures": { @@ -373,11 +375,13 @@ "requires": [ "the showcase's ONE ai-exposed action: showcase_portfolio_snapshot (global script action, ai: { exposed: true }, examples/app-showcase/src/ui/actions/index.ts) — the exposed side", "any showcase action WITHOUT an ai block (e.g. showcase_mark_done on showcase_task) — the undeclared side", - "MCP reachable with an osk_ key (see ai.mcp-http-surface)" + "MCP reachable with an osk_ key (see ai.mcp-http-surface)", + "acceptance[1]'s PERMITTED half has no stock producer: showcase_portfolio_snapshot gates no permission, so nothing exposed is ever held back. Stage one at runtime as the admin: PUT /api/v1/meta/action/qa_ai_gated { name: 'qa_ai_gated', label: 'QA AI gated', objectName: 'showcase_task', operation: 'update', patch: { progress: 50 }, execution: 'perRecord', ai: { exposed: true, description: 'QA probe: an AI-exposed action behind a capability gate' }, requiredPermissions: ['manage_metadata'] }. It is a DECLARATIVE update because the listing drops a script action with no registered handler before it asks the permission (listActions, packages/runtime/src/domains/mcp.ts), and it requires manage_metadata because the admin holds that capability and a member persona does not (actionPermissionError, packages/runtime/src/action-execution.ts). The member side needs an osk_ key of a member persona (Mei Phone, phone.demo@example.com), minted as ai.mcp-http-surface mints the admin's. DELETE /api/v1/meta/action/qa_ai_gated in teardown" ] }, "steps": [ "over MCP, call list_actions with NO arguments (its input schema is strict and empty: an objectName argument is refused — the tool result is isError with 'MCP error -32602: Input validation error … Unrecognized key(s) on this list_actions call: `objectName`'); record which actions appear", + "permitted half (only when fixtures.requires[3] is staged): call list_actions again with the member persona's osk_ key; record which actions appear in each persona's list", "call run_action on the UNDECLARED action (actionName: 'showcase_mark_done', a real recordId); capture the refusal", "verify the refusal left zero side effects: read the task over REST — status unchanged", "call run_action on the exposed action (actionName: 'showcase_portfolio_snapshot'); capture the result payload ({ ok, scope: 'global', accounts, projects, invoices })", @@ -395,7 +399,7 @@ { "clause": "list_actions returns ONLY actions that are both ai-exposed and permitted to the caller — showcase_portfolio_snapshot present, showcase_mark_done absent", "oracle": "api", - "verify": "the list_actions payload against the fixture's declared ai blocks (mcp-http-tools.ts: 'actions that are BOTH declared ai-exposed and that the caller is permitted to run'). On the stock showcase only the ai-exposed half discriminates: showcase_portfolio_snapshot is the one exposed action and no persona lacks a permission it needs, so no exposed-but-unpermitted action exists to be held back. Score the permitted half not-run(fixture) unless the run stages such an action", + "verify": "the list_actions payload against the fixture's declared ai blocks (mcp-http-tools.ts: 'actions that are BOTH declared ai-exposed and that the caller is permitted to run'). On the stock showcase only the ai-exposed half discriminates: showcase_portfolio_snapshot is the one exposed action and no persona lacks a permission it needs, so no exposed-but-unpermitted action exists to be held back. The staged qa_ai_gated action (fixtures.requires[3]) discriminates it: present in the admin's list_actions, absent from the member's, while showcase_portfolio_snapshot is in both. Without that staging, score the permitted half not-run(fixture)", "evidence": "the list_actions response" }, { @@ -438,7 +442,8 @@ "date": "2026-10-28", "change": "acceptance[1].verify says which half the stock fixture discriminates. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26476, manual api set, at 756220c4; wave anchor #25807, slice B): the clause's 'permitted' filter was not discriminated, because no stock action pairs ai.exposed with a permission gate a persona lacks. Re-read at base fd246c22: examples/app-showcase/src/ui/actions/index.ts carries one ai: { exposed: true } block (showcase_portfolio_snapshot). The clause is unchanged; the verify names the fixture gap", "ref": "#25807" - } + }, + { "revision": 6, "date": "2026-10-31", "change": "fixtures.requires[3], steps[1] and acceptance[1].verify stage the permitted half of the list_actions filter. Fixture gap of QA run #27392 (18.3.0 sweep, wave 3, at c4c515ad), parked on sweep anchor #27377: the permitted half scored blocked(fixture) because no stock action pairs ai.exposed with a permission some persona lacks. The recipe is a runtime declarative update action, ai-exposed, requiring manage_metadata, listed for the admin and held back from a member. Derived from source at base 488b3ed0: packages/runtime/src/domains/mcp.ts listActions (the handler check precedes the permission check, which a declarative update skips), packages/runtime/src/action-execution.ts actionPermissionError, packages/spec/src/ui/action.zod.ts (ai.exposed requires a description). Not yet run live: the first run that stages it proves the recipe. The clause is unchanged", "ref": "#27392" } ] }, { @@ -446,7 +451,7 @@ "title": "MCP validate_expression returns build-accurate errors/warnings/inferred type + in-scope context for every site variant, and fail-closes on unknown/system objects", "since": "v16", "status": "active", - "revision": 5, + "revision": 6, "priority": "P2", "surface": "api", "fixtures": { @@ -468,6 +473,7 @@ "call it with a text field misused in arithmetic ('record.title * 2'); capture the WARNING (not error)", "call the flow_condition site with bare fields ('estimate_hours > 8') — must validate clean", "call it against a nonexistent object and against a sys_* object; capture both refusals", + "call it with a blank and a whitespace-only expression ('' and ' ') at site formula, and with '' at site template; capture each verdict (negative[1])", "record the inScope payload (dialect/roots/fields/functions) returned alongside each verdict" ], "acceptance": [ @@ -497,7 +503,8 @@ } ], "negative": [ - "a validation verdict that DIFFERS from the build gate for the same expression is a FAIL — the tool's one job is build-accuracy (api-backend.formula-gates is the same engine)" + "a validation verdict that DIFFERS from the build gate for the same expression is a FAIL — the tool's one job is build-accuracy (api-backend.formula-gates is the same engine)", + "a blank or whitespace-only expression ('' or ' ') answering ok:true is a FAIL: the save/build gate refuses a source that is blank after trimming, and the tool parses with that gate's slot schema first (VALIDATE_SITE_MAP's slot, packages/mcp/src/mcp-http-tools.ts), so it answers ok:false with ONE error carrying the schema's own issue — code invalid_union and the EVALUATED_EXPRESSION_SOURCE_REQUIRED sentence (packages/spec/src/shared/expression.zod.ts) at formula, validation and flow_condition, the template sentence at site template — and no inferredType (pinned in packages/mcp/src/mcp-validate-expression.test.ts, 'a blank or whitespace-only expression gets the save gate verdict'). validateExpression alone treats a blank source as not authored and answers ok, which is the split #27411 recorded and #27534 closed" ], "automated": { "kind": "unit", "ref": "packages/mcp/src/mcp-validate-expression.test.ts — MCPServerRuntime.handleHttpRequest over a fake McpDataBridge whose describeObject returns a crm_opportunity schema: a plugin unit, not the served MCP door the api oracle names, so it corroborates acceptance[0], [1] and [3] and never scores them pass (RUNNER rule 6). Its sound-formula case asserts ok, errors, warnings and inScope only, never inferredType, so acceptance[0]'s inferredType half has no pin at any layer; acceptance[2] is not pinned (see its verify)" }, "source": [ @@ -515,7 +522,8 @@ "date": "2026-10-28", "change": "variants[2] and automated.ref corrected. Checklist-accuracy findings of the 18.2.0 wave 2 (QA run #26468, Tier 1 half A, at 756220c4; wave anchor #25807, slice B). (1) variants[2] said a record.-prefixed ref is an error under flow_condition; live, 'record.estimate_hours > 8' there answered ok:true with no errors, because the flattened scope binds the scope roots as maps beside the bare fields. Acceptance[2] is unaffected: it compares the bare form across the two sites. (2) The pin's sound-formula case never checks inferredType, so the ref now says acceptance[0]'s inferredType half is unpinned. Re-read at base fd246c22: packages/formula/src/cel-engine.ts buildTypedEnv (flattened: SCOPE_ROOTS registered as map), packages/mcp/src/mcp-http-tools.ts VALIDATE_SITE_MAP, packages/mcp/src/mcp-validate-expression.test.ts 'accepts a sound formula'. No clause changed", "ref": "#25807" - } + }, + { "revision": 6, "date": "2026-10-31", "change": "negative[1] and steps[5] add the blank-input boundary. Checklist-accuracy finding of QA run #27409 (18.3.0 sweep, wave 3, Tier 1 half A, at c4c515ad; sweep anchor #27377), whose product half #27411 was closed by #27534: the item had no clause for an empty or blank expression. The tool now answers the save gate's verdict for it, so the clause asks for exactly that. Re-read at base 488b3ed0: packages/mcp/src/mcp-http-tools.ts (validate_expression parses the slot schema before validateExpression), packages/spec/src/shared/expression.zod.ts EVALUATED_EXPRESSION_SOURCE_REQUIRED, packages/mcp/src/mcp-validate-expression.test.ts (the blank-input block). Existing clauses unchanged", "ref": "#27409" } ] }, { @@ -767,13 +775,13 @@ "title": "the MCP OAuth transport rule is judged on the DEPLOYMENT's own host: a private / link-local plain-HTTP deployment serves the OAuth track and logs the accepted-transport warning; a public plain-HTTP one is still refused, fail-closed, and logs its own public-plaintext warning", "since": "v17", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "api", "fixtures": { "app": "any", "requires": [ - "THREE boots of the same build, differing only in the deployment base URL: (a) a PRIVATE address the host actually holds — a LAN address under `os dev`, e.g. http://192.168.x.x:PORT or http://10.x.x.x:PORT; (b) a PUBLIC host spelling — http://example.com or an http:// public IP literal, which needs no reachability because every clause here is about what the deployment ADVERTISES; (c) loopback (http://localhost:PORT), the unchanged control. The deployment base URL is set with OS_AUTH_URL (BETTER_AUTH_URL and OS_BASE_URL are read too, in that order: packages/cli/src/commands/serve.ts AUTH_BASE_URL_ENV_NAMES). A host that holds no RFC 1918 or link-local address cannot stage boot (a): record its clauses blocked(fixture). An address outside the eligible blocks, such as 192.0.2.2 (TEST-NET-1), is PUBLIC to the rule (auth-manager.ts PLAIN_HTTP_ELIGIBLE_IPV4_CIDRS), so it serves boot (b)", + "THREE boots of the same build, differing only in the deployment base URL: (a) a PRIVATE address literal, e.g. http://192.168.x.x:PORT or http://10.x.x.x:PORT; (b) a PUBLIC host spelling — http://example.com or an http:// public IP literal, which needs no reachability because every clause here is about what the deployment ADVERTISES; (c) loopback (http://localhost:PORT), the unchanged control. The deployment base URL is set with OS_AUTH_URL (BETTER_AUTH_URL and OS_BASE_URL are read too, in that order: packages/cli/src/commands/serve.ts AUTH_BASE_URL_ENV_NAMES). Boot (a) does NOT need the host to hold that address: the transport rule judges the HOST LITERAL of the configured base URL, never the interfaces the process binds (isOAuthEligibleBaseUrl and isPrivateOrLoopbackHostLiteral, packages/plugins/plugin-auth/src/auth-manager.ts), and every clause on (a) reads what the deployment ADVERTISES and LOGS. So OS_AUTH_URL=http://10.0.0.5:PORT on a host that holds only 127.0.0.1 stages acceptance[0] and the private leg of acceptance[2] whole; QA run #27409 reproduced the whole (a) shape on such an unheld address. Record in the run env whether the address was held. Only steps[3]'s real-client leg needs an address the host holds, because a second machine has to reach it; without one, that leg alone is blocked(fixture). An address outside the eligible blocks, such as 192.0.2.2 (TEST-NET-1), is PUBLIC to the rule (auth-manager.ts PLAIN_HTTP_ELIGIBLE_IPV4_CIDRS), so it serves boot (b)", "the MCP HTTP surface at its default (on — served unless OS_MCP_SERVER_ENABLED=false) and the embedded AS at its default (follows the MCP surface — packages/plugins/plugin-auth/src/auth-manager.ts#resolveOidcProviderEnabled)", "the boot log of each boot, captured from the first line — the transport warning is emitted ONCE at discovery-route mount and is not repeated per request", "a second machine (or container) on the same private network for the (a) boot, if the run also drives a real MCP client end to end" @@ -873,7 +881,8 @@ "date": "2026-10-28", "change": "fixtures.requires[0] and steps[6] name the base-URL door and the no-private-address case. Checklist-accuracy findings of the 18.2.0 wave 2 (QA run #26468, Tier 1 half A, at 756220c4; wave anchor #25807, slice B): the item never said how to set the deployment base URL, which is OS_AUTH_URL (then BETTER_AUTH_URL, OS_BASE_URL), nor how to stage the https re-boot (OS_AUTH_URL=https://… behind a terminator, or os dev --cert/--key); and the runner's container held only 127.0.0.1 and 192.0.2.2, which cannot stage boot (a) but, being outside the eligible CIDR list, stages boot (b). Re-read at base fd246c22: packages/cli/src/commands/serve.ts AUTH_BASE_URL_ENV_NAMES, packages/cli/src/commands/dev.ts cert/key flags, packages/plugins/plugin-auth/src/auth-manager.ts PLAIN_HTTP_ELIGIBLE_IPV4_CIDRS. The run's missing-clause note (the AS metadata issuer should equal authorization_servers[0]) is not added here: it is the open product defect #26472, whose fix owns that clause. No clause changed", "ref": "#25807" - } + }, + { "revision": 6, "date": "2026-10-31", "change": "fixtures.requires[0] says which reading scores boot (a). Checklist-accuracy finding and fixture gap of QA run #27409 (18.3.0 sweep, wave 3, Tier 1 half A, at c4c515ad; sweep anchor #27377): the line demanded a held private address and scored (a) blocked(fixture) without one, but the clauses judge only what is advertised and logged, and an unheld OS_AUTH_URL=http://10.0.0.5 reproduced the whole (a) shape (both protected-resource paths 200 with that origin, the 401 resource_metadata pointer, the accepted-transport warning at WARN). A held address stays required for the real-client leg (steps[3]). Re-read at base 488b3ed0: packages/plugins/plugin-auth/src/auth-manager.ts isOAuthEligibleBaseUrl, which judges the base URL's host literal. No clause changed", "ref": "#27409" } ] }, { @@ -881,7 +890,7 @@ "title": "MCP on v18: a coded refusal on the data tools, list_actions and the record resource renders {error:{code,message,status}} (not the bare message), both MCP doors honour a runtime-authored object's apiEnabled:false including after a restart, and run_action withholds PERMISSION_DENIED details", "since": "v18", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "api", "personas": [ @@ -892,13 +901,14 @@ "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", "MCP enabled on the boot (ai.mcp-http-surface names the switch)", - "a runtime-authored scratch object (PUT /api/v1/meta/object/NAME on a scratch boot) with enable: { apiEnabled: false } — the key lives under `enable` (a top-level apiEnabled is refused 422 unrecognized key) — and a sharingModel (security-owd-unset refuses an object without one, 422)" + "a runtime-authored scratch object (PUT /api/v1/meta/object/NAME on a scratch boot) with enable: { apiEnabled: false } — the key lives under `enable` (a top-level apiEnabled is refused 422 unrecognized key) — and a sharingModel (security-owd-unset refuses an object without one, 422)", + "acceptance[3]'s producer: no stock action refuses a persona at run_action, because the showcase's one AI-exposed action (showcase_portfolio_snapshot) is ungated. Stage a runtime DECLARATIVE update action exposed to AI on showcase_task, as QA run #27409 did: PUT /api/v1/meta/action/qa_ai_mark { name: 'qa_ai_mark', label: 'QA AI mark', objectName: 'showcase_task', operation: 'update', patch: { progress: 50 }, execution: 'perRecord', ai: { exposed: true, description: 'QA probe: a declared write run as the caller' } }, and run it as Ada Auditor (auditor.demo@example.com, with an osk_ key of hers) against a real task. The declared write runs as the CALLER (executeDeclarativeUpdateAction, packages/runtime/src/action-execution.ts) and the auditor holds no edit on showcase_task, so run_action answers PERMISSION_DENIED; the admin's run of the same action is the control. DELETE /api/v1/meta/action/qa_ai_mark in teardown" ] }, "steps": [ "call the MCP get_record tool for a missing id — its arguments are { objectName, recordId } (an `id` argument is refused -32602 with a did-you-mean naming recordId); call query_records on the enable.apiEnabled: false object", "restart the boot (same file DB) and repeat", - "call run_action on an action the caller may not run" + "call run_action on an action the caller may not run: the staged qa_ai_mark (fixtures.requires[3]) as auditor.demo, with a real showcase_task recordId, then the same call as the admin (the control)" ], "acceptance": [ { @@ -948,7 +958,8 @@ "change": "fixtures.requires[2] and steps[0] name the spellings the doors accept — checklist-accuracy findings of the 18.0 QA sweep (QA runs #24540 and #24558, ai.mcp-coded-refusals-and-runtime-exposure; wave anchor #24579 §3). Re-derived at base 9a7838edf5: get_record takes recordId (packages/mcp/src/mcp-http-tools.ts aliases id → recordId), apiEnabled sits under ObjectSchema.enable, and a runtime object needs a sharingModel (packages/lint security-owd-unset, error tier). No clause changed; step 3's missing PERMISSION_DENIED subject is a fixture gap left for §4", "ref": "#24649" }, - { "revision": 3, "date": "2026-10-26", "change": "automated.ref extended — checklist-accuracy findings of the 18.2.0 sweep, Tier 1 record #25815 at 6679644a (wave anchor #25807). acceptance[1]: mcp-coded-refusal.test.ts asserts the clause at plugin-unit layer but was not in the ref. acceptance[2]: the record found no pin driving query_records on an apiEnabled false runtime object across a restart; re-read on main at b4199c96, packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts does, on the stdio door at engine layer, so it joins the ref and the ref now names each pin's layer. No clause changed, no status changed", "ref": "#25836" } + { "revision": 3, "date": "2026-10-26", "change": "automated.ref extended — checklist-accuracy findings of the 18.2.0 sweep, Tier 1 record #25815 at 6679644a (wave anchor #25807). acceptance[1]: mcp-coded-refusal.test.ts asserts the clause at plugin-unit layer but was not in the ref. acceptance[2]: the record found no pin driving query_records on an apiEnabled false runtime object across a restart; re-read on main at b4199c96, packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts does, on the stdio door at engine layer, so it joins the ref and the ref now names each pin's layer. No clause changed, no status changed", "ref": "#25836" }, + { "revision": 4, "date": "2026-10-31", "change": "fixtures.requires[3] and steps[2] name acceptance[3]'s producer. Checklist-accuracy finding of QA run #27409 (18.3.0 sweep, wave 3, Tier 1 half A, at c4c515ad; sweep anchor #27377): the fixtures named no recipe for the PERMISSION_DENIED arm, and the run staged it with a runtime declarative update action on showcase_task, ai-exposed, run as auditor.demo. Re-read at base 488b3ed0: packages/runtime/src/action-execution.ts (the declarative update writes as the caller), packages/spec/src/ui/action.zod.ts (ai.exposed requires a description). The clause is unchanged", "ref": "#27409" } ] } ] diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index ff809c62e0e..9587ebad67f 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -396,7 +396,7 @@ "title": "Data-API query contract: every filter operator gives known answers; $-params, select, sort, expand honored; malformed input 400s with the exact code", "since": "v15", "status": "active", - "revision": 7, + "revision": 8, "priority": "P0", "surface": "api", "personas": [ @@ -412,8 +412,8 @@ }, "steps": [ "boot the showcase on its own port and DB; as admin GET /api/v1/data/showcase_account with no filter and a generous $top — this unfiltered baseline is the local ground truth every operator answer is computed from (never hardcode counts; the seed can drift)", - "for each operator variant below, run BOTH spellings against showcase_account (or showcase_task where the type fits): the POST /api/v1/data/showcase_account/query body form (where: {field: {$op: value}}) and the AST array form ([field, op, value] — spellings per AST_OPERATOR_MAP); e.g. where {status: {$eq: 'active'}}, {annual_revenue: {$gt: 10000000}}, {status: {$in: ['active','prospect']}}, {annual_revenue: {$between: [5000000, 50000000]}}, {name: {$contains: 'orth'}}, {name: {$startsWith: 'Con'}}, {churn_reason: {$null: true}} — whose AST spellings ['churn_reason', 'is_null'] and ['churn_reason', 'is_not_null'] take no comparand", - "for each operator: compute the expected id set from the baseline locally and compare with the returned records — a known-answer check, not a smoke 200", + "for each operator variant below, run BOTH spellings against showcase_account (or showcase_task where the type fits): the POST /api/v1/data/showcase_account/query body form (where: {field: {$op: value}}) and the AST array form ([field, op, value] — spellings per AST_OPERATOR_MAP); e.g. where {status: {$eq: 'active'}}, {annual_revenue: {$gt: 10000000}}, {status: {$in: ['active','prospect']}}, {annual_revenue: {$between: [5000000, 50000000]}}, {name: {$contains: 'orth'}}, {name: {$startsWith: 'Con'}}, {churn_reason: {$null: true}} — whose AST spellings ['churn_reason', 'is_null'] and ['churn_reason', 'is_not_null'] take no comparand. A COMPOUND AST filter is written prefix-style, the logic keyword first: ['and', ['status', '=', 'active'], ['annual_revenue', '>', 10000000]] (lowerFilterAST, packages/spec/src/data/filter.zod.ts, reads a leading 'and' or 'or'). The infix form [cond, 'and', cond] is refused 400, so it is not the AST twin of a $and", + "for each operator: compute the expected id set from the baseline locally and compare with the returned records — a known-answer check, not a smoke 200. $contains, $notContains, $startsWith and $endsWith are CASE-SENSITIVE: they lower to a case-exact LIKE on the SQL family (CONTAINS_DESCRIPTION and its siblings in filter.zod.ts), so their local expectation compares case-exactly; $icontains (op:icontains) is the case-insensitive twin, and it folds ASCII case only", "pagination window: GET /api/v1/data/showcase_account?$orderby=name&$top=3&$skip=2 and verify it equals rows 3–5 of the locally sorted baseline; also POST body limit/offset/top equivalents", "selection: ?$select=name,status (and body fields: ['name','status']) — response objects carry exactly the requested keys — no system identity key is added, not even `id` (the SQL driver selects query.fields verbatim); then the dotted related column fields: ['account.name'] on showcase_invoice, which is REFUSED by design — 400 INVALID_FIELD whose message prescribes `expand` (a dotted path reaches the driver as a column name, matches none, and would fall back to every field)", "sort: $orderby / orderBy asc and desc on annual_revenue — full ordering compared to the locally sorted baseline, not just first row", @@ -578,7 +578,8 @@ "date": "2026-10-27", "change": "automated.ref names the HTTP pin for acceptance[4] and says what two named pins do and do not reach; the variants gain the six AST_OPERATOR_MAP operators the list omitted; steps[1] names the comparand-less null spellings. Checklist-accuracy findings of the 18.2.0 wave 2 (QA run #26468, release-18.2.0-w2-tier1-a, at 756220c4; wave anchor #25807, slice A): showcase-expand-crud-gate.dogfood.test.ts drives acceptance[4] over HTTP and was missing (so 'no clause has a pin at its oracle's layer' was false); request-schema-gate.conformance.test.ts has no collection-operator case; engine-filter-array-lowering asserts the engine's INVALID_FILTER, not the POST door's code; and AST_OPERATOR_MAP serves icontains, ilike, is_empty/is_not_empty and before/after beyond the list. Re-read at base e4ba846f (packages/spec/src/data/filter.zod.ts AST_OPERATOR_MAP). acceptance[8] now asks a verdict of six more rows", "ref": "#25807" - } + }, + { "revision": 8, "date": "2026-10-31", "change": "steps[1] names the prefix-form compound AST and steps[2] the case contract of the substring operators. Checklist-accuracy findings of QA run #27409 (18.3.0 sweep, wave 3, Tier 1 half A, at c4c515ad; sweep anchor #27377): the infix compound [cond, 'and', cond] answered 400 while the step showed only the single-condition form, and the substring operators matched case-exactly on the SQL driver while the step gave no case note. Re-read at base 488b3ed0: packages/spec/src/data/filter.zod.ts lowerFilterAST and the CONTAINS / NOT_CONTAINS / STARTS_WITH / ENDS_WITH / ICONTAINS descriptions. No clause changed", "ref": "#27409" } ] }, { @@ -586,7 +587,7 @@ "title": "Sampled endpoints return the standard error envelope with ledgered codes — no invented codes, no retired keys, statuses match the map", "since": "v16", "status": "active", - "revision": 7, + "revision": 8, "priority": "P1", "surface": "api", "personas": [ @@ -598,7 +599,7 @@ "app": "showcase", "requires": [ "showcase_invoice.name is required:true — the cheapest deterministic VALIDATION_FAILED", - "a member persona lacking showcase_announcement create (member_default: create false) — the deterministic 403. Stock showcase SEEDS two loginable non-admin personas for exactly this: Mei Phone (phone.demo@example.com) and Ada Auditor (auditor.demo@example.com), both with DEMO_PERSONA_PASSWORD = 'showcase123' (examples/app-showcase/src/security/demo-personas.ts, provisioned by seed-approval-demo.ts on every dev boot). Driven as Mei, POST /api/v1/data/showcase_announcement answers 403 PERMISSION_DENIED — a ledgered code — so the 403 family is reachable with no provisioning at all. POST /api/v1/auth/sign-up/email also works on a stock dev boot if a THIRD identity is wanted" + "a member persona lacking showcase_announcement create (member_default: create false) — the deterministic 403. Stock showcase SEEDS two loginable non-admin personas for exactly this: Mei Phone (phone.demo@example.com) and Ada Auditor (auditor.demo@example.com), both with DEMO_PERSONA_PASSWORD = 'showcase123' (examples/app-showcase/src/security/demo-personas.ts, provisioned by seed-approval-demo.ts on every dev boot). Driven as Mei, POST /api/v1/data/showcase_announcement answers 403 PERMISSION_DENIED — a ledgered code — so the 403 family is reachable with no provisioning at all. A THIRD identity is not a self-sign-up on stock: POST /api/v1/auth/sign-up/email answers 403 SELF_REGISTRATION_CLOSED, because the showcase declares no audience posture and the default is invite_only (packages/plugins/plugin-auth/src/audience-posture.ts). Mint it as the admin with POST /api/v1/auth/admin/create-user, or invite the address first: a pending invitation admits that address's own sign-up" ] }, "steps": [ @@ -713,7 +714,8 @@ "date": "2026-10-27", "change": "acceptance[3].clause stops calling OBJECT_NOT_FOUND and RECORD_NOT_FOUND extension codes. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26476, release-18.2.0-w2-manual-api, at 756220c4; wave anchor #25807, slice A): both are StandardErrorCode members (packages/spec/src/api/errors.zod.ts), while VALIDATION_FAILED is not. Neither is a floor code, so both still answer by family; the clause now sorts the codes into floor codes and the rest. Re-read at base e4ba846f. The assertion (status agrees with code) is unchanged", "ref": "#25807" - } + }, + { "revision": 8, "date": "2026-10-31", "change": "fixtures.requires[1] corrected from QA run #27392 (18.3.0 sweep, wave 3, at c4c515ad; sweep anchor #27377): it said sign-up/email works on a stock dev boot, but it answers 403 SELF_REGISTRATION_CLOSED under the invite_only default. The line now names the two doors that do mint a third identity. Re-read at base 488b3ed0: packages/plugins/plugin-auth/src/audience-posture.ts (an undeclared posture is invite_only; a pending invitation admits), packages/plugins/plugin-auth/src/admin-user-endpoints.ts (POST /admin/create-user). No clause changed", "ref": "#27392" } ] }, { @@ -2003,7 +2005,7 @@ "title": "Server-side aggregation contract: every AggregationFunction × DateGranularity gives known answers — ruled empty-set values, ISO-Monday week buckets, retired array_agg/string_agg refuse with prescriptions, and aggregate stays secured (engine-only, never raw driver)", "since": "v16", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "api", "personas": [ @@ -2021,7 +2023,7 @@ "why": "supplies acceptance[4]'s restricted persona: a contributor-bound member whose showcase_invoice read is its own rows only, so its grouped count must differ from the admin's. Without it the secured-aggregate clause has no discriminating persona on stock (the demo personas are refused at the object gate or read everything) and scores blocked(fixture)" }, "knownGaps": [ - "Stock seed dates may not spread signed_on across enough DISTINCT buckets for every granularity to be answer-discriminating (e.g. all rows in one ISO week proves week-bucketing resolved, not that it bucketed correctly). Same discipline as api-backend.date-range-preset-matrix: record which granularities were answer-discriminating vs merely well-formed; seeding a few rows at known date offsets during the run is the honest close — record it if done.", + "Stock seed dates may not spread signed_on across enough DISTINCT buckets for every granularity to be answer-discriminating (e.g. all rows in one ISO week proves week-bucketing resolved, not that it bucketed correctly). Same discipline as api-backend.date-range-preset-matrix: record which granularities were answer-discriminating vs merely well-formed; seeding rows at known values during the run is the honest close — record it if done. The stock seed discriminates neither the count leg nor the week leg on its own: all 14 stock showcase_account rows carry an annual_revenue, so count('*') equals count(annual_revenue), and their signed_on values are daysAgo() offsets from the boot day (examples/app-showcase/src/data/seed/index.ts), so which weekday or ISO year they hit moves every day. QA run #27392 closed both with five POSTs to /api/v1/data/showcase_account (each with a name and status 'active', the two required fields), whose discriminating rows are: one with no annual_revenue (the count-all vs non-null row acceptance[0].verify asks for); signed_on 2025-12-29, a Monday, and 2026-01-04, a Sunday, which share ISO week 2026-W01 but sit in different Sunday-start weeks; and 2027-01-01, a Friday, which lands in ISO week 2026-W53 (the ISO-year boundary). The null-date bucket needs no seeding: the stock prospects carry no signed_on. Record the seeded rows in the run env.", "Whether a given aggregate is answered by driver pushdown or by the in-memory path (packages/objectql/src/in-memory-aggregation.ts) depends on driver + timezone (tz≠UTC date buckets take the in-memory path per the #1982 note); the two paths must label identically (#3773/#3839 seam). Record which path answered when it is observable (via the timezone lever, which only POST /api/v1/analytics/query carries — steps[3]), and score the CONTRACT, which is path-independent." ] }, @@ -2030,8 +2032,8 @@ "boot the showcase on its own port and DB; as admin read the unfiltered showcase_account baseline (generous $top) — the local ground truth every expected value below is computed from", "function matrix: for each of the 6 functions POST /api/v1/data/showcase_account/query with { groupBy: ['status'], aggregations: [{ function, field: 'annual_revenue', alias: 'v' }] } (count also in its fieldless/'*' spelling), plus one UNgrouped run per function; compute each expected per-group value locally from the baseline and compare exactly — known answers, not smoke 200s. count_distinct over annual_revenue does not discriminate on the seeded rows (QA run #26476 measured every value distinct, so it equals count of the field): run count_distinct over `status` as well, where the distinct count is smaller than the row count. Add the #11152 leg: min/max over a boolean field answer the NUMBER it is worth (0/1, maintainer ruling 2026-08-28), never false/true", "granularity matrix: record wall clock + timezone, then for each of the 5 granularities POST { groupBy: [{ field: 'signed_on', dateGranularity }], aggregations: [{ function: 'count', alias: 'n' }] }; reconcile the returned bucket KEYS and counts against locally computed ISO-8601 buckets (bucketDateValue's own shapes: 'YYYY', 'YYYY-Qn', 'YYYY-MM', 'YYYY-MM-DD', 'YYYY-Wnn' with weeks starting Monday / week 1 = first Thursday); rows with null/unparseable dates must land in ONE real-null-keyed bucket (#3839 — never a '(null)' sentinel string). The TIMEZONE lever is not on this door: POST /api/v1/data/showcase_account/query refuses a `timezone` key 400 INVALID_FIELD (the protocol does not forward one; engine.aggregate reads only query.timezone). The one wire lever is POST /api/v1/analytics/query with `timezone` — on the stock cube showcase_delivery, a count measure over its time dimension due_date at granularity day, once at UTC and once in a negative-offset zone such as America/Los_Angeles (QA run #24595: the answer's data.sql is present on the driver path and absent on the in-memory one, and the SQL echo refuses a non-UTC bucket)", - "empty-set semantics: (a) a query whose where matches nothing, grouped and ungrouped; (b) a per-aggregation filter (#10576 — aggregations[].filter, SQL FILTER(WHERE …) semantics) that empties one measure while a sibling measure keeps the full bucket. Expected per the platform ruling (emptyGroupValueFor, packages/spec/src/data/aggregation-policy.ts): count/count_distinct/sum → 0, avg/min/max → null — a 0 flattened onto avg (or a null on count) is a FAIL either direction (objectui#3136)", - "negatives: aggregations [{ function: 'array_agg', field: 'name', alias: 'x' }] and the string_agg twin → 400 with the RETIREMENT prescriptions (each message ends with the 'os migrate meta --from 16' instruction; only those two spellings get it); an unknown function ('median') → 400 whose issue lists the six legal functions (zod's own enum error, deliberately NOT the retirement text — telling a typo'd author their value 'was removed' would misinform); aggregations naming a nonexistent field → the located protocol refusal (protocol.ts assertAggregationFieldsExist); aggregations as a non-array → on this door the REQUEST SCHEMA answers first: 400 VALIDATION_FAILED with a fields[] entry located at query.aggregations ('expected array'). The protocol's worked-example refusal (assertAggregationFieldsExist: 'must be an array of { function, field?, alias } entries') is not reached through POST /query — the #20116 door posture api-backend.filter-comparand-conformance records", + "empty-set semantics: (a) a query whose where matches nothing, UNgrouped, where the identities come back as the one result row. Grouped, an empty where answers NO groups at all, because there is no bucket to carry a value, so the per-group identities are read on path (b); (b) a per-aggregation filter (#10576 — aggregations[].filter, SQL FILTER(WHERE …) semantics) that empties one measure while a sibling measure keeps the full bucket. Expected per the platform ruling (emptyGroupValueFor, packages/spec/src/data/aggregation-policy.ts): count/count_distinct/sum → 0, avg/min/max → null — a 0 flattened onto avg (or a null on count) is a FAIL either direction (objectui#3136)", + "negatives: aggregations [{ function: 'array_agg', field: 'name', alias: 'x' }] and the string_agg twin → 400 with the RETIREMENT prescriptions (each message says there is no replacement in the query vocabulary and ends with the shared retirement tail, AGG_RETIRED_TAIL in query.zod.ts: 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; only those two spellings get it); an unknown function ('median') → 400 whose issue lists the six legal functions (zod's own enum error, deliberately NOT the retirement text — telling a typo'd author their value 'was removed' would misinform); aggregations naming a nonexistent field → the located protocol refusal (protocol.ts assertAggregationFieldsExist); aggregations as a non-array → on this door the REQUEST SCHEMA answers first: 400 VALIDATION_FAILED with a fields[] entry located at query.aggregations ('expected array'). The protocol's worked-example refusal (assertAggregationFieldsExist: 'must be an array of { function, field?, alias } entries') is not reached through POST /query — the #20116 door posture api-backend.filter-comparand-conformance records", "secured-aggregate leg: run one grouped count (count by status on showcase_invoice) as the provisioned contributor-bound member, whose visible row set provably differs from the admin's; compute their expectation from THEIR OWN filtered baseline. The rule under test is the one packages/runtime/src/action-execution.ts states for the aggregate door: aggregate MUST run through the ObjectQL engine (RLS/tenant middleware + the FLS aggregate-input gate) — a raw driver.aggregate() evaluates over every row" ], "acceptance": [ @@ -2048,13 +2050,13 @@ "evidence": "the 5-row bucket table + the recorded clock/zone" }, { - "clause": "empty-set values are the RULED identities, on both the empty-where and the #10576 measure-scoped-filter paths: count/count_distinct/sum → 0 (measured facts), avg/min/max → null (undefined, never flattened to a zero that reads as a measurement) — matching emptyGroupValueFor, the contract in-memory-aggregation.ts implements", + "clause": "empty-set values are the RULED identities, on both the empty-where path (ungrouped, since a grouped empty where answers no groups) and the #10576 measure-scoped-filter path (per group): count/count_distinct/sum → 0 (measured facts), avg/min/max → null (undefined, never flattened to a zero that reads as a measurement) — matching emptyGroupValueFor, the contract in-memory-aggregation.ts implements", "oracle": "api", "verify": "the six per-function empty-set values on both paths; a sibling unfiltered measure in the same request keeps its full-bucket value (the #10576 isolation half)", "evidence": "both responses + the expected-value table" }, { - "clause": "retired functions refuse with their PRESCRIPTIONS and unknown functions with the enum listing: array_agg/string_agg answer 400 carrying their registered retirement messages (query.zod.ts — no replacement exists; the message says to reshape and names 'os migrate meta --from 16'), while an invented function gets zod's own enum error listing the six legal members — the two error classes must not blur (the enum error map returns the retirement text ONLY for the two spellings that used to be legal)", + "clause": "retired functions refuse with their PRESCRIPTIONS and unknown functions with the enum listing: array_agg/string_agg answer 400 carrying their registered retirement messages (query.zod.ts — no replacement exists; the message says to reshape, and its AGG_RETIRED_TAIL says `os migrate meta` no longer carries the protocol-16.x conversion and names the protocol upgrade guide, https://objectstack.ai/docs/protocol-upgrade), while an invented function gets zod's own enum error listing the six legal members — the two error classes must not blur (the enum error map returns the retirement text ONLY for the two spellings that used to be legal)", "oracle": "api", "verify": "the three rejection bodies: two carry the retirement prescriptions, the third lists count/sum/avg/min/max/count_distinct; all are 400, never 500", "evidence": "the three rejections" @@ -2146,15 +2148,16 @@ "date": "2026-10-27", "change": "steps[2] names a field that discriminates count_distinct. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26476, release-18.2.0-w2-manual-api, at 756220c4; wave anchor #25807, slice A): over annual_revenue every seeded value is distinct, so count_distinct equals the per-field count and a broken distinct would pass. No clause changed", "ref": "#25807" - } + }, + { "revision": 7, "date": "2026-10-31", "change": "knownGaps[0], steps[4], steps[5], acceptance[2] and acceptance[3] corrected from QA run #27392 (18.3.0 sweep, wave 3, at c4c515ad; sweep anchor #27377). (1) The retirement message no longer names 'os migrate meta --from 16': both refusals end with AGG_RETIRED_TAIL, which names `os migrate meta` and the protocol upgrade guide URL, and steps[5] and acceptance[3] now say so. (2) A grouped query whose where matches nothing answers no groups, so the empty-where identities are read ungrouped and the per-group ones on the #10576 filter path; steps[4](a) and acceptance[2] said 'grouped and ungrouped'. (3) knownGaps[0] names the seeded rows that made the count and week legs discriminating. Re-read at base 488b3ed0: packages/spec/src/data/query.zod.ts AGG_RETIRED_TAIL, examples/app-showcase/src/data/seed/index.ts (14 accounts, all with annual_revenue). No clause's assertion was weakened", "ref": "#27392" } ] }, { "id": "api-backend.formula-stdlib-matrix", - "title": "CEL formula stdlib: all 29 registered functions give known answers against the BUILT package — calendar-day timezone contract, addMonths month-end clamp, floor(-1.2)==-2, null-dropping joins, mixed int/double arithmetic, isoDate/isoDatetime text — and a formula calling an unregistered function is REFUSED at the authoring door (422), never stored to read null", + "title": "CEL formula stdlib: all 30 bare-callable stdlib functions give known answers against the BUILT package — calendar-day timezone contract, addMonths month-end clamp, floor(-1.2)==-2, null-dropping joins, mixed int/double arithmetic, isoDate/isoDatetime text — and a formula calling an unregistered function is REFUSED at the authoring door (422), never stored to read null", "since": "v16", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "mixed", "personas": [ @@ -2165,27 +2168,27 @@ "app": "showcase", "requires": [ "the two stock formula fields for the live leg: showcase_field_zoo.f_formula (field-zoo.object.ts) and showcase_project.budget_remaining (project.object.ts)", - "runtime metadata authoring (PUT /meta/object on a scratch object) for the stdlib-through-the-live-door leg and the unregistered-function probe. Every scratch object body declares a `sharingModel` (e.g. 'private'): an active-state object save without one is refused 422 security-owd-unset (packages/lint/src/validate-security-posture.ts, run on every active object publish) before any formula is judged" + "runtime metadata authoring for the stdlib-through-the-live-door leg and the unregistered-function probe, into a WRITABLE package (the showcase package is read-only): install a scratch package that DECLARES its namespace — POST /api/v1/packages { manifest: { id: 'com.objectstack.qa.formula', name: 'QA formula fixture', version: '1.0.0', type: 'app', namespace: 'qaformula' }, enableOnInstall: true } — then PUT /api/v1/meta/object/qaformula_NAME?package=com.objectstack.qa.formula. The object name carries the namespace prefix, or the save answers 422 NAMESPACE_PREFIX (a manifest with no namespace gets one derived from the id's last segment). Every scratch object body declares a `sharingModel` (e.g. 'private'): an active-state object save without one is refused 422 security-owd-unset (packages/lint/src/validate-security-posture.ts, run on every active object publish) before any formula is judged" ], "knownGaps": [ - "The stock formulas exercise only null-guard ternaries and arithmetic — NOT the stdlib catalog. The 29-function matrix therefore runs in a harness against the BUILT package (the same discipline as api-backend.formula-gates), and the live-door leg proves the same evaluator serves reads, not each function individually; a scratch formula field authored through the metadata channel carries two representative stdlib calls onto the live path. Record which oracle each function's verdict rests on." + "The stock formulas exercise only null-guard ternaries and arithmetic — NOT the stdlib catalog. The 30-function matrix therefore runs in a harness against the BUILT package (the same discipline as api-backend.formula-gates), and the live-door leg proves the same evaluator serves reads, not each function individually; a scratch formula field authored through the metadata channel carries two representative stdlib calls onto the live path. Record which oracle each function's verdict rests on." ] }, "steps": [ - "count the registration chain: packages/formula/src/stdlib.ts registerStdLib registers exactly 29 bare-callable functions by chained registerFunction call — now, today, daysFromNow, daysAgo, isBlank, coalesce, trim, joinNonEmpty, daysBetween, addDays, addMonths, date, datetime, isoDate, isoDatetime, abs, round, floor, ceil, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty (isoDate and isoDatetime since #22347). The receiver method `can` (current_user.can(object, verb)), which registerPermissionPredicate adds at the head of registerStdLib, is the permission predicate, not a bare-callable catalog function, and is outside this count — validate.ts's CEL_STDLIB_FUNCTIONS docblock tallies the same 29. NOT enumSource-pinnable (a builder chain, not a named z.enum) — re-count the calls in source at run time and fail the matrix stale if the count moved; cross-check the docs catalog (content/docs/data-modeling/formulas.mdx) advertises the same set, no more, no fewer", + "count the registration chain: packages/formula/src/stdlib.ts registerStdLib (through registerBoundStdLib) registers exactly 30 bare-callable functions: 29 by chained registerFunction call — now, today, daysFromNow, daysAgo, isBlank, coalesce, trim, joinNonEmpty, daysBetween, addDays, addMonths, date, datetime, isoDate, isoDatetime, abs, round, floor, ceil, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty (isoDate and isoDatetime since #22347) — plus the MACRO label(record.), which bindOptionLabel registers at the head of the chain (#26608). The receiver methods `can` (current_user.can(object, verb)) and `hasCapability`, which bindPermissionPredicate and bindCapabilityPredicate add at the same head, are permission predicates, not bare-callable catalog functions, and are outside this count — validate.ts's CEL_STDLIB_FUNCTIONS docblock tallies the same 30 ('30 added by registerStdLib'). NOT enumSource-pinnable (a builder chain, not a named z.enum) — re-count the calls in source at run time and fail the matrix stale if the count moved; cross-check the docs catalog (content/docs/data-modeling/formulas.mdx) advertises the same set, no more, no fewer (its table lists label(record.f) last)", "build the workspace (or confirm dist freshness — the stale-dist trap is the known false-negative here, same as api-backend.formula-gates); the harness must import the BUILT package and record the resolved module path", - "drive every one of the 29 with a pinned now and a known-answer expectation, at minimum: floor(-1.2)==-2 and ceil(-1.2)==-1 (round toward −∞/+∞, NOT toward zero); addMonths(date('2026-01-31'), 1) == 2026-02-28T00:00:00Z (month-end clamp, never a March overflow); daysBetween(a,b) sign convention (b−a, negative when b earlier); date()/datetime() alias parity on the same ISO input; isoDate(date('2026-02-28T13:45:00Z'))=='2026-02-28' and isoDatetime(date('2026-02-28T13:45:00Z'))=='2026-02-28T13:45:00.000Z' (the UTC calendar), isoDate(today()) under the boundary zone below == that zone's calendar day, and text refused (isoDate('2026-01-01') faults no matching overload — write isoDate(date(s))); coalesce(null,'x')=='x' but coalesce('', 'x')=='' (null/undefined only — from CEL only the null half is authorable: the function treats undefined like null, but an absent record key faults 'No such key' before coalesce runs, so the undefined arm is the harness table's, not an expression's); joinNonEmpty([record.a, record.b, record.c, record.d], '-')=='a-b' with record {a: 'a', b: null, c: ' ', d: 'b'} (null AND blank-after-trim dropped — written through record fields, because cel-js refuses a list LITERAL that mixes string and null at type-check, 'List elements must have the same type', so the literal ['a', null, ' ', 'b'] never evaluates); isBlank vs isEmpty on null/''/[]; trim(null)==''; upper/lower/contains/startsWith/endsWith on mixed input; matches regex truthiness; len over string/list/map; min/max returning the SMALLER/LARGER OPERAND VERBATIM (type preserved, numeric comparison); abs/round; now() === the pinned instant", + "drive every one of the 30 with a pinned now and a known-answer expectation, at minimum: floor(-1.2)==-2 and ceil(-1.2)==-1 (round toward −∞/+∞, NOT toward zero); addMonths(date('2026-01-31'), 1) == 2026-02-28T00:00:00Z (month-end clamp, never a March overflow); daysBetween(a,b) sign convention (b−a, negative when b earlier); date()/datetime() alias parity on the same ISO input; isoDate(date('2026-02-28T13:45:00Z'))=='2026-02-28' and isoDatetime(date('2026-02-28T13:45:00Z'))=='2026-02-28T13:45:00.000Z' (the UTC calendar), isoDate(today()) under the boundary zone below == that zone's calendar day, and text refused (isoDate('2026-01-01') faults no matching overload — write isoDate(date(s))); coalesce(null,'x')=='x' but coalesce('', 'x')=='' (null/undefined only — from CEL only the null half is authorable: the function treats undefined like null, but an absent record key faults 'No such key' before coalesce runs, so the undefined arm is the harness table's, not an expression's); joinNonEmpty([record.a, record.b, record.c, record.d], '-')=='a-b' with record {a: 'a', b: null, c: ' ', d: 'b'} (null AND blank-after-trim dropped — written through record fields, because cel-js refuses a list LITERAL that mixes string and null at type-check, 'List elements must have the same type', so the literal ['a', null, ' ', 'b'] never evaluates); isBlank vs isEmpty on null/''/[]; trim(null)==''; upper/lower/contains/startsWith/endsWith on mixed input; matches regex truthiness; len over string/list/map; min/max returning the SMALLER/LARGER OPERAND VERBATIM (type preserved, numeric comparison); abs/round; now() === the pinned instant; label(record.kind) over record {kind: 'clean'} with optionLabels {kind: {clean: 'Clean'}} on the EvalContext == 'Clean', the same call with no optionLabels refused at run time (never the stored value 'clean'), and label() of a field the map declares no options for refused (packages/formula/src/option-label.test.ts pins these shapes)", "calendar-day timezone contract (ADR-0053 Phase 2 D1): pin now to an instant whose calendar day DIFFERS between UTC and the reference zone (e.g. 2026-08-30T22:00:00Z with timezone Asia/Shanghai — locally already 08-31) and assert today() == 2026-08-31T00:00:00Z (the reference-tz calendar day expressed as UTC MIDNIGHT — never an instant carrying wall-clock time), daysFromNow/daysAgo offset from that same day; an unknown zone falls back to the UTC calendar day, never throws", "mixed-arithmetic overloads (registerNumericCoercions, stdlib.ts): a double-typed record field divided/multiplied by an int literal (record.amount / 100) evaluates to the promoted double instead of faulting 'no such overload' to silent null (#1928), while pure int 7/2 == 3 keeps integer-division semantics untouched", "live-door parity: GET a showcase_project row and confirm budget_remaining materializes (read-path evaluation); author a scratch object through the metadata channel, declaring its sharingModel (fixtures.requires[1]), whose formula field composes two stdlib calls (e.g. joinNonEmpty + upper) and read back the known answer — the stdlib reaching the LIVE read path, not just the harness", - "the unregistered-function probe: on a scratch object, PUT /api/v1/meta/object/NAME at the ACTIVE door (no ?mode=draft) with a formula field calling an UNregistered function (e.g. bad_f = sqrt(4)), its body declaring a sharingModel (fixtures.requires[1]) — without one the 422 is security-owd-unset's refusal, not the expression's. Capture the 422 INVALID_METADATA and its expression-invalid issue, then GET the object and confirm the field was not stored. A ?mode=draft save of the same body is admitted (draft saves defer the gate to promotion, #4463 D1), and its draft→active promotion is refused with the same expression-invalid finding. Promoted through POST /api/v1/packages/PACKAGE/publish-drafts, that refusal is an entry with outcome 'refused' inside the batch envelope, not the request's own 422 (packages/metadata-protocol/src/protocol.ts, the publish-drafts outcome union). What the door cannot see is unchanged underneath: a formula that compiles but faults at EVALUATE time, or a row stored before the gate, still reads null (applyFormulaPlan's `r.ok ? … : null`, packages/objectql/src/engine.ts), now with one warn line per (object, field) (#22031)" + "the unregistered-function probe: on a scratch object in the scratch package (fixtures.requires[1]), PUT /api/v1/meta/object/qaformula_NAME?package=com.objectstack.qa.formula at the ACTIVE door (no ?mode=draft) with a formula field calling an UNregistered function (e.g. bad_f = sqrt(4)), its body declaring a sharingModel (fixtures.requires[1]) — without one the 422 is security-owd-unset's refusal, not the expression's. Capture the 422 INVALID_METADATA and its expression-invalid issue, then GET the object and confirm the field was not stored. A ?mode=draft save of the same body is admitted (draft saves defer the gate to promotion, #4463 D1), and its draft→active promotion is refused with the same expression-invalid finding. Promoted through POST /api/v1/packages/PACKAGE/publish-drafts, that refusal is an entry with outcome 'refused' inside the batch envelope, not the request's own 422 (packages/metadata-protocol/src/protocol.ts, the publish-drafts outcome union). What the door cannot see is unchanged underneath: a formula that compiles but faults at EVALUATE time, or a row stored before the gate, still reads null (applyFormulaPlan's `r.ok ? … : null`, packages/objectql/src/engine.ts), now with one warn line per (object, field) (#22031)" ], "acceptance": [ { - "clause": "all 29 advertised functions evaluate to their known answers against the BUILT package — the matrix is exhaustive over the hand-counted registration chain (registerStdLib's 29 bare-callable registerFunction calls; the receiver method `can` is the permission predicate, not a catalog function), and the docs catalog matches it in both directions (an advertised-but-unregistered function or a registered-but-undocumented one each fail this clause)", + "clause": "all 30 advertised functions evaluate to their known answers against the BUILT package — the matrix is exhaustive over the hand-counted registration chain (registerStdLib's 29 chained registerFunction calls plus the label() macro; the receiver methods `can` and `hasCapability` are permission predicates, not catalog functions), and the docs catalog matches it in both directions (an advertised-but-unregistered function or a registered-but-undocumented one each fail this clause)", "oracle": "test", "verify": "harness output: one known-answer row per function, resolved module path pointing into dist/node_modules; the source count and the docs-table cross-check recorded", - "evidence": "the 29-row harness table + the resolved path + the two counts" + "evidence": "the 30-row harness table + the resolved path + the two counts" }, { "clause": "the calendar-day trio (today/daysFromNow/daysAgo) resolves to the REFERENCE-TIMEZONE calendar day expressed as UTC midnight (ADR-0053 D1) — proven with an instant whose calendar day differs between UTC and the zone, so a UTC-only implementation cannot pass by accident; unknown zone falls back to UTC without throwing", @@ -2229,8 +2232,8 @@ "timezone-boundary" ], "source": [ - "packages/formula/src/stdlib.ts#registerStdLib (registerStdLib — the 29 chained registerFunction calls, isoDate / isoDatetime since #22347; the receiver method `can`, from registerPermissionPredicate, is outside the count; calendarDayUtc ADR-0053 D1; addMonthsUtc clamp; the floor/ceil #3306 note), (registerNumericCoercions #1928)", - "packages/formula/src/validate.ts#CEL_STDLIB_FUNCTIONS (the advertised bare-callable list; its docblock tallies the 29 registerStdLib adds and why a receiver-only name such as `can` is never in it)", + "packages/formula/src/stdlib.ts#registerStdLib (registerStdLib — the 29 chained registerFunction calls, isoDate / isoDatetime since #22347, plus label(), the macro bindOptionLabel registers (#26608): 30 bare-callable; the receiver methods `can` and `hasCapability`, from bindPermissionPredicate and bindCapabilityPredicate, are outside the count; calendarDayUtc ADR-0053 D1; addMonthsUtc clamp; the floor/ceil #3306 note), (registerNumericCoercions #1928)", + "packages/formula/src/validate.ts#CEL_STDLIB_FUNCTIONS (the advertised bare-callable list; its docblock tallies the 30 registerStdLib adds and why a receiver-only name such as `can` is never in it)", "packages/lint/src/authoring-rules.ts#EXPRESSION_INVALID (the expression-invalid rule the object save door now dispatches on a formula field, #22031) + packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts (the #22019 block: sqrt(record.amount) refused 422 INVALID_METADATA on an active save and on a draft's promotion, nothing lands) + packages/lint/src/runtime-gate.object-formula-writes.test.ts + packages/objectql/src/engine-formula-fault-log.test.ts (the read-path residual: a pre-gate row still reads null, one warn line per object and field)", "packages/objectql/src/engine.ts#applyFormulaPlan (applyFormulaPlan — evaluate-fault → null, the silent-null contract; the evaluateFormulaField docblock states the compile-throws vs evaluate-nulls split)", "content/docs/data-modeling/formulas.mdx (the advertised catalog the matrix cross-checks)", @@ -2269,7 +2272,8 @@ "date": "2026-10-27", "change": "steps[6] and acceptance[5] say how the draft's promotion is refused. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26456, release-18.2.0-w2-manual-browser, at 756220c4; wave anchor #25807, slice A): the run promoted through publish-drafts and met the refusal as an outcome:'refused' entry inside the batch envelope, while 'refused the same way' read as a second HTTP 422. Re-read at base e4ba846f (metadata-protocol protocol.ts: outcome 'published' | 'refused' | 'nothing_to_publish'). The clause's assertion (refused, nothing lands) is unchanged", "ref": "#25807" - } + }, + { "revision": 6, "date": "2026-10-31", "change": "The function count is 30, not 29, and the scratch-object legs name their writable package. Re-point of the red row QA run #27441 (18.3.0 sweep, wave 3, manual set, at c4c515ad) parked on sweep anchor #27377 §1, plus its fixture finding. (1) The registry gained label(record.f), a macro bindOptionLabel registers beside the permission predicates, and the docs table lists it, so the title, steps[0], steps[2], knownGaps[0], acceptance[0] (clause and evidence) and source[0]/[1] count 30, steps[2] adds the label() known-answer rows, and the receiver-only names outside the count are `can` and `hasCapability` (registerPermissionPredicate is now bindPermissionPredicate). (2) fixtures.requires[1] and steps[6]: a runtime object save needs a writable package and its namespace prefix as well as a sharingModel. Re-read at base 488b3ed0: packages/formula/src/stdlib.ts registerBoundStdLib and bindOptionLabel, packages/formula/src/validate.ts CEL_STDLIB_FUNCTIONS docblock ('30 added by registerStdLib'), content/docs/data-modeling/formulas.mdx, packages/metadata-protocol/src/protocol-authoring-gate.ts (NAMESPACE_PREFIX). The clause still fails on any count drift in either direction", "ref": "#27441" } ] }, { @@ -2277,7 +2281,7 @@ "title": "enable.apiMethods three-state whitelist on the REST boundary: 404 hides apiEnabled:false, 405 OBJECT_API_METHOD_NOT_ALLOWED carries the EFFECTIVE derived set, bulk∧child + writeMode-precise import, legacy 8-value strip-and-WARN (deny-all cliff), the object gate answers ahead of the caller's 403, batch gated per-op before the txn", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -2300,8 +2304,8 @@ "free negatives, as ADMIN (the gate keys on the object, not the caller): PATCH /api/v1/data/sys_session/{id} → 405 OBJECT_API_METHOD_NOT_ALLOWED with the allowed[] array; GET /api/v1/data/sys_device_code (list against a get-only whitelist) → 405; GET /api/v1/data/sys_oauth_consent → 404 OBJECT_API_DISABLED (existence hidden); capture full bodies", "ordering leg: repeat the sys_session PATCH as the NON-ADMIN member and compare with the admin's 405 — the object-level gate answers before any caller-level 403 (the identity-auth.session-list-revoke precedent, '405 before 403'); the anonymous twin answers the 401 floor first", "scratch subset leg: author qa_verb_gate_probe with enable.apiMethods ['get','list','create']; verify update/delete → 405 whose allowed[] is the EFFECTIVE closure in API_OPERATION_ORDER (get, list, create + derived aggregate/search/export/import — search only while the object's `searchable` is not false, api-derivation.ts — and NEVER restore/purge, and NOT the raw 3-element whitelist); POST createMany → refused (bulk not granted; the createMany body is a bare JSON array of records, not { records }); re-author adding 'bulk' → createMany passes the gate, updateMany still refused (bulk∧child: the child verb must itself be granted)", - "import precision leg (on the scratch object granting create but not update): the import door with writeMode insert clears the gate, writeMode update → 405 (rest-server.ts — the writeMode-precise check, not the coarse create∨update)", - "legacy strip leg: author enable.apiMethods ['upsert'] and capture BOTH the parse-time warning (object.zod.ts — names the FROM→TO guidance 'declare [create,update] — upsert derives from create ∧ update' AND the deny-all cliff sentence, since stripping leaves []) and the runtime effect (every op → 405, allowed: []); then ['get','history'] → kept ['get'], warn names the history derivation; the registration-time diagnostic (packages/objectql/src/registry.ts warnStrippedLegacyApiMethods) adds the per-object line for schemas that skip Zod", + "import precision leg (on the scratch object granting create but not update): POST /api/v1/data/qa_verb_gate_probe/import with { format: 'json', rows: [{ name: 'qa-import-1' }], writeMode: 'insert' } clears the gate, and the same body with writeMode 'update' and matchFields: ['name'] → 405 (rest-server.ts — the writeMode-precise check, not the coarse create∨update). ⚠️ The body is parsed BEFORE the precise gate (packages/rest/src/data-action-routes.ts runs prepareImportRequest, then re-gates on the resolved writeMode): a body without format 'json' and a rows[] array, such as { writeMode, records }, and an update or upsert without a non-empty matchFields[], answer 400 INVALID_REQUEST from the parser — a refusal that reads like the gate and is not it", + "legacy strip leg: author enable.apiMethods ['upsert'] and capture BOTH the parse-time warning (object.zod.ts — names the FROM→TO guidance 'declare [create,update] — upsert derives from create ∧ update' AND the deny-all cliff sentence, since stripping leaves []) and the runtime effect (every op → 405, allowed: []); then ['get','history'] → kept ['get'], warn names the history derivation; the registration-time diagnostic (packages/objectql/src/registry.ts warnStrippedLegacyApiMethods) adds the per-object line for schemas that skip Zod. ⚠️ Both warnings print ONCE per process: the parse-time one once per distinct legacy combination (stripLegacyApiMethods, object.zod.ts), the [Registry] line once per object name (warnedLegacyApiMethods, packages/objectql/src/registry.ts). Author each legacy leg on its OWN scratch object name (e.g. qa_verb_legacy_upsert and qa_verb_legacy_history) and read each warning at its first occurrence in the boot log; re-authoring a name or a combination already warned about prints nothing, which is the dedupe, not a missing warning", "batch leg: POST /api/v1/batch with [valid create on showcase_task, update on sys_session]; the whole batch is refused by the per-op gate BEFORE the transaction opens (batch-routes.ts) with 405 OBJECT_API_METHOD_NOT_ALLOWED naming `bulk` — the route's own gate (a cross-object batch is a bulk write, checked as bulk ∧ the row's verb), which sys_session never grants — not the row's `update`; the valid sibling row never lands", "both-sides control: GET and list on sys_session as an entitled caller still answer 200 — the whitelist GRANTS what it lists" ], @@ -2401,7 +2405,8 @@ "date": "2026-10-27", "change": "steps[3] names the searchable flag on the derived search verb and the createMany body shape. Checklist-accuracy findings of the 18.2.0 wave 2 (QA run #26476, release-18.2.0-w2-manual-api, at 756220c4; wave anchor #25807, slice A): the closure carries search only when searchable is not false, and the createMany door takes a bare array. Re-read at base e4ba846f (packages/spec/src/data/api-derivation.ts: the search row's flag; packages/rest/src/batch-routes.ts: 'the body must be a JSON array of record objects'). No clause changed", "ref": "#25807" - } + }, + { "revision": 4, "date": "2026-10-31", "change": "steps[4] spells the import body and steps[5] names the warn-once dedupe. Checklist-accuracy findings of QA run #27392 (18.3.0 sweep, wave 3, at c4c515ad; sweep anchor #27377). (1) The import leg gave no body: the door parses {format:'json', rows:[…], writeMode} (plus matchFields[] for update and upsert) before its precise gate, and a malformed body answers 400 INVALID_REQUEST, which reads like the gate. (2) The [Registry] stripped-legacy warning fires once per object name per process, and the parse warning once per legacy combination, so a second legacy leg on the same name shows no warning. Re-read at base 488b3ed0: packages/rest/src/import-prepare.ts prepareImportRequest, packages/rest/src/data-action-routes.ts (the import route's stage-2 gate), packages/objectql/src/registry.ts warnStrippedLegacyApiMethods, packages/spec/src/data/object.zod.ts stripLegacyApiMethods. No clause changed", "ref": "#27392" } ] }, { From 8de73450a82ff0ca535c7c0aa4c4440674bda836 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:21:21 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(qa):=20wave-3=20accuracy=20slice=20S6?= =?UTF-8?q?=20=E2=80=94=20automation=20items=20(wip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01QUs3BvdUqqhcRdQJHjUSXA Co-authored-by: Claude --- .../platform-checklist/areas/automation.json | 126 +++++++++++++----- 1 file changed, 92 insertions(+), 34 deletions(-) diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 9fd049c3b13..076137f16f2 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -6,9 +6,9 @@ "fixtures": { "$comment": "AREA-LEVEL standing fixtures. An item opts in with fixtures.provisioning = { use: \"\", why: \"…\" }; the recipe is written ONCE here so several items do not carry drifting copies of the same call sequence. Recipes are runtime-provisioned (no repo file is touched, nothing to seed) and every call is grounded in framework source at the cited file:line — replay them literally, and if one 4xxs, re-read the citation before assuming the recipe rotted. Shape copied from qa-scratch-authz in areas/attachments-storage.json (#7716/#7670).", "qa-flow-status-doors": { - "title": "Two scratch probe flows (qa_no_start_node / qa_always_fails) in a scratch package, so every never-dispatched and ran-and-failed row of the #9378 status table has a target, plus a store-unreadable fault arm (step 5) for the resume door's 503 STORE_UNAVAILABLE row", - "why": "Stock showcase can reach four rows of the trigger table on its own — 404 (any unheld name), 409 (toggle showcase_urgent_task_alert OFF), 200 success, and 200 PAUSED (showcase_reassign_wizard) — but NOT the two that need a purpose-built definition. 422 FLOW_NO_START_NODE needs a flow whose `nodes` array holds no `start` node, and 400 FLOW_FAILED needs one that reaches a node and fails TERMINALLY: the showcase's only deterministic failure, showcase_resilient_sync, is wrapped in a try_catch that HANDLES it, so the run succeeds and the 400 row is never exercised. Without this recipe those two rows are blocked(fixture) and a regression that reverted either one leaves the area green. The resume door's 503 STORE_UNAVAILABLE row has no stock trigger either: it needs the durable suspended-run store to fail a read while the server and the resume route stay up, which no request can cause. Step 5 makes that read fail for one resume, out of process, and puts the store back; without it the row is blocked(fixture), as it scored in both 18.2.0 waves (QA run #25817, and the wave-2 run #26476).", - "provenance": "authored from source in the 17.1.0 post-release sweep (#10236); the scratch-package authoring path is the one automation.flow-error-handling already declares ('a writable/scratch package to author the UNPROTECTED probe flow into — the showcase itself ships read-only') and the call shapes are qa-scratch-authz's (#7670). Proven by a live run in the 18.2.0 sweep (QA run #25817, at 6679644a): every step answered 2xx, and the 422 and 400 rows answered at both trigger routes; the four stock rows were first hand-verified in #10225. Step 5 was added and proven by #26622 on a fresh showcase boot at d31329f1 (2026-10-27): the resume answered 503 STORE_UNAVAILABLE while the table was away, and after the rename back the run read paused and a valid resume answered 200.", + "title": "Three scratch probe flows (qa_no_start_node / qa_always_fails / qa_input_schema_invalid) in a scratch package, so every never-dispatched and ran-and-failed row of the #9378 status table has a target, plus a store-unreadable fault arm (step 5) for the resume door's 503 STORE_UNAVAILABLE row", + "why": "Stock showcase can reach four rows of the trigger table on its own — 404 (any unheld name), 409 (toggle showcase_urgent_task_alert OFF), 200 success, and 200 PAUSED (showcase_reassign_wizard) — but NOT the two that need a purpose-built definition. 422 FLOW_NO_START_NODE needs a flow whose `nodes` array holds no `start` node, and 400 FLOW_FAILED needs one that reaches a node and fails TERMINALLY: the showcase's only deterministic failure, showcase_resilient_sync, is wrapped in a try_catch that HANDLES it, so the run succeeds and the 400 row is never exercised. Without this recipe those two rows are blocked(fixture) and a regression that reverted either one leaves the area green. The resume door's 503 STORE_UNAVAILABLE row has no stock trigger either: it needs the durable suspended-run store to fail a read while the server and the resume route stay up, which no request can cause. Step 5 makes that read fail for one resume, out of process, and puts the store back; without it the row is blocked(fixture), as it scored in both 18.2.0 waves (QA run #25817, and the wave-2 run #26476). The 422 FLOW_INPUT_SCHEMA_INVALID row needs a third definition, a node whose own config violates the inputSchema it declares, which no stock flow carries; step 6 authors it, and without it that row is blocked(fixture).", + "provenance": "authored from source in the 17.1.0 post-release sweep (#10236); the scratch-package authoring path is the one automation.flow-error-handling already declares ('a writable/scratch package to author the UNPROTECTED probe flow into — the showcase itself ships read-only') and the call shapes are qa-scratch-authz's (#7670). Proven by a live run in the 18.2.0 sweep (QA run #25817, at 6679644a): every step answered 2xx, and the 422 and 400 rows answered at both trigger routes; the four stock rows were first hand-verified in #10225. Step 5 was added and proven by #26622 on a fresh showcase boot at d31329f1 (2026-10-27): the resume answered 503 STORE_UNAVAILABLE while the table was away, and after the rename back the run read paused and a valid resume answered 200. Step 6 was added from QA run #27392 (18.3.0 sweep, wave 3, at c4c515ad), which staged the 422 FLOW_INPUT_SCHEMA_INVALID row inside this package with an assignment node whose inputSchema types `assignments` as a string while its config gives an object, and read 422 at both trigger routes and at the /actions door.", "app": "showcase", "requires": [ "an admin session holding `manage_metadata` — PUT /api/v1/meta/:type/:name is capability-gated per ADR-0066 D1 (#6603), so a bare authenticated session is NOT enough", @@ -70,6 +70,58 @@ "call": "fault arm for automation.trigger-status-contract acceptance[4]'s 503 STORE_UNAVAILABLE row, run AT that row and never up front: with a live paused showcase_reassign_wizard run (RUNID) in hand, rename the durable suspended-run table away in the boot's TELEMETRY database from a second process — node -e \"new (require('node:sqlite').DatabaseSync)('PRIMARY.telemetry.db').exec('ALTER TABLE sys_automation_run RENAME TO sys_automation_run__qa_off')\" — send one valid resume, POST /api/v1/automation/showcase_reassign_wizard/runs/RUNID/resume {\"inputs\": {\"new_assignee\": \"…\"}}, then rename it back (ALTER TABLE sys_automation_run__qa_off RENAME TO sys_automation_run)", "expect": "the resume answers 503 with error.code STORE_UNAVAILABLE and a message beginning 'Durable suspended-run store unreachable for run' and ending in the driver's refusal; after the rename back, GET /api/v1/automation/showcase_reassign_wizard/runs/RUNID reads status 'paused' and the same valid resume answers 200 — the intact-suspension half of the row. ⚠️ The table lives in the TELEMETRY file beside the primary (PRIMARY.telemetry.db), not in the primary: a rename aimed at the primary answers 'no such table'. ⚠️ While the table is away every run read and write in the boot fails, so rename it back before anything else touches a run. A 404 RUN_NOT_FOUND here is the defect the row exists for: an unreadable store reported as a missing run", "source": "packages/services/service-automation/src/suspended-run-store.ts#ObjectStoreSuspendedRunStore (the durable store reads and writes sys_automation_run through the data engine); packages/services/service-automation/src/run-resume.ts#loadSuspendedRunStrict and packages/services/service-automation/src/run-resume.ts#resumeInternal (a strict load that throws answers STORE_UNAVAILABLE and leaves the suspension unconsumed, never RUN_NOT_FOUND); packages/cli/src/utils/telemetry-datasource.ts#resolveTelemetryDbPath (the telemetry file is the primary's path plus `.telemetry.db`); packages/runtime/src/domains/automation.ts — the resume route's STORE_UNAVAILABLE → 503 arm" + }, + { + "step": 6, + "call": "PUT /api/v1/meta/flow/qa_input_schema_invalid?package=com.objectstack.qa.automation", + "body": { + "name": "qa_input_schema_invalid", + "label": "QA — node config violates its inputSchema", + "type": "autolaunched", + "status": "active", + "nodes": [ + { + "id": "start", + "type": "start", + "label": "Start" + }, + { + "id": "assign", + "type": "assignment", + "label": "Mistyped input", + "config": { + "assignments": { + "qa_flag": "set" + } + }, + "inputSchema": { + "assignments": { + "type": "string", + "required": true + } + } + }, + { + "id": "end", + "type": "end", + "label": "End" + } + ], + "edges": [ + { + "id": "e1", + "source": "start", + "target": "assign" + }, + { + "id": "e2", + "source": "assign", + "target": "end" + } + ] + }, + "expect": "2xx on the author, alongside steps 2 and 3 (it is not a fault arm, so it goes up front). On trigger the engine checks every node's config against the inputSchema that node declares BEFORE any node runs: `assignments` is an object where the schema says string, so the run never dispatches and answers 422 FLOW_INPUT_SCHEMA_INVALID with the message \"Node 'assign' parameter 'assignments' expected type 'string' but got 'object'\". ⚠️ The schema is the node's OWN `inputSchema` key (a per-parameter { type, required } map), not the executor's; a node with no inputSchema is never checked, so dropping the key turns this into an ordinary successful run.", + "source": "packages/services/service-automation/src/engine.ts#validateNodeInputSchemas (validateNodeInputSchemas throws InputSchemaViolationError; execute() answers it as the never-dispatched FLOW_INPUT_SCHEMA_INVALID exit); packages/spec/src/automation/flow.zod.ts (FlowNodeSchema.inputSchema: type is one of string, number, boolean, object, array); packages/runtime/src/flow-dispatch-status.ts#classifyFlowRefusal (the 422 row)" } ], "teardown": "POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": true} to undo step 4, then DELETE /api/v1/packages/com.objectstack.qa.automation (client `packages.uninstall`, ledgered at rest-route-ledger.ts) — or discard the run's own file DB, the cheaper path a boot on its own port and DB makes free. The toggle is NOT covered by discarding the DB if you reused a boot, so undo it explicitly. Step 5 undoes itself with its rename back; if a run stopped between the two renames, rename the table back before anything else.", @@ -527,7 +579,7 @@ "title": "Every demonstrable flow node type authors in the designer, executes in a run, and surfaces its step in run logs", "since": "v15", "status": "active", - "revision": 10, + "revision": 11, "priority": "P1", "surface": "mixed", "personas": [ @@ -536,7 +588,7 @@ "fixtures": { "app": "showcase", "requires": [ - "the showcase flow-per-node-type map (examples/app-showcase/src/automation/flows/): create_record=showcase_inbound_task_webhook · update_record+screen=showcase_reassign_wizard · get_record+decision+delete_record=showcase_inquiry_purge · script+notify=showcase_task_completed · wait=showcase_task_follow_up · subflow=showcase_task_done_notify_owner→showcase_notify_owner · map=showcase_release_signoff · connector_action=showcase_task_completed_rest_ping · loop=showcase_batch_reminders · parallel+http=showcase_fan_out_notify · try_catch=showcase_resilient_sync · assignment=showcase_closure_signoff · approval=showcase_expense_signoff · approval_revise=showcase_budget_approval (wait_revision)" + "the showcase flow-per-node-type map (examples/app-showcase/src/automation/flows/): create_record=showcase_inbound_task_webhook · update_record+screen=showcase_reassign_wizard · get_record+decision+delete_record=showcase_inquiry_purge · script+notify=showcase_task_completed · wait=showcase_task_follow_up · subflow=showcase_task_done_notify_owner→showcase_notify_owner · map=showcase_release_signoff · connector_action=showcase_task_completed_rest_ping · loop=showcase_batch_reminders · parallel+http=showcase_fan_out_notify · try_catch=showcase_resilient_sync · assignment=showcase_closure_signoff and showcase_one_task_signoff (the map child: its mark_ok / mark_no nodes run inside every showcase_release_signoff item once that item's approval is decided) · approval=showcase_expense_signoff · approval_revise=showcase_budget_approval (wait_revision)" ], "knownGaps": [ "parallel_gateway / join_gateway / boundary_event are deliberately NOT in the matrix: BPMN-interop lowering targets whose author-facing forms are the ADR-0031 structured containers — waived with reasons in examples/app-showcase/src/coverage.ts FLOW_NODE_WAIVERS, and the coverage test enforces every OTHER enum member is authored", @@ -573,7 +625,7 @@ "record_change chain: POST a showcase_task WITH `assignee` set to a real user's email, under a project that has an `owner` (the project lookup is required), with status 'in_review' — task_status_flow allows done only from in_review and does not check a create — then PATCH status→done — fires showcase_task_completed (script+notify), showcase_fan_out_notify (parallel+http), showcase_resilient_sync (try_catch), showcase_task_done_notify_owner (subflow); the create also fires showcase_task_follow_up (wait) and showcase_declarative_connector_ping / showcase_mcp_connector_echo (connector_action). ⚠ The notify steps address the task's assignee and its project's owner: a task created without an assignee, or under a project without an owner, makes them fail LOUDLY for want of a recipient — a correct refusal, not a notify FAIL (QA run #23166)", "on-demand chain: POST /api/v1/automation//trigger for showcase_batch_reminders {params:{tasks:[…]}} (loop), showcase_inquiry_purge (get_record+decision+delete_record — seed at least one closed showcase_inquiry first), showcase_release_signoff {params:{items:[…]}} (map — its itemObject is showcase_task, so each item is record-shaped, {id, title})", "browser chain: run the Tasks row action showcase_bulk_reassign → showcase_reassign_wizard (screen + update_record)", - "approval chain: PATCH a showcase_expense_report to submitted (approval via showcase_expense_signoff); PATCH a showcase_project budget above 100000 to open showcase_budget_approval, then POST /api/v1/approvals/requests/:id/revise so the run parks at the approval_revise node", + "approval chain: the boot already leaves a PAUSED showcase_expense_signoff run at its approval node — the showcase's approval-demo seed launches it on EXP-2001 (examples/app-showcase/src/security/seed-approval-demo.ts) — so locate the approval variant on that run; a NEW one needs a report ENTERING submitted from another status (the start condition requires previous.status != 'submitted'), e.g. PATCH EXP-2003 (draft) to submitted; PATCH a showcase_project budget above 100000 to open showcase_budget_approval, then POST /api/v1/approvals/requests/:id/revise so the run parks at the approval_revise node", "webhook chain: POST /api/v1/automation/hooks/showcase_inbound_task_webhook/intake with a valid x-objectstack-signature to exercise create_record — the door accepts only the timestamped form t=UNIX_SECONDS,v1=HEX, the HMAC-SHA256 of 'T.RAW_BODY'; a body-only HMAC answers 401 INVALID_SIGNATURE (packages/triggers/trigger-api/src/api-trigger.ts verifyInboundSignature) — the flow names its secret (config.secretRef: 'SHOWCASE_WEBHOOK_SECRET'), so boot with that environment variable set to a value of your choosing and sign with it; unset, the hook answers 503", "for EACH variant: GET the owning flow's runs (GET /api/v1/automation//runs + /runs/:runId) and locate a step whose nodeType equals the variant; record run id, step status, and — for suspending types — the paused-then-resumed transition", "screenshot the Runs panel of one composite run (showcase_project_escalation or showcase_fan_out_notify) showing the container-nested steps", @@ -697,7 +749,8 @@ "date": "2026-10-27", "change": "steps[1], steps[3] and steps[6] name the request shapes the run needed. Checklist-accuracy findings of the 18.2.0 wave 2 (QA run #26456, release-18.2.0-w2-manual-browser, at 756220c4; wave anchor #25807, slice A): the clone door needs a label as well as a name, release_signoff's items are record-shaped, and the inbound hook accepts only the timestamped signature. Re-read at base e4ba846f (api-trigger.ts; examples/app-showcase/src/automation/flows/index.ts showcase_release_signoff's map node; packages/runtime/src/domains/automation-flow-clone.test.ts, the label-required case). No clause changed", "ref": "#25807" - } + }, + { "revision": 11, "date": "2026-10-31", "change": "fixtures.requires[0] names the second assignment carrier and steps[5] the seeded approval run. Fixture findings of QA run #27441 (18.3.0 sweep, wave 3, manual set, at c4c515ad; sweep anchor #27377): assignment is also exercised by showcase_one_task_signoff, and the boot already leaves a paused showcase_expense_signoff run, while PATCHing an already-submitted report starts nothing. Re-read at base 488b3ed0: examples/app-showcase/src/automation/flows/index.ts (showcase_one_task_signoff mark_ok / mark_no; showcase_expense_signoff's record-after-update start condition), examples/app-showcase/src/security/seed-approval-demo.ts (launches showcase_expense_signoff on EXP-2001). No clause changed", "ref": "#27441" } ], "enumSource": { "file": "packages/spec/src/automation/flow.zod.ts", @@ -710,7 +763,7 @@ "title": "Every flow trigger type fires and produces a run of its bound flow; the run's trigger block records the launch event", "since": "v15", "status": "active", - "revision": 9, + "revision": 10, "priority": "P1", "surface": "mixed", "personas": [ @@ -720,7 +773,7 @@ "app": "showcase", "requires": [ "one seeded flow per trigger kind: record_change=showcase_urgent_task_alert (record-after-write, the only type:'record_change' flow) · autolaunched=showcase_batch_reminders (trigger route) · schedule=showcase_scheduled_digest (60s interval) · screen=showcase_reassign_wizard (Tasks row action showcase_bulk_reassign) · api=showcase_inbound_task_webhook (HMAC intake hook) · time_relative=showcase_task_due_reminder (schedule flow hosting config.timeRelative)", - "⚠️ the schedule and time_relative legs need package-authored scheduled work switched ON at boot: export OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 (with OS_PORT). On a default boot both showcase flows are unbound by deployment policy — the banner says \"1 flow declares a 'schedule' trigger but is NOT bound — disabled by deployment policy\" (and the same for 'time_relative'), and GET /api/v1/automation/_status reports bound:false with that reason — so no tick and no sweep ever comes, which is the policy working, not a trigger defect" + "⚠️ the schedule and time_relative legs need package-authored scheduled work switched ON at boot: export OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 (with OS_PORT). On a default boot both showcase flows are unbound by deployment policy — the banner says \"1 flow declares a 'schedule' trigger but is NOT bound — disabled by deployment policy\" (and the same for 'time_relative'), and GET /api/v1/automation/_status reports bound:false with that reason — so no tick and no sweep ever comes, which is the policy working, not a trigger defect. ⚠️ time_relative has a second clock: showcase_task_due_reminder declares no `schedule` beside its timeRelative block, so even with the switch on it sweeps once a day at 08:00 UTC (the default cadence, packages/spec/src/automation/time-relative-trigger.zod.ts), and a run outside that minute sees no sweep. Fire the variant on a FAST CLONE, as QA run #27409 did: the same definition authored under a scratch name into a writable package (?package=, the way the area's qa-flow-status-doors recipe authors flows), with `schedule: { type: 'interval', intervalMs: 60000 }` as a sibling of `timeRelative` on the start node's config, plus a showcase_task that is not done and whose due_date falls 1 or 3 days out (offsetDays [3, 1])" ] }, "variants": [ @@ -853,7 +906,8 @@ "date": "2026-10-27", "change": "automated.ref names two pins that corroborate clauses below their oracle's layer. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26468, release-18.2.0-w2-tier1-a, at 756220c4; wave anchor #25807, slice A): the mapping pass found inbound-hook-member.test.ts covering negative[1]'s refusal and screen-flow.spec.ts carrying acceptance[4]'s network half, neither named. No clause changed", "ref": "#25807" - } + }, + { "revision": 10, "date": "2026-10-31", "change": "fixtures.requires[1] says how to fire the time_relative variant inside a run. Checklist-accuracy finding of QA run #27409 (18.3.0 sweep, wave 3, Tier 1 half A, at c4c515ad; sweep anchor #27377): the stock sweep runs daily at 08:00 UTC, so the variant had to be fired on a fast clone, and the fixture line named only the scheduled-work switch. Re-read at base 488b3ed0: examples/app-showcase/src/automation/flows/index.ts TaskDueReminderFlow (no schedule on its start config), packages/spec/src/automation/time-relative-trigger.zod.ts (omitting schedule means daily at 08:00 UTC; schedule is a sibling of timeRelative). No clause changed", "ref": "#27409" } ] }, { @@ -1114,7 +1168,7 @@ "title": "Suspended runs persist to sys_automation_run, survive a cold restart, and resume — including nested (linked-run) pauses", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -1133,7 +1187,7 @@ "PATCH a showcase_project status→'completed' — showcase_project_closure parks at its subflow node and showcase_closure_signoff parks at its approval node (linked runs)", "read the paused rows from sys_automation_run over the data API by run id: status, flow name, suspended node, and the rehydration payload", "stop the server process entirely; cold-boot a second server over the SAME database file", - "re-read the same sys_automation_run rows — still present, still paused", + "re-read the same sys_automation_run rows — still present, still paused (the follow-up's row may already have resumed and completed on the new process if its PT1M timer fired first; acceptance[1] says how that reads)", "resume path 1: let the wait timer elapse (the job service schedules a one-shot resume) and confirm the follow-up notify landed", "resume path 2: POST /api/v1/approvals/requests/:id/approve on the closure sign-off — the child resumes, bubbles its decision output, and the parent completes", "confirm the paused rows were CONSUMED on resume (replaced by run history rows), not left behind" @@ -1148,7 +1202,7 @@ { "clause": "the paused rows and ordinary records survive a literal cold boot over the same database file", "oracle": "api", - "verify": "post-restart reads return the identical paused rows (and the seeded data) — the #4518 wasm-driver class of loss (writes never reaching disk) shows up exactly here", + "verify": "post-restart reads return the identical paused rows for the nested pair (showcase_project_closure at signoff, showcase_closure_signoff at ask_signoff: no timer moves them) and the seeded data. The showcase_task_follow_up run may already be gone: its PT1M timer can fire on the NEW process before the re-read, and then its paused row reads RECORD_NOT_FOUND and its run history reads completed, with the remind step finished AFTER the old process stopped. That completion on the new process is its survival proof (the same evidence as acceptance[2]), not a miss; a follow-up that completed on the OLD process, or that is neither paused nor completed after the restart, is a FAIL — the #4518 wasm-driver class of loss (writes never reaching disk) shows up exactly here", "evidence": "pre/post-restart row reads" }, { @@ -1203,7 +1257,8 @@ "date": "2026-10-26", "change": "automated.ref says which clauses the pin scores, over which fixture and at which layer. Checklist-accuracy findings of the 18.2.0 sweep (QA run #25815, release-18.2.0-tier1, at 6679644a: a0/a1/a4, and a1; wave anchor #25807, card #25840): the pin drives its own fixture (flow_durable_suspend, a screen-node pause), not the showcase_task_follow_up timer or the showcase_project_closure → showcase_closure_signoff nested pair the steps name — the generic persistence and consumption assertions hold, the per-kind rows are not observed — and its cold boot is a second bootStack kernel over the same file inside one test process, not a separately spawned server. Re-read at base 9753d1637f (both of the pin's describe blocks boot durableSuspendStack through bootStack over one databaseFile). No clause changed", "ref": "#25840" - } + }, + { "revision": 4, "date": "2026-10-31", "change": "acceptance[1].verify and steps[5] accept the timer run's completion on the new process. Checklist-accuracy finding of QA run #27409 (18.3.0 sweep, wave 3, Tier 1 half A, at c4c515ad; sweep anchor #27377): the PT1M timer of showcase_task_follow_up can fire on the cold-booted process before the re-read, so 'the identical paused rows' could not hold for that run, while its completion on the new process is the stronger survival proof. The nested pair, which no timer moves, must still read paused and identical. Re-read at base 488b3ed0: examples/app-showcase/src/automation/flows/index.ts (showcase_task_follow_up's wait timer PT1M). The clause is unchanged", "ref": "#27409" } ] }, { @@ -1634,7 +1689,7 @@ "title": "The flow-dispatch HTTP status contract: every never-dispatched, ran-and-failed and non-terminal outcome answers its own status + ADR-0112 code, at every door that dispatches a flow", "since": "v17", "status": "active", - "revision": 10, + "revision": 11, "priority": "P1", "surface": "api", "personas": ["seeded admin (admin@objectos.ai / admin123)", "org member for the resume 403 arm — created by the admin with POST /api/v1/auth/admin/create-user and past its password change (POST /api/v1/auth/change-password): a held session answers 403 PASSWORD_RESET_REQUIRED, the right status for the wrong reason"], @@ -1646,22 +1701,21 @@ ], "provisioning": { "use": "qa-flow-status-doors", - "why": "unblocks the 422 FLOW_NO_START_NODE and 400 FLOW_FAILED rows of clause 0 and their per-door repeats in clause 2. Without it those two rows are blocked(fixture): stock showcase has no start-less flow, and its one deterministic failure (showcase_resilient_sync) is wrapped in a try_catch that HANDLES the failure, so the run SUCCEEDS and the 400 row is never exercised. The item would score partial with the two most-regressible rows unproven. The recipe's step 5 also unblocks clause 4's 503 STORE_UNAVAILABLE arm, which no request can cause: it renames the durable suspended-run table away in the boot's telemetry database for one resume and puts it back. Without it that arm is blocked(fixture), as it scored in both 18.2.0 waves." + "why": "unblocks the 422 FLOW_NO_START_NODE, 422 FLOW_INPUT_SCHEMA_INVALID and 400 FLOW_FAILED rows of clause 0 and their per-door repeats in clause 2. Without it those two rows are blocked(fixture): stock showcase has no start-less flow, and its one deterministic failure (showcase_resilient_sync) is wrapped in a try_catch that HANDLES the failure, so the run SUCCEEDS and the 400 row is never exercised. The item would score partial with the two most-regressible rows unproven. The recipe's step 5 also unblocks clause 4's 503 STORE_UNAVAILABLE arm, which no request can cause: it renames the durable suspended-run table away in the boot's telemetry database for one resume and puts it back. Without it that arm is blocked(fixture), as it scored in both 18.2.0 waves. The recipe's step 6 stages the 422 FLOW_INPUT_SCHEMA_INVALID row (qa_input_schema_invalid), which nothing in stock carries." }, "knownGaps": [ - "The MCP `run_action` bridge shares action-execution.ts's mapper with POST /api/v1/actions/... but is not an HTTP door, so it has no status to observe — clause 2 covers it by asserting the two share ONE mapper (source-level), not by driving MCP.", - "The 422 FLOW_INPUT_SCHEMA_INVALID row has no stock trigger: no showcase flow and no area recipe carries a node whose config violates its declared inputSchema (packages/services/service-automation/src/engine.ts validateNodeInputSchemas, the InputSchemaViolationError arm of execute()). Until a run stages one, acceptance[0] scores that row not-run and the item is partial; the row is listed so a run does not read the four-row table as the whole of classifyFlowRefusal." + "The MCP `run_action` bridge shares action-execution.ts's mapper with POST /api/v1/actions/... but is not an HTTP door, so it has no status to observe — clause 2 covers it by asserting the two share ONE mapper (source-level), not by driving MCP." ] }, "steps": [ "read the table first, from its single definition: packages/runtime/src/flow-dispatch-status.ts (FLOW_NOT_FOUND_STATUS, classifyFlowRefusal, isPausedRun) — it is a module rather than a route mapper precisely because three doors must agree on it, and a second copy of it is a defect by construction. The resume arms are a separate list, read them from packages/runtime/src/domains/automation.ts (the `POST /:name/runs/:runId/resume` branch and its code→status arms)", "boot the showcase on its own port and DB (dogfood §0) with OS_PORT exported as well as -p (RUNNER.md's connector self-URL fact), sign in as the dev admin, and run the qa-flow-status-doors recipe", - "TRIGGER, per row, against BOTH doors — POST /api/v1/automation/:name/trigger and the legacy POST /api/v1/automation/trigger/:name (the shape client.automation.trigger() calls): a name the deployment does not hold (404 RESOURCE_NOT_FOUND: the status table's 404 row names no code of its own, so the envelope carries the spec's status default, HttpStatusErrorCodeMap in packages/spec/src/api/errors.zod.ts); qa_always_fails; the disabled showcase_urgent_task_alert; qa_no_start_node; a flow that succeeds — showcase_notify_owner with {\"params\": {\"ownerId\": , \"message\": \"…\"}} answers 200 (not showcase_resilient_sync, whose CEL condition reads the record's status and answers a bare trigger 400 FLOW_FAILED); showcase_reassign_wizard (pauses)", + "TRIGGER, per row, against BOTH doors — POST /api/v1/automation/:name/trigger and the legacy POST /api/v1/automation/trigger/:name (the shape client.automation.trigger() calls): a name the deployment does not hold (404 RESOURCE_NOT_FOUND: the status table's 404 row names no code of its own, so the envelope carries the spec's status default, HttpStatusErrorCodeMap in packages/spec/src/api/errors.zod.ts); qa_always_fails; the disabled showcase_urgent_task_alert; qa_no_start_node; qa_input_schema_invalid (the recipe's step 6: 422 FLOW_INPUT_SCHEMA_INVALID); a flow that succeeds — showcase_notify_owner with {\"params\": {\"ownerId\": , \"message\": \"…\"}} answers 200 (not showcase_resilient_sync, whose CEL condition reads the record's status and answers a bare trigger 400 FLOW_FAILED); showcase_reassign_wizard (pauses)", "record for every response the HTTP status AND error.code together — neither alone is the contract, and a run that captured only the status cannot tell 409 FLOW_DISABLED from any other 409", "on the 400 FLOW_FAILED responses, additionally record error.details.errorMessage and error.details.summary (the flow author's own text and the per-node accounting) — they are the documented payload of that row and the console reads them from exactly there", "RESUME: trigger showcase_reassign_wizard with {\"params\": {\"recordId\": , \"objectName\": \"showcase_task\"}} to obtain a live paused runId (without recordId the valid resume fails 400 FLOW_FAILED: its update_record filters on recordId), then drive each refusal arm against POST /api/v1/automation/:name/runs/:runId/resume — a valid resume, carrying the screen's required new_assignee (200, next screen or completion) — ⚠️ a BODYLESS resume is not a valid one on the stock wizard: it answers 400 INVALID_SCREEN_INPUT, the screen-input row below; a resume missing a required screen input (400 INVALID_SCREEN_INPUT); a resume whose signal writes the engine's `$` namespace (400 INVALID_SIGNAL) — spelled {\"inputs\": {\"new_assignee\": \"…\"}, \"output\": {\"$x\": 1}}, which lands as collect.$x. The screen check runs first (refuseInvalidScreenInput before applyResumeSignal, engine.ts), so the body must also satisfy the screen's required new_assignee: {\"output\": {\"$x\": 1}} alone answers 400 INVALID_SCREEN_INPUT (QA run #24779), as does a `$` key inside `inputs`; and a body carrying both `inputs` and `variables` is refused 400 VALIDATION_FAILED since #24594; a resume while the durable run store is unreadable (503 STORE_UNAVAILABLE) — the qa-flow-status-doors recipe's step 5, the table renamed away for that one resume and back before the re-read; a resume of an unknown runId (404 RUN_NOT_FOUND); a resume as the member persona, neither the trigger identity nor a resumeAuthority holder (403 PERMISSION_DENIED); and 409 RESUME_IN_PROGRESS, reproducible with a scratch screen flow start → screen(collect, required x) → http GET a local delay server (e.g. http://127.0.0.1:FREE-PORT/slow?s=6) → screen(collect2, required y) → end: send a second resume while the first is inside the http node (the engine's `resuming` guard holds for the whole continuation), and the second screen makes the intact suspension and a later resume observable (QA run #24596)", "after each RETRYABLE refusal, re-read the run and confirm the suspension is STILL PAUSED and a later valid resume still works — 'the refusal left the suspension intact' is half of what those arms mean, and a status alone does not show it", - "cross-door: dispatch a disabled flow through POST /api/v1/actions/... with a type:'flow' action and through a declared type:'flow' endpoint, and record what each answers. No stock flow is reachable from all three doors, so each door takes its own: showcase_reassign_wizard through its declared action (POST /api/v1/actions/showcase_task/showcase_bulk_reassign), and showcase_inquiry_purge through its endpoint (POST /api/v1/apps/showcase/inquiries/purge, examples/app-showcase/src/system/apis/index.ts) — toggle each OFF first and back ON in teardown. The /actions door's 400 FLOW_FAILED and 422 FLOW_NO_START_NODE rows need a type:'flow' action targeting qa_always_fails or qa_no_start_node, which stock does not declare: author one into the recipe's package (PUT /api/v1/meta/action/NAME?package=com.objectstack.qa.automation, the route QA run #25817 names) — the qa-flow-status-doors recipe does not carry that step", + "cross-door: dispatch a disabled flow through POST /api/v1/actions/... with a type:'flow' action and through a declared type:'flow' endpoint, and record what each answers. No stock flow is reachable from all three doors, so each door takes its own: showcase_reassign_wizard through its declared action (POST /api/v1/actions/showcase_task/showcase_bulk_reassign), and showcase_inquiry_purge through its endpoint (POST /api/v1/apps/showcase/inquiries/purge, examples/app-showcase/src/system/apis/index.ts) — toggle each OFF first and back ON in teardown. The /actions door's 400 FLOW_FAILED and 422 FLOW_NO_START_NODE rows need a type:'flow' action targeting qa_always_fails or qa_no_start_node, which stock does not declare: author one into the recipe's package (PUT /api/v1/meta/action/NAME?package=com.objectstack.qa.automation, the route QA run #25817 names) — the qa-flow-status-doors recipe does not carry that step. ⚠️ showcase_inquiry_purge really deletes: a dispatch that is NOT refused runs its predicate delete_record (multi: true) over every showcase_inquiry whose status is 'closed' (examples/app-showcase/src/automation/flows/index.ts). POST the purge endpoint only while the flow is toggled OFF, never again after teardown re-enables it, and run this leg on the run's own throwaway DB", "capture the per-row × per-door verdict table" ], "acceptance": [ @@ -1806,7 +1860,8 @@ "date": "2026-10-30", "change": "the RESUME_CHILD_PENDING variant names the code's second arm and the cancelled-item record (PR #28176): an item whose suspension is gone without a cancelled history entry is refused with the same 409, naming the remedy (cancel the parent run); a cancelled item is recorded in its slot and the map continues. Read at head of PR #28176 (run-resume.ts continuePastCancelledMapItem; builtin/map-node.ts)", "ref": "#27971" - } + }, + { "revision": 11, "date": "2026-10-31", "change": "The 422 FLOW_INPUT_SCHEMA_INVALID row gains its recipe, and steps[7] warns about the purge endpoint. Checklist-accuracy findings of QA run #27392 (18.3.0 sweep, wave 3, at c4c515ad; sweep anchor #27377). (1) knownGaps[1] said the row had no stock trigger and scored not-run, but the run staged it inside the qa-flow-status-doors package and read 422 at both trigger routes and /actions: the recipe gains step 6 (qa_input_schema_invalid), steps[2] drives it, provisioning.why names it, and the knownGaps entry is dropped. (2) Once re-enabled, showcase_inquiry_purge deletes every closed inquiry, so the cross-door leg now says to drive it only while the flow is off and on a throwaway DB. Re-read at base 488b3ed0: packages/services/service-automation/src/engine.ts validateNodeInputSchemas, examples/app-showcase/src/automation/flows/index.ts showcase_inquiry_purge (delete_record, multi: true), examples/app-showcase/src/system/apis/index.ts InquiryPurgeEndpoint. acceptance[0] is unchanged, and its 422 FLOW_INPUT_SCHEMA_INVALID row is now scorable on the recipe", "ref": "#27392" } ] }, { @@ -1951,7 +2006,7 @@ "title": "Disabling a packaged flow that packaged callers invoke as a subflow is refused 409 DELETE_RESTRICTED naming every caller; the refused attempt writes no ledger row; enabling the subflow itself is never guarded", "since": "v17", "status": "active", - "revision": 10, + "revision": 11, "priority": "P1", "surface": "api", "personas": [ @@ -1981,7 +2036,7 @@ "enable arm, child FIRST: POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": true} while its caller is still off (confirm it is never refused), then toggle showcase_task_done_notify_owner back ON, then repeat the child's enable with the caller armed (a no-op enable). ⚠️ Enabling the CALLER while its packaged subflow is still disabled is refused by design since 679f95e (#20711) — 409 RESOURCE_CONFLICT 'cannot be enabled while 1 packaged subflow it calls is disabled … Enable it first' — so the caller-first order is not a fail of this item", "teardown: re-enable anything left disabled (or discard the run's own DB)", "scope, not a probe: the UI half (the caller names rendered verbatim in a role=alert on the Setup packaged-automation page) is deliberately NOT scored here; it is automation.setup-packaged-automation-board's refusal clause", - "several-callers leg (negative[1]): run the qa-second-packaged-caller recipe, then POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": false} and record the answer — 409 DELETE_RESTRICTED whose message counts 2 packaged callers and names both showcase_task_done_notify_owner and qasub_second_notify_caller, and NO sys_metadata_activation row for showcase_notify_owner; then the recipe's teardown, after which the same disable names the stock caller alone" + "several-callers leg (negative[1]): run the qa-second-packaged-caller recipe, then POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": false} and record the answer — 409 DELETE_RESTRICTED whose message counts 2 packaged callers and names both showcase_task_done_notify_owner and qasub_second_notify_caller, and NO active=false sys_metadata_activation row for showcase_notify_owner: its row is UNCHANGED. Run in this item's order, steps[5]'s enables already wrote that row (active true), so 'no row at all' holds only on a boot where nothing has toggled the child yet; then the recipe's teardown, after which the same disable names the stock caller alone" ], "acceptance": [ { @@ -2058,7 +2113,8 @@ "date": "2026-10-27", "change": "fixtures.provisioning opts into the new area recipe qa-second-packaged-caller, negative[1] says how its door leg is staged, steps[8] drives it, fixtures.knownGaps gains its CLOSED-by-recipe line and source cites the install-local door and packagedArtifactOwner. Card #26622 (maintainer-directed in the 18.2.0 wave-2 session: 「补测试数据配方。立卡」): negative[1] scored blocked(fixture) in both 18.2.0 waves because only a package-loader-installed package can add a second PACKAGED caller. Re-driven once on the recipe, on a fresh showcase boot at d31329f1: with the package installed, POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": false} answered 409 DELETE_RESTRICTED, 'cannot be disabled while 2 packaged flows still call it as a subflow: 'showcase_task_done_notify_owner', 'qasub_second_notify_caller'', with both callers in the remedy sentence; sys_metadata_activation held no row; after the teardown the same disable named showcase_task_done_notify_owner alone. Verdict for negative[1]: pass (no FAIL observed). No clause changed", "ref": "#26622" - } + }, + { "revision": 11, "date": "2026-10-31", "change": "steps[8] reads the ledger row as unchanged, not absent. Checklist-accuracy finding of QA run #27392 (18.3.0 sweep, wave 3, at c4c515ad; sweep anchor #27377): run in the item's own order, steps[5]'s enable arm had already written an active row for showcase_notify_owner, so 'NO sys_metadata_activation row' could not hold when steps[8] runs. What the refusal must not do is write an active=false row. Re-read at base 488b3ed0: packages/services/service-automation/src/engine.ts toggleFlow (the durable row is written FIRST on every toggle that lands, enables included). acceptance[2] is unchanged: at steps[3] no toggle has landed yet", "ref": "#27392" } ] }, { @@ -2066,7 +2122,7 @@ "title": "POST /automation/:name/clone: whole-definition copy mutating exactly name/label/status, protection envelope dropped, no ancestry, draft-but-armed (double-fire notice verbatim), 409/404/400 refusal arms — and the honest clause: the clone must survive a restart and reach Studio", "since": "v17", "status": "active", - "revision": 9, + "revision": 10, "priority": "P1", "surface": "mixed", "personas": [ @@ -2081,7 +2137,7 @@ }, "steps": [ "boot the showcase on its own port against its own file DB (dogfood §0, OS_PORT exported); sign in as the dev admin", - "refusal arms first (no state left behind): POST /api/v1/automation/showcase_urgent_task_alert/clone with {} · with {\"name\":\"x\"} (label missing) · with {\"label\":\"x\"} (name missing) · with {\"name\":\"showcase_urgent_task_alert\",\"label\":\"Same\"} (same-name) · with {\"name\":\"Bad-Name!\",\"label\":\"Illegal\"} (violates ^[a-z_][a-z0-9_]*$) · and POST /api/v1/automation/does_not_exist/clone {\"name\":\"qa_x\",\"label\":\"X\"} (unknown source) — record every status + code + message", + "refusal arms first (no state left behind): POST /api/v1/automation/showcase_urgent_task_alert/clone with {} · with {\"name\":\"x\"} (label missing) · with {\"label\":\"x\"} (name missing) · with {\"name\":\"\",\"label\":\"Empty\"} (empty name, code invalid_type) · with {\"name\":\"showcase_urgent_task_alert\",\"label\":\"Same\"} (same-name) · with {\"name\":\"Bad-Name!\",\"label\":\"Illegal\"} (violates ^[a-z_][a-z0-9_]*$) · and POST /api/v1/automation/does_not_exist/clone {\"name\":\"qa_x\",\"label\":\"X\"} (unknown source) — record every status + code + message", "the copy: POST /api/v1/automation/showcase_urgent_task_alert/clone {\"name\":\"qa_urgent_alert_clone\",\"label\":\"QA urgent alert clone\"} — record the FULL response body (flow + notice)", "read both definitions back (GET /api/v1/automation/qa_urgent_alert_clone and …/showcase_urgent_task_alert) and diff them key by key", "double-fire: POST /api/v1/data/showcase_task with priority='urgent'; GET both flows' runs lists", @@ -2092,10 +2148,10 @@ ], "acceptance": [ { - "clause": "name AND label are both mandatory: an empty body, a missing name, and a missing label each answer 400 with a per-field detail (code 'required'), and nothing reaches the service", + "clause": "name AND label are both mandatory: an empty body, a missing name, and a missing label each answer 400 with a per-field detail (code 'required', which names an ABSENT key; a name or label that is present but empty or not a string, such as \"\", answers the same 400 with code 'invalid_type'), and nothing reaches the service", "oracle": "api", - "verify": "the three refusals from step 2: 400, details naming the absent field — for {} only the FIRST absent one, name: the door checks name, then label, and answers at the first absence, so {} carries one detail, not two (domains/automation.ts — mandatory per ADR-0126 §7.1, the #11513 shape; an unknown body key answers 400 unknown_field). Route-level pins exist in packages/runtime/src/domains/automation-flow-clone.test.ts — this run proves the same arms on the live dispatcher", - "evidence": "the three 400 bodies with their details arrays" + "verify": "the refusals from step 2 — the three absent-key arms, plus the empty-string arm {\"name\":\"\",\"label\":\"Empty\"} answering 400 with one detail {field: name, code: invalid_type}: 400, details naming the absent field — for {} only the FIRST absent one, name: the door checks name, then label, and answers at the first absence, so {} carries one detail, not two (domains/automation.ts — mandatory per ADR-0126 §7.1, the #11513 shape; an unknown body key answers 400 unknown_field). Route-level pins exist in packages/runtime/src/domains/automation-flow-clone.test.ts — this run proves the same arms on the live dispatcher", + "evidence": "the four 400 bodies with their details arrays" }, { "clause": "the copy is whole-definition with exactly name/label/status mutated, the protection envelope and read decorations dropped, and NO ancestry: every other key of the clone deep-equals the source; none of the 10 FLOW_CLONE_DROPPED_KEYS (_diagnostics, _draft, _lock, _lockReason, _lockSource, _provenance, _packageId, _packageVersion, _lockDocsUrl, protection) is present on the clone; no clonedFrom-shaped key exists in the response or the stored definition", @@ -2179,7 +2235,8 @@ "date": "2026-10-27", "change": "surface api → mixed, and steps[7] says its browser leg needs a built console. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26476, release-18.2.0-w2-manual-api, at 756220c4; wave anchor #25807, slice A): acceptance[6] and steps[7] open Studio in a browser, so the item is not api-only, and on a `pnpm build` tree the run met /_console/ answering 503 until `pnpm console:build`. Every other step stays an api step. No clause changed", "ref": "#25807" - } + }, + { "revision": 10, "date": "2026-10-31", "change": "acceptance[0] and steps[1] separate an absent key from an empty one. Checklist-accuracy finding of QA run #27441 (18.3.0 sweep, wave 3, manual set, at c4c515ad; sweep anchor #27377): an empty-string name or label answers 400 with code invalid_type, and 'required' is only for an absent key; the clause now says both and adds the empty-string arm. Re-read at base 488b3ed0: packages/runtime/src/domains/automation.ts (the clone door's name and label details: absent ⇒ 'required', otherwise 'invalid_type'). The three absent-key arms are unchanged", "ref": "#27441" } ] }, { @@ -2430,7 +2487,7 @@ "title": "Flow authoring contract on v18, refused at `os validate` — and at PUT /meta/flow for every flow-body arm but the CEL root scope, which only `os validate` judges: single-brace {…} value tokens (CEL only; text slots use {{ }}), $-prefixed bound variable names, builtin node config keys/values the executor does not declare, dangling or repeated edges, `record`/`user.id` roots a flow cannot bind, retryPolicy.maxRetry, and an anonymous endpoint targeting a runAs:\"system\" flow", "since": "v18", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "cli", "personas": [ @@ -2446,7 +2503,7 @@ }, "steps": [ "for each arm run `os validate` in the scratch app and record exit code + rule id + located path: fields:{owner:\"{record.owner}\"}; notify title \"{record.name}\"; screen field named \"$x\"; create_record outputVariable:42; notify bogusKey; an edge to a removed node; an AUTOLAUNCHED flow with no record entrance (one started only through POST /api/v1/automation/NAME/trigger) reading record.x — ⛔ not a type:'api' inbound-hook flow: that type binds `record` (the hook payload) and validates clean once it carries config.secret (flow-api-trigger-secret-missing otherwise); `user.id` in CEL; job retryPolicy:{maxRetry:3}; an authRequired:false flow endpoint with an ARMED rateLimit ({ enabled: true, … } — without one ADR-0121 D6's rate-limit refusal fires beside the D2b one) targeting a runAs:\"system\" flow", - "PUT the flow-body arms acceptance[2] lists to a boot of the scratch app at /api/v1/meta/flow/qa_ARM (scratch names); the endpoint arm PUTs the `runAs: \"system\"` body of the flow its code-declared endpoint names", + "PUT the flow-body arms acceptance[2] lists to a boot of the scratch app at /api/v1/meta/flow/qa_ARM (scratch names); the endpoint arm PUTs the `runAs: \"system\"` body of the flow its code-declared endpoint names — the endpoint's target must be a flow name the scratch app's code does NOT declare, so the PUT is a tenant create the door judges; PUT under a flow the app's package ships and it answers 403 NOT_OVERRIDABLE first", "controls: the CEL envelope {dialect:\"cel\",source:\"record.owner\"}, a notify title \"Case {{ record.name }}\", and a query placeholder {current_user_id} in a CRUD filter operand all PASS" ], "acceptance": [ @@ -2463,7 +2520,7 @@ "evidence": "one line per arm" }, { - "clause": "at PUT /api/v1/meta/flow the door refuses what it judges — 422 INVALID_METADATA with a located issue, and nothing stored — for the single-brace value and text tokens, the $-prefixed bound name, the undeclared builtin node config key and value, the dangling or repeated edge, an undeclared `retry` key under a `try_catch` node (`retry: {maxRetry: 3}`, the flow-shaped twin of the job arm) and a `runAs: \"system\"` flow that a code-declared `authRequired: false` endpoint names (issue at `runAs`). The two CEL root-scope arms — `record.x` in a flow no entrance hands a record, and `user.id` — answer 200 and are stored: this door does not judge root scope, by design, and `os validate` (acceptance[1]) refuses them. The job and the endpoint themselves are code-only types (PUT /meta/job and /meta/api answer 403 NOT_CREATABLE), so they are not PUT arms", + "clause": "at PUT /api/v1/meta/flow the door refuses what it judges — 422 INVALID_METADATA with a located issue, and nothing stored — for the single-brace value and text tokens, the $-prefixed bound name, the undeclared builtin node config key and value, the dangling or repeated edge, an undeclared `retry` key under a `try_catch` node (`retry: {maxRetry: 3}`, the flow-shaped twin of the job arm) and a `runAs: \"system\"` flow that a code-declared `authRequired: false` endpoint names (issue at `runAs`) — the endpoint names a TENANT flow name, one no package ships: a PUT under a packaged flow's name answers 403 NOT_OVERRIDABLE before the door judges the body, which is the overridability refusal, not this arm's. The two CEL root-scope arms — `record.x` in a flow no entrance hands a record, and `user.id` — answer 200 and are stored: this door does not judge root scope, by design, and `os validate` (acceptance[1]) refuses them. The job and the endpoint themselves are code-only types (PUT /meta/job and /meta/api answer 403 NOT_CREATABLE), so they are not PUT arms", "oracle": "api", "verify": "status + code per arm, then GET the same name: a 422 arm reads back 404, a root-scope arm 200", "evidence": "per-arm lines" @@ -2513,7 +2570,8 @@ "date": "2026-10-20", "change": "automated.ref narrowed per clause — checklist-accuracy finding C8 of QA run #24786 (wave anchor #24579 round 12, round 6's theme-B rule): the ref listed nine spec and lint unit pins with no clause qualifier, though acceptance[1]–[3] need the CLI or the PUT door. The pins are acceptance[0]'s own subject, so `automated` stays, narrowed to it. No clause changed", "ref": "#24824" - } + }, + { "revision": 5, "date": "2026-10-31", "change": "acceptance[2] and steps[1] say the endpoint arm needs a tenant flow name. Checklist-accuracy finding of QA run #27409 (18.3.0 sweep, wave 3, Tier 1 half A, at c4c515ad; sweep anchor #27377): a PUT of the runAs:'system' body under a packaged flow's name answered 403 NOT_OVERRIDABLE before the door judged it, so the arm never reached its 422 until the endpoint named a flow no package ships. Re-read at base 488b3ed0: packages/metadata-protocol/src/protocol.ts (a package-shipped item answers NOT_OVERRIDABLE at the meta write door). The 422 assertion is unchanged", "ref": "#27409" } ] }, {