diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index fc1ea7b0bb8..13efad9260f 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": 5, + "revision": 6, "priority": "P1", "surface": "api", "personas": ["admin"], @@ -37,7 +37,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", + "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", "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", @@ -68,7 +68,7 @@ { "clause": "the runtime write door matches the declared registry posture — ALL sides: PUT ?mode=draft succeeds for a RUNTIME-CREATED skill (and tool) name — one no package ships (allowRuntimeCreate:true); the same PUT against a PACKAGE-SHIPPED skill/tool is refused 403 NOT_OVERRIDABLE (allowEnvironmentOverlay:false, ee58392e1 — ADR-0005 ai row); and the agent kind has NO governed runtime write path (allowRuntimeCreate:false; 'for agents, the code is the record', metadata-plugin.zod.ts — migrateStoredMetadata reports agent rows 'skipped' by design)", "oracle": "api", - "verify": "the runtime-created skill draft PUT (qa_runtime_skill) returns 200 and the draft is readable via GET /api/v1/meta/skill/qa_runtime_skill?state=draft — ⚠️ the READ parameter is ?state=draft, not ?mode=draft: rest-server.ts keys the draft projection on req.query.state, while ?mode=draft is the WRITE-side parameter, and a GET carrying ?mode=draft answers 400 VALIDATION_ERROR ('The \"mode\" query parameter is not supported by this endpoint') — the read door refuses a parameter it does not understand instead of serving the active item. The draft PUT against the package-shipped qa_probe_skill answers 403 code NOT_OVERRIDABLE (sys-metadata-repository.ts assertAllowed, override-artifact intent) — CORRECT, not a bug: a 200 there would be a per-org overlay the registry row forbids. The agent runtime save is refused/unsupported (capture the actual status), and that refusal is recorded as CORRECT, not filed as a bug", + "verify": "the runtime-created skill draft PUT (qa_runtime_skill) returns 200 and the draft is readable via GET /api/v1/meta/skill/qa_runtime_skill?state=draft — ⚠️ the READ parameter is ?state=draft, not ?mode=draft: rest-server.ts keys the draft projection on req.query.state, while ?mode=draft is the WRITE-side parameter, and a GET carrying ?mode=draft answers 400 VALIDATION_ERROR ('The \"mode\" query parameter is not supported by this endpoint') — the read door refuses a parameter it does not understand instead of serving the active item. The draft PUT against the package-shipped qa_probe_skill answers 403 code NOT_OVERRIDABLE (sys-metadata-repository.ts assertAllowed, override-artifact intent) — CORRECT, not a bug: a 200 there would be a per-org overlay the registry row forbids. The agent runtime save is refused 403 in both of the code-only shapes — NOT_CREATABLE for a name no package ships, NOT_OVERRIDABLE for the package-shipped qa_probe_agent (metadata-protocol codeOnlyCreateError / codeOnlyOverrideError) — and that refusal is recorded as CORRECT, not filed as a bug", "evidence": "the three PUT responses (runtime-created skill 200 + its ?state=draft read, package-shipped skill 403, agent refusal)" }, { @@ -113,6 +113,12 @@ "date": "2026-10-16", "change": "clause 4's verify said a GET with ?mode=draft serves the active item or 404s (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: PUT /meta/skill/qa_runtime_skill_24036?mode=draft → 200 state draft; GET ?state=draft → 200 with the draft; GET ?mode=draft → 400 VALIDATION_ERROR naming the unsupported mode parameter; the bare GET → 404 (no active item). The agent runtime save answers 403 NOT_CREATABLE (code-only type). The read-parameter warning keeps its point with the current answer", "ref": "#24036" + }, + { + "revision": 6, + "date": "2026-10-19", + "change": "steps[1] and acceptance[3].verify corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24540, ai.agent-tool-skill-metadata-roundtrip; wave anchor #24579 §3). Compiling an app-package agent now warns agent-authoring-withdrawn (packages/lint/src/validate-ai-agent-authoring.ts, warning tier) while the meta door still serves it, and the package-shipped agent PUT answers NOT_OVERRIDABLE where revision 5's history cited only the runtime-name NOT_CREATABLE. Re-derived at base 9a7838edf5. No clause changed", + "ref": "#24649" } ] }, @@ -121,7 +127,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": 3, + "revision": 4, "priority": "P1", "surface": "api", "fixtures": { @@ -132,7 +138,7 @@ ] }, "variants": [ - "OAuth scope data:read → read-family tools: list_objects, describe_object, query_records, get_record (packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_DATA_READ)", + "OAuth scope data:read → read-family tools: list_objects, describe_object, validate_expression, query_records, get_record, and aggregate_records where the bridge implements aggregate (packages/mcp/src/mcp-http-tools.ts canRead block; packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_DATA_READ)", "OAuth scope data:write → write-family tools: create_record, update_record, delete_record", "OAuth scope actions:execute → list_actions, run_action" ], @@ -142,7 +148,7 @@ "POST /api/v1/mcp/skill (wrong method); capture the 405 and its Allow header", "POST /api/v1/mcp with NO credentials (an MCP initialize body); capture the 401 body and any WWW-Authenticate header", "open an MCP Streamable HTTP session against /api/v1/mcp with the osk_ key: initialize, then tools/list; record the full tool-name set", - "if the OAuth track is live in the environment, request /mcp with an OAuth token carrying NONE of the three MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"", + "if the OAuth track is live in the environment, request /mcp with an OAuth token (to mint one locally, register the client with application_type: 'native' — dynamic client registration refuses a web client whose redirect is http or a loopback host, 'web clients require https redirect URIs on non-loopback hosts', @better-auth/oauth-provider) carrying NONE of the three MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"", "capture the dispatcher-vs-hono distinction: the disabled boot's 404 comes from the gate (surface not advertised), never a hang or 500" ], "acceptance": [ @@ -201,7 +207,8 @@ "history": [ { "revision": 1, "date": "2026-08-07", "change": "initial — the #3358 sweep could not drive MCP because the config was off; both sides are now explicit", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 3, "date": "2026-10-16", "change": "clause 5's tool list omitted resume_run (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 with an admin osk_ key: tools/list returns twelve tools, resume_run among them; mcp-http-tools.ts registers it only when the bridge exposes resumeRun, the same optional shape as aggregate_records, so the verify now names that degradation too", "ref": "#24036" } + { "revision": 3, "date": "2026-10-16", "change": "clause 5's tool list omitted resume_run (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 with an admin osk_ key: tools/list returns twelve tools, resume_run among them; mcp-http-tools.ts registers it only when the bridge exposes resumeRun, the same optional shape as aggregate_records, so the verify now names that degradation too", "ref": "#24036" }, + { "revision": 4, "date": "2026-10-19", "change": "variants[0] and steps[5] corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24540, ai.mcp-http-surface; wave anchor #24579 §3). The data:read family also serves aggregate_records and validate_expression (both read-only), and a local OAuth leg needs a native client. Re-derived at base 9a7838edf5 (mcp-http-tools.ts registers validate_expression and, when the bridge exposes aggregate, aggregate_records inside the canRead gate; the oauth-provider DCR rule in the installed @better-auth/oauth-provider 1.7.3). No clause changed", "ref": "#24649" } ] }, { @@ -336,7 +343,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": 3, + "revision": 4, "priority": "P1", "surface": "api", "fixtures": { @@ -382,7 +389,7 @@ "evidence": "the log excerpt" }, { - "clause": "system objects are refused wholesale: run_action against a sys_* object errors with the system-object guard ('… is on a system object and is not exposed via MCP'), never executes", + "clause": "system objects are refused wholesale: run_action against a sys_* object errors with the system-object guard in either of its two spellings — with objectName the MCP tool layer answers 'Object \"sys_x\" is a system object and its actions are not exposed via MCP' (mcp-http-tools.ts); without it the runtime guard answers \"Action '' is on a system object and is not exposed via MCP\" (action-execution.ts) — and never executes", "oracle": "api", "verify": "the refusal text + absence of any side effect", "evidence": "the refusal" @@ -402,7 +409,8 @@ "history": [ { "revision": 1, "date": "2026-08-07", "change": "initial import from the 15.1 plan §A9", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 3, "date": "2026-10-16", "change": "step 1 called list_actions with objectName: 'showcase_task' as a second form (#23878 checklist-accuracy finding). list_actions takes no arguments: re-measured on main c91af8ed6 the objectName call answers isError with MCP error -32602 'Unrecognized key(s) … objectName', while the no-argument call returns [showcase_portfolio_snapshot]. Re-run of the item's clauses on that boot: showcase_mark_done refused with the actionAiExposureError prescription and the task unchanged; showcase_portfolio_snapshot returns accounts 14 / projects 5 / invoices 12, equal to the REST totals; the [action-audit] MCP run_action line names the caller; a sys_* object is refused", "ref": "#24036" } + { "revision": 3, "date": "2026-10-16", "change": "step 1 called list_actions with objectName: 'showcase_task' as a second form (#23878 checklist-accuracy finding). list_actions takes no arguments: re-measured on main c91af8ed6 the objectName call answers isError with MCP error -32602 'Unrecognized key(s) … objectName', while the no-argument call returns [showcase_portfolio_snapshot]. Re-run of the item's clauses on that boot: showcase_mark_done refused with the actionAiExposureError prescription and the task unchanged; showcase_portfolio_snapshot returns accounts 14 / projects 5 / invoices 12, equal to the REST totals; the [action-audit] MCP run_action line names the caller; a sys_* object is refused", "ref": "#24036" }, + { "revision": 4, "date": "2026-10-19", "change": "acceptance[4].clause quotes both live spellings of the guard — checklist-accuracy finding of the 18.0 QA sweep (QA run #24540, ai.mcp-run-action-exposure-gate clause 4; wave anchor #24579 §3): the clause's quote matched only the runtime guard's. Re-derived at base 9a7838edf5 (packages/mcp/src/mcp-http-tools.ts and packages/runtime/src/action-execution.ts)", "ref": "#24649" } ] }, { @@ -480,13 +488,13 @@ "title": "Authored skill instructions project onto the MCP prompts primitive (#3905) — listed with identity, fetchable by name, and NOT projected when instruction-less or inactive", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "fixtures": { "app": "any", "requires": [ - "three authored skills in a writable package: qa_prompt_full (instructions + label + description), qa_prompt_bare (tools only, NO instructions), qa_prompt_off (instructions but active:false) — or the same three authored at runtime with PUT /api/v1/meta/skill/NAME on stock showcase (admin; 200), which the projection serves at once", + "three authored skills in a writable package: qa_prompt_full (instructions + label + description), qa_prompt_bare (tools only, NO instructions), qa_prompt_off (instructions but active:false) — or the same three authored at runtime with PUT /api/v1/meta/skill/NAME on stock showcase (admin; 200) — every body carries `tools` (SkillSchema requires it: `tools: []` for qa_prompt_full and qa_prompt_off, else 422 INVALID_METADATA 'tools: expected array, received undefined') — which the projection serves at once", "MCP HTTP reachable with an osk_ key (see ai.mcp-http-surface)" ], "knownGaps": [ @@ -547,7 +555,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: the open framework's one live skill consumer (#3905) had no checklist coverage", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-10-17", "change": "steps[5] said 'reload metadata' without naming what makes a source edit live (checklist-accuracy finding, #23878 close-out): os dev serves the built dist, so a bare restart re-serves the old definition at /meta while the MCP projection follows the source. The step now names the rebuild routes (the watcher's rebuild + restart, objectstack build, --compile), the stale-boot warning, the runtime PUT route, and a /meta active:true check before re-listing; the fixture names the runtime-authored route. Re-run on main c91af8ed6. Runtime route on stock showcase: three skills PUT (200 each); initialize declares the prompts capability; prompts/list carries qa_prompt_full with title and description, not qa_prompt_bare or qa_prompt_off; prompts/get qa_prompt_full returns the authored instructions verbatim; prompts/get qa_prompt_bare and an unknown name answer -32602; PUT qa_prompt_off active:true and the next list carries it. File route on a scratch copy of the showcase under os dev: list carried only qa_prompt_full; saving active:true rebuilt and restarted the server and the list then carried qa_prompt_off (/meta active true); flipping it back to false with the server stopped and restarting without a build printed the stale-dist warning, and on that boot prompts/list omitted qa_prompt_off while GET /api/v1/meta/skill/qa_prompt_off still read active true. Clause 4: packages/mcp src/skill-prompts.test.ts 12 passed", "ref": "#24036" } + { "revision": 2, "date": "2026-10-17", "change": "steps[5] said 'reload metadata' without naming what makes a source edit live (checklist-accuracy finding, #23878 close-out): os dev serves the built dist, so a bare restart re-serves the old definition at /meta while the MCP projection follows the source. The step now names the rebuild routes (the watcher's rebuild + restart, objectstack build, --compile), the stale-boot warning, the runtime PUT route, and a /meta active:true check before re-listing; the fixture names the runtime-authored route. Re-run on main c91af8ed6. Runtime route on stock showcase: three skills PUT (200 each); initialize declares the prompts capability; prompts/list carries qa_prompt_full with title and description, not qa_prompt_bare or qa_prompt_off; prompts/get qa_prompt_full returns the authored instructions verbatim; prompts/get qa_prompt_bare and an unknown name answer -32602; PUT qa_prompt_off active:true and the next list carries it. File route on a scratch copy of the showcase under os dev: list carried only qa_prompt_full; saving active:true rebuilt and restarted the server and the list then carried qa_prompt_off (/meta active true); flipping it back to false with the server stopped and restarting without a build printed the stale-dist warning, and on that boot prompts/list omitted qa_prompt_off while GET /api/v1/meta/skill/qa_prompt_off still read active true. Clause 4: packages/mcp src/skill-prompts.test.ts 12 passed", "ref": "#24036" }, + { "revision": 3, "date": "2026-10-19", "change": "fixtures.requires[0] gives the runtime-authored skill body the shape the door accepts — checklist-accuracy finding of the 18.0 QA sweep (QA run #24540, ai.skill-instructions-mcp-prompts fixtures.requires; wave anchor #24579 §3). Re-derived at base 9a7838edf5 (packages/spec/src/ai/skill.zod.ts: `tools` is a required array). No clause changed", "ref": "#24649" } ] }, { @@ -824,7 +833,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": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -835,11 +844,11 @@ "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 on a scratch boot) with apiEnabled:false" + "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)" ] }, "steps": [ - "call the MCP get_record tool for a missing id; call query_records on the apiEnabled:false object", + "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" ], @@ -884,6 +893,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "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" } ] } diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 31660299067..53363b11ad5 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -178,12 +178,12 @@ "title": "Removed/retired authoring keys fail at parse/build with located guidance", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "build", "steps": [ "sample retired keys across the ADR-0087 registries, at minimum: a query-surface tombstone (QueryAST cursor / joins / distinct / windowFunctions — retiredKey per #4286), the error-envelope tombstone (EnhancedApiError.fieldErrors, renamed to fields by ADR-0114 D4), and one entry from the retired-filter-operator registry in packages/spec/src/data/filter.zod.ts (RetiredFilterOperatorGuidance)", - "author a config/payload using each sampled key: parse the query via the spec schema (QuerySchema.safeParse with cursor: {...}), parse an error envelope carrying fieldErrors; run ObjectSchema.create / os build where the key is config-side. The retired filter operator is DIFFERENT and is driven at runtime: author a stored filter carrying it (e.g. { name: { $regex: 'acme' } } in a view or widget filter) and confirm parse/build stay GREEN (FilterConditionSchema is an open record keyed by field name — no tombstone can live on it); then send the same predicate as the where of a query request on the running stack (e.g. POST /api/v1/data//query) and capture the 400 INVALID_FILTER refusal; then run `os migrate meta --from 16` and locate the filter-regex-options-retired advisory", + "author a config/payload using each sampled key: parse the query via the spec schema (QuerySchema.safeParse with cursor: {...}), parse an error envelope carrying fieldErrors; run ObjectSchema.create / os build where the key is config-side. The retired filter operator is DIFFERENT and is driven at runtime: author a stored filter carrying it on a FilterCondition-typed slot — a dashboard widget's `filter`, e.g. { name: { $regex: 'acme' } } (QA run #24541 used an ops-dashboard widget). ⛔ Not a view: a view's `filter` is the ViewFilterRule array [{ field, operator, value }] with a closed operator vocabulary, so the $-object predicate is no view shape at all and confirm parse/build stay GREEN (FilterConditionSchema is an open record keyed by field name — no tombstone can live on it); then send the same predicate as the where of a query request on the running stack (e.g. POST /api/v1/data//query) and capture the 400 INVALID_FILTER refusal; then run `os migrate meta --from 16` and locate the filter-regex-options-retired advisory", "capture each rejection's full text", "the documented-strip control: parse an UNKNOWN key on the RestServerConfig `routes` sub-object — RestServerConfigSchema.safeParse({ routes: { someUnknownKey: 1 } }) (or RouteGenerationConfigSchema directly) — and confirm it parses clean with the key gone (routes reads back {}). The behavior is declared in packages/spec/src/api/rest-server.zod.ts: RouteGenerationConfigSchema's docblock states the schemas are not `.strict()`, which is exactly why its retired keys carry tombstones. No spec docblock declares a 'pure display annotation' that is stripped by design — that example is not the control; this documented non-strict sub-object is", "record which registry each sampled key came from" @@ -250,6 +250,12 @@ "date": "2026-10-16", "change": "the strip-by-design leg named no concrete key, and no spec docblock declares a 'pure display annotation' that is stripped by design (#23878 checklist-accuracy finding; searched on main c91af8ed6). The leg now names the documented control the run used: an unknown key on RestServerConfig.routes parses clean to routes {} because RouteGenerationConfigSchema's docblock declares the schemas non-strict, and the same sub-object's retired key includeObjects is refused with its prescription. Re-run against the built @objectstack/spec: safeParse success with {} for the unknown key, failure naming '`routes.includeObjects` was removed …' for the tombstone", "ref": "#24036" + }, + { + "revision": 5, + "date": "2026-10-19", + "change": "steps[1] names a slot the $-operator predicate can be stored on — checklist-accuracy finding of the 18.0 QA sweep (QA run #24541, api-backend.enforce-or-remove-authoring-gates step 2; wave anchor #24579 §3): a view filter is the array shape [{field, operator, value}], not a FilterCondition. Re-derived at base 9a7838edf5: ViewFilterRuleSchema (packages/spec/src/ui/view.zod.ts) is a strict rule object, while the widget filter is FilterConditionSchema (ui/dashboard.zod.ts). No clause changed", + "ref": "#24649" } ] }, @@ -342,7 +348,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": 3, + "revision": 4, "priority": "P0", "surface": "api", "personas": [ @@ -361,7 +367,7 @@ "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}}", "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", "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 (plus system identity keys); 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)", + "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", "expand: as the CONTRIBUTOR (restricted persona), POST /api/v1/data/showcase_invoice/query with expand: {contact: {object: 'showcase_contact', fields: ['name']}} on an invoice they own — contributor holds NO showcase_contact grant, so the expanded row must be withheld/masked while their own invoice row returns (15.1 §A6: expand routes through the secured find path, #2850)", "negative shapes: POST query where {status: {$nin: 'done'}} on showcase_task (scalar comparand for a collection operator — #5869) and the AST twin ['status','not_in','done'], plus the GET twin ?$filter=[\"status\",\"not_in\",\"done\"] (the two doors answer different codes — see the clause); GET with an unknown $-param (?$pageSize=5); GET with a bare unknown key (?not_a_field=x); sort on a nonexistent field; a where clause naming a nonexistent field", @@ -494,6 +500,12 @@ "date": "2026-10-01", "change": "re-pointed to what the doors answer since #20116 (QA run #21056 scored acceptance[5] — the run record's A6 — FAIL; verifier disposition: stale clause, not a product defect). acceptance[5]: POST /query, both spellings, is refused at the request schema with 400 VALIDATION_FAILED + a located fields[] entry; GET $filter still answers 400 INVALID_FILTER; the step-8 GET twin was added. acceptance[2] and step 5: a dotted fields entry is refused 400 INVALID_FIELD by design, prescribing expand. negative[0]: {\"filter\": …} is a declared transport spelling of where, so the widening probe now uses a mistyped clause (400 VALIDATION_FAILED) and an undeclared key (400 INVALID_FIELD). Residual for the maintainer, not a defect: the same condition answers VALIDATION_FAILED on POST and INVALID_FILTER on GET (#8001 family)", "ref": "#21060" + }, + { + "revision": 4, + "date": "2026-10-19", + "change": "steps[4] states the projection the build serves — checklist-accuracy finding of the 18.0 QA sweep (QA run #24556, api-backend.query-contract-matrix step 5; wave anchor #24579 §3): $select narrows to exactly the requested keys and adds no identity key. Re-derived at base 9a7838edf5 (objectql engine.ts: SqlDriver emits builder.select(query.fields) verbatim). No clause changed", + "ref": "#24649" } ] }, @@ -502,7 +514,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": 4, + "revision": 5, "priority": "P1", "surface": "api", "personas": [ @@ -547,9 +559,9 @@ "evidence": "the bodies" }, { - "clause": "HTTP status matches the code's declared mapping (HttpStatusErrorCodeMap): 400 validation/filter/param codes, 401 UNAUTHENTICATED, 403 PERMISSION_DENIED, 404 not-found flavors", + "clause": "HTTP status agrees with the code: a standard-catalog code answers the status HttpStatusErrorCodeMap maps it from (VALIDATION_ERROR 400, UNAUTHENTICATED 401, PERMISSION_DENIED 403, RESOURCE_NOT_FOUND 404, …), and a registered extension code (OBJECT_NOT_FOUND, RECORD_NOT_FOUND, VALIDATION_FAILED, …) answers a status in its condition's family (ErrorHttpStatusMap: validation 400, not_found 404, authorization 403, conflict 409, …). HttpStatusErrorCodeMap is the status → FLOOR-code map — the code a bare status derives — not a code → status table, so it names no status for an extension code", "oracle": "api", - "verify": "status-vs-code table across the samples; any mismatch (e.g. a 500 carrying a caller-fixable code) fails", + "verify": "status-vs-code table across the samples: each standard code judged against HttpStatusErrorCodeMap read in reverse, each extension code by its family; any mismatch (e.g. a 500 carrying a caller-fixable code) fails", "evidence": "the table" }, { @@ -611,6 +623,12 @@ "date": "2026-10-16", "change": "step 6 sent the BATCH_NOT_ATOMIC sample as options {atomic:false} (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: that body answers 200 and creates the row, because batch.zod strips the options wrapper; a TOP-LEVEL atomic:false answers 400 BATCH_NOT_ATOMIC ('Cross-object batch is always atomic; use POST /data/:object/batch …'), a code ErrorCode carries. The step now uses the top-level spelling; the clauses are unchanged", "ref": "#24036" + }, + { + "revision": 5, + "date": "2026-10-19", + "change": "acceptance[3] (clause, verify) names the oracle it can be judged by — checklist-accuracy finding of the 18.0 QA sweep (QA run #24541, api-backend.error-envelope-ledger acceptance[3]; wave anchor #24579 §3): HttpStatusErrorCodeMap is a status → floor-code map (400 → VALIDATION_ERROR, 404 → RESOURCE_NOT_FOUND, …), and OBJECT_NOT_FOUND / RECORD_NOT_FOUND / VALIDATION_FAILED have no entry in it. Re-derived at base 9a7838edf5 (packages/spec/src/api/errors.zod.ts: HttpStatusErrorCodeMap, standardErrorCodeForHttpStatus, ErrorHttpStatusMap)", + "ref": "#24649" } ] }, @@ -619,7 +637,7 @@ "title": "Bulk write doors (createMany/updateMany/deleteMany, per-object batch): per-row outcomes, size cap, single/bulk parity, no silent full-table writes", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -637,7 +655,7 @@ "boot showcase isolated; as the member POST /api/v1/data/showcase_private_note/createMany with a BARE JSON ARRAY of 3 notes with unique-marker titles (the body is the array; a {records} wrapper answers 400 VALIDATION_FAILED); GET them back", "single/bulk parity: POST a 4th note via the single door (POST /api/v1/data/showcase_private_note) and diff its response/stored shape against one createMany row (same fields stamped: owner_id, timestamps, defaults)", "as the member POST /api/v1/data/showcase_private_note/updateMany {\"records\": [{\"id\": \"\", \"data\": {…}}, …]} naming ONLY the marker rows' ids — updateMany is id-based (UpdateManyRequestSchema); a {where, data} body answers 400 VALIDATION_FAILED naming `records`; re-read", - "as the member POST updateMany naming ids that match nothing the member may touch: a nonexistent id, and the id of another user's private note (invisible to the member); capture the per-row results, then re-read the foreign note system-side. Also send {\"records\": []}", + "as the member POST updateMany naming ids that match nothing the member may touch: a nonexistent id, and the id of another user's private note (invisible to the member) — in SEPARATE calls, or in one call with options.continueOnError: true: under the default (continueOnError: false) the first failed row ends the run and the second id reports NOT_ATTEMPTED, which says nothing about the no-existence-oracle parity; capture the per-row results, then re-read the foreign note system-side. Also send {\"records\": []}", "as admin POST createMany with records.length == maxBatchSize+1 (201 on default config); capture", "deleteMany is id-based too ({\"ids\": [...]}; a {where} body answers 400 naming `ids`). As the member, POST deleteMany on two of its own marker notes and capture the refusal — the member baseline holds no allowDelete on showcase_private_note (row 0 PERMISSION_DENIED, the rest NOT_ATTEMPTED); then as admin POST deleteMany with the marker ids; re-read; then re-issue the same deleteMany (the ids are gone now)", "throughout: capture per-row result arrays and any ERR_BULK_RESULT_MISMATCH appearance" @@ -658,7 +676,7 @@ { "clause": "a write that names nothing the caller may touch is a calm per-row no-op, never a wider write: updateMany naming a nonexistent id, or another user's invisible note, answers 2xx with that row failed RECORD_NOT_FOUND (the same code for both — no existence oracle) and the foreign row unchanged; an empty records list answers 2xx with total 0; a repeated deleteMany reports its now-missing ids RECORD_NOT_FOUND (the #2982 failure shape was bulk writes escaping their scope — an id-based door cannot match more rows than it names)", "oracle": "api", - "verify": "step-4/6 responses: per-row RECORD_NOT_FOUND for the nonexistent and the foreign id, success false, succeeded 0; the foreign note re-reads unchanged system-side; the empty list reports total 0; the repeated deleteMany's first row RECORD_NOT_FOUND", + "verify": "step-4/6 responses: per-row RECORD_NOT_FOUND for the nonexistent and the foreign id (each judged in its own call or under continueOnError: true — a NOT_ATTEMPTED row is the stop, not an answer), success false, succeeded 0; the foreign note re-reads unchanged system-side; the empty list reports total 0; the repeated deleteMany's first row RECORD_NOT_FOUND", "evidence": "responses + control read" }, { @@ -715,6 +733,12 @@ "date": "2026-10-16", "change": "re-pointed to the id-based bulk doors (#23878 checklist-accuracy findings, re-measured on main c91af8ed6). updateMany takes {records:[{id,data}]} and deleteMany {ids:[…]} (a where body answers 400 VALIDATION_FAILED naming records / ids), and createMany a bare array. The where-clause steps and the 'empty match' clause are rewritten for that contract: a nonexistent id and another user's invisible note both answer per-row RECORD_NOT_FOUND with the foreign row unchanged, and records [] answers total 0 — the clause still fails if a row the caller cannot see is written. The baseline member holds no allowDelete on showcase_private_note (member deleteMany: PERMISSION_DENIED then NOT_ATTEMPTED; single DELETE 403), so the delete legs name the member's refusal and run the deletion as admin. BATCH_TOO_LARGE at 201 records re-measured: 400, zero rows", "ref": "#24036" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "steps[3] and acceptance[2].verify send the two ids so each gets an answer — checklist-accuracy finding of the 18.0 QA sweep (QA run #24541, api-backend.bulk-write-contract step 4 / acceptance[2]): updateMany stops at the first failed row by default, so one batch [nonexistent, foreign] reports the foreign id NOT_ATTEMPTED. Re-derived at base 9a7838edf5: BatchOptions continueOnError defaults false (packages/spec/src/api/batch.zod.ts). No clause changed", + "ref": "#24649" } ] }, @@ -1415,7 +1439,7 @@ "title": "A retired metadata DEFINITION (whole schema type withdrawn) refuses at every authoring door — distinct from a retired key, and the door that must not fall through to 'unknown type, ignored'", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "build", "personas": [ @@ -1436,7 +1460,7 @@ "sample at least four entries spanning BOTH version prefixes and at least three different domains (e.g. data/, system/, identity/, ui/)", "for each sampled def, read its entry file and record which ADR-0087 route retired it, whether a prescription was authored, and its CARRIER: the authored key that embedded it, retired as a retiredKey() tombstone (e.g. manifest.loading for kernel/PluginLoadingConfig and its closure; agent.lifecycle for automation/StateMachine and its family), or none — most entries are schema-export definitions with zero consumers: no metadata-type binding, no stack collection, no embedding (e.g. data/ExternalDataSource, automation/ScheduleState). Sample at least one def of each kind", "build door, per sampled def, each in its own scratch stack run through os build: for a def WITH a carrier, author the carrier key (e.g. manifest.loading: { strategy: 'lazy' }; an agent carrying lifecycle) — the refusal is the carrier's tombstone. For a def WITHOUT one there is no metadata of that type to author ('a stack that declares metadata of the retired type' could only be an invented key, which is the control below), so drive the door it actually had: import its Zod export from its @objectstack/spec subpath in the config (e.g. ExternalDataSourceSchema from '@objectstack/spec/data') and confirm json-schema/DOMAIN/NAME.json is absent from packages/spec/json-schema", - "runtime door against a live boot: PUT /api/v1/meta/TYPE/NAME carrying the carrier key where its type is runtime-writable, and record a code-only type's up-front refusal where it is not (agent is code-only); for a zero-consumer def, PUT a type name spelled after it (e.g. external_data_source) — no type was ever bound, so the expected answer is the same refusal as the control", + "runtime door against a live boot: PUT /api/v1/meta/TYPE/NAME carrying the carrier key where its type is runtime-writable, and record a code-only type's up-front refusal where it is not (agent is code-only); manifest.loading's carrier has no runtime metadata-write door at all — `manifest` (like package and plugin) is not a registered metadata type, so PUT /api/v1/meta/manifest/… answers the control's own 400 'is not a metadata type': record it as that, like the code-only agent; for a zero-consumer def, PUT a type name spelled after it (e.g. external_data_source) — no type was ever bound, so the expected answer is the same refusal as the control", "as a control, author an invented top-level stack key (e.g. qaInventedFamily) at the build door and PUT an invented type name at the runtime door, and compare their refusals to the retired-def refusals" ], "acceptance": [ @@ -1491,6 +1515,12 @@ "date": "2026-10-17", "change": "steps re-pointed (checklist-accuracy finding, #23878 close-out): RETIRED_DEFS entries are schema-export definitions, most with no metadata-type binding or stack collection, so 'a stack that declares metadata of each sampled retired type' could only be an invented key. Steps 3–6 now sample by carrier (an authored key retired as a tombstone, or none), drive the carrier key or the schema-export import at os build, name the runtime type each reaches, and make the control an invented stack key and an invented type; knownGaps[0] records the reading. No clause changed. Re-run with os build on a minimal scratch stack (baseline exit 0): 17 data/ExternalDataSource (zero consumers) import → exit 2 \"The requested module '@objectstack/spec/data' does not provide an export named 'ExternalDataSourceSchema'\"; 18 automation/ScheduleState (zero consumers) import → exit 2, the same generic shape; 17 kernel/PluginLoadingConfig via manifest.loading → exit 2 'manifest.loading was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block …'; 18 automation/StateMachine via agent.lifecycle → exit 2 'agent.lifecycle was removed in @objectstack/spec 17.7.0 … Delete the key …'; control qaInventedFamily → exit 2 'Unrecognized key(s) on this stack definition'. The carrier refusals name the retirement and differ from the control; the zero-consumer ones are generic, which knownGaps[0] scores as correct. json-schema/data/ExternalDataSource.json, automation/ScheduleState.json and automation/StateMachine.json are absent. Runtime door on stock showcase: PUT /meta/agent/NAME with lifecycle → 403 'Metadata type agent is code-only …'; PUT /meta/external_data_source/NAME and the invented control → the same 400 \"… is not a metadata type\"", "ref": "#24036" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "steps[4] says what manifest.loading's runtime door is — checklist-accuracy finding of the 18.0 QA sweep (QA run #24541, api-backend.retired-def-refusal step 5; wave anchor #24579 §3): the carrier's type has no runtime write door. Re-derived at base 9a7838edf5: DEFAULT_METADATA_TYPE_REGISTRY (packages/spec/src/kernel/metadata-plugin.zod.ts) has no manifest, package or plugin entry, and the meta door answers an unregistered type 'is not a metadata type' (metadata-protocol protocol.ts). No clause changed", + "ref": "#24649" } ] }, @@ -1499,7 +1529,7 @@ "title": "A disabled packaged action is refused 409 ACTION_DISABLED at BOTH dispatch doors — after the capability gate (a 403 is never an oracle for the switch), before the param contract and the record load, durable across restart", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P0", "surface": "api", "personas": [ @@ -1525,7 +1555,7 @@ "REST door: POST /api/v1/actions/showcase_task/showcase_mark_done with {\"recordId\": \"no-such-record-0000\"}; capture status + body; then re-read a REAL seeded task and confirm no done/progress write happened anywhere", "param-ordering discriminator: disable showcase_action_param_gallery (POST /actions/_activation/showcase_field_zoo/showcase_action_param_gallery {\"enabled\":false}), then invoke POST /actions/showcase_field_zoo/showcase_action_param_gallery with params: {\"bogus_key\": 1} (missing required p_text AND an unknown key) plus a nonexistent recordId; capture. Re-enable and repeat the IDENTICAL request; capture the contrast", "403-oracle: POST /actions/showcase_field_zoo/showcase_zoo_perm_missing while it is ENABLED; capture the 403 body. Disable it via /actions/_activation/showcase_field_zoo/showcase_zoo_perm_missing, repeat the identical invocation, capture again; byte-compare the two refusals", - "MCP door: disable showcase_portfolio_snapshot (POST /actions/_activation/global/showcase_portfolio_snapshot {\"enabled\":false}); call the MCP run_action tool for it; capture the tool error envelope. Re-enable and confirm the tool call runs again", + "MCP door: disable showcase_portfolio_snapshot (POST /actions/_activation/global/showcase_portfolio_snapshot {\"enabled\":false}); call the MCP run_action tool for it with { actionName: 'showcase_portfolio_snapshot' } (the argument is `actionName` — an `action` key is refused -32602 with a did-you-mean); capture the tool error envelope. Re-enable and confirm the tool call runs again", "restart durability: RESTART the server (hydrateActionActivations re-loads the ledger at boot, after the authored-action re-sync); wait for the serving line, then invoke showcase_mark_done again → capture; re-enable it via the activation door → invoke again → dispatches; re-read the ledger row (active true, row still present)", "flow-type leg (variant): disable showcase_bulk_reassign (type 'flow', target showcase_reassign_wizard) and invoke it over REST — the refusal must be the ACTION's own switch, ahead of the type branch and of any consult of the target flow's ledger row" ], @@ -1622,6 +1652,12 @@ "date": "2026-10-01", "change": "knownGaps no longer presents a live MCP call as possibly undrivable: a stock boot mounts POST /api/v1/mcp (named in the boot banner), and QA run #21056 drove tools/call run_action live for clause 4. The unit pin stays the fallback where the endpoint is absent", "ref": "#21060" + }, + { + "revision": 4, + "date": "2026-10-19", + "change": "steps[5] names the MCP argument — checklist-accuracy finding of the 18.0 QA sweep (QA run #24556, api-backend.packaged-action-disabled-dispatch step 6; wave anchor #24579 §3). Re-derived at base 9a7838edf5 (packages/mcp/src/mcp-http-tools.ts run_action input: actionName, objectName, recordId). No clause changed", + "ref": "#24649" } ] }, @@ -1630,7 +1666,7 @@ "title": "POST /actions/_activation/:object/:action — the activation door's ordered arms: authority before body and lookup, the exact 400 shapes, 404 undeclared, 409 ambiguity, 501/503 honesty, and the reserved segment surviving the real mount", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -1647,7 +1683,8 @@ "knownGaps": [ "The AMBIGUITY arm (409 RESOURCE_CONFLICT from refuseAmbiguousActionActivation — two objects declaring one action name) has NO stock fixture: every showcase action name is unique across both action files (verified — the machine names are all showcase_-prefixed and declared once), so the live leg needs an authored scratch package declaring the SAME action name on two objects. Do not fake it; the arm is pinned at unit level (action-activation-posture-gate.test.ts: 409 RESOURCE_CONFLICT naming both owners, and setActionActive NEVER called — no row on refusal), and a run without the scratch package scores the clause from that pin, recorded as such.", "The 501 arm (engine without setActionActive) and the 503 arm (attached store that throws / no ledger attached) are unreachable on a stock boot — the stock engine implements the projection and the plugin attaches the store. Both are pinned at unit level (action-activation-posture-gate.test.ts: 501 naming ADR-0126 §8; 503 SERVICE_UNAVAILABLE rather than a 200 for a write that did not persist). #12359 measured the 503 live once (actions-without-automation composition) and the registration MOVE closed it — packaged-activation-ledger-reach.dogfood.test.ts keeps that composition green.", - "The group/isolated POSTURE legs of the §5 operator gate need an enterprise tenancy runtime no example app composes — the gate itself (both tiers, all postures) is access-security territory (activation-write-operator-gate); this item asserts only the ORDER (authority before body/lookup), which the stock single-posture boot can prove through the manage_metadata tier." + "The group/isolated POSTURE legs of the §5 operator gate need an enterprise tenancy runtime no example app composes — the gate itself (both tiers, all postures) is access-security territory (activation-write-operator-gate); this item asserts only the ORDER (authority before body/lookup), which the stock single-posture boot can prove through the manage_metadata tier.", + "acceptance[4]'s metadata-plane 503 arm — the declaration lookup reports `degraded` (packages/runtime/src/domains/actions.ts, the 'Cannot verify the declaration … the metadata plane is unavailable' refusal) — is unreachable on a stock boot and pinned by no file in automated.ref: action-activation-posture-gate.test.ts's 503 is the store that cannot persist the row, a different arm, and http-dispatcher.actions-type-dispatch.test.ts '503s rather than running ungated when the metadata plane cannot answer' pins the DISPATCH door's use of the same lookup, not this door. Record that arm as not covered — no live door, no pin — never as a pass carried by the 404" ] }, "steps": [ @@ -1687,7 +1724,7 @@ { "clause": "the declaration arm distinguishes its three states: an undeclared action → 404 'has no declaration — there is nothing to switch off' (the ledger addresses DECLARED actions, ADR-0126 §4; a typo is not a server fault, #7535); a metadata plane that cannot answer → 503 (an outage is not a verdict); and NO refused probe of any arm writes a sys_metadata_activation row", "oracle": "api", - "verify": "step-5: 404 with the nothing-to-switch-off sentence; the ledger read shows zero rows written by any refusal (the only rows present are the happy path's)", + "verify": "step-5: 404 with the nothing-to-switch-off sentence; the ledger read shows zero rows written by any refusal (the only rows present are the happy path's). The metadata-plane 503 arm has neither a stock door nor a pin (knownGaps): score it not covered, and the clause partial on its account", "evidence": "the 404 trace + the ledger read" }, { @@ -1747,6 +1784,12 @@ "date": "2026-09-04", "change": "ledger read-back detail and one citation — the door's ordered arms are UNCHANGED and no arm moved. Two corrections, both downstream of #15024 dropping sys_metadata_activation's reserved organization_id column before it ever shipped. (1) VACUITY: step 6 and the happy-path clause read the column back and asserted it NULL. With the column gone that read passes rather than failing (`row.organization_id ?? null` is `null` either way), so the leg was green for the wrong reason; both now score the row's KEY SET, the same inversion #15024 applied to the dogfood pin. automated.ref carried the same staleness as 'one org-NULL row' and now quotes the pin's live name. (2) DECISION: the clause glossed the column as 'the per-org dimension is reserved and unwritten', citing ADR-0126 §5 D3 — a bullet ADR-0131 D7 WITHDREW ('a reserved nullable tenant column is the shape D1 forbids'), naming this table as reverted before 17.3 and not returning. Left as it stood it told a runner the tenant dimension was coming back. The source list now carries ADR-0131 D7 and marks which half of §5 survives: the operator gate stands, the reserved column does not", "ref": "#15253" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "knownGaps and acceptance[4].verify record the one arm nothing covers — checklist-accuracy finding of the 18.0 QA sweep (QA run #24541, api-backend.action-activation-door-contract acceptance[4]; wave anchor #24579 §3): the metadata-plane-degraded 503 arm is named in the clause but is neither stock-reachable nor pinned by any file in automated.ref. Re-derived at base 9a7838edf5: the arm is actions.ts's declaration.degraded branch; action-activation-posture-gate.test.ts pins the non-durable store 503 instead, and the metadata-plane 503 pin that exists (http-dispatcher.actions-type-dispatch.test.ts) drives the dispatch door. No clause changed", + "ref": "#24649" } ] }, @@ -1755,7 +1798,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": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -1774,16 +1817,16 @@ }, "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.", - "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 (e.g. via the timezone lever), and score the CONTRACT, which is path-independent." + "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." ] }, "steps": [ "read the two enums and pin the matrix: AggregationFunction (packages/spec/src/data/query.zod.ts — 6 members: count/sum/avg/min/max/count_distinct, with retirement error messages wired for array_agg/string_agg) and DateGranularity (— 5 members: day/week/month/quarter/year). Only ONE enumSource pin fits an item; AggregationFunction carries it — hand-count DateGranularity's 5 members at run time and record the count", "boot showcase isolated; 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. 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)", + "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 → the shape refusal with the worked example", + "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", "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": [ @@ -1827,7 +1870,7 @@ "negative": [ "a refused/unknown aggregation degrading to RAW ROWS with a 200 is the catastrophic direction — the aggregate action door's own rule ('grouped/aggregated output must stay the only thing this action can ever return', action-execution.ts) exists because the engine's in-memory path degrades to raw rows when neither aggregations nor groupBy survive, and the FLS result masker does not cover the aggregate op", "an aggregation over a nonexistent field answering 200 (a computed value over nothing) instead of the located refusal is the #4134 class applied to the aggregation axis", - "the two bucket paths (driver pushdown vs in-memory) labeling the same instant differently is the #3773 seam — record both labels if the timezone lever exposes the divergence; it is a FAIL even when each path is self-consistent" + "the two bucket paths (driver pushdown vs in-memory) labeling the same instant differently is the #3773 seam — record both labels if the timezone lever exposes the divergence; it is a FAIL even when each path is self-consistent. For a `date` dimension (showcase_delivery.due_date and showcase_account.signed_on are Field.date) the label must be the same in EVERY zone — a calendar date has no instant to shift; only a `datetime` dimension legitimately labels differently per zone (QA run #24595 measured the in-memory path shifting a date a day earlier under a negative offset — #24618)" ], "variants": [ "fn:count", @@ -1874,6 +1917,12 @@ "date": "2026-10-16", "change": "the secured-aggregate leg named Mei Phone or Ada Auditor as the restricted persona, and neither gives a differing visible set (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: Mei's grouped count on showcase_invoice answers 403 PERMISSION_DENIED at the object gate and Ada's equals the admin's (viewAllRecords). The item now opts into search:qa-contributor-bound-member, and the re-run on that persona — one own invoice on an active account — gave [{draft: 1}] against the admin's [{draft: 3}, {paid: 5}, {sent: 5}]", "ref": "#24036" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "steps[3], steps[5], knownGaps[1] and negative[2] name the doors that answer — checklist-accuracy findings of the 18.0 QA sweep (QA run #24541 step 6, and QA run #24595 knownGaps[1] / step 4 / negative[2], api-backend.aggregate-contract-matrix; wave anchor #24579 §3, comment 6272314386). On POST /data/:object/query a non-array aggregations is refused by the request schema before the protocol's worked example, and that door carries no timezone; the lever is POST /api/v1/analytics/query on cube showcase_delivery / due_date. negative[2] now says a date dimension's labels are zone-independent; that row's FAIL was the product defect #24618, which PR #24642 (787939130c) fixed on main after this round's base; the clause is unchanged. Re-derived at base 9a7838edf5 (protocol.ts assertAggregationFieldsExist, objectql engine.ts ENGINE_AGGREGATE_OPTION_KEYS, spec data/analytics.zod.ts timezone, examples/app-showcase/src/data/analytics/showcase.cube.ts)", + "ref": "#24649" } ] }, @@ -1882,7 +1931,7 @@ "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", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -1902,7 +1951,7 @@ "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", "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); 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 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", "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 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", @@ -1979,6 +2028,12 @@ "date": "2026-10-13", "change": "clause 1 (acceptance[0], #23104 C1) and clause 6 (acceptance[5], #23104 C6) revised, both stale spec. C1: registerStdLib now chains 29 bare-callable registerFunction calls, the 27 plus isoDate and isoDatetime (345d3f3d8, #22347), and formulas.mdx lists the same 29 in 19 rows. The title, step 1's count and list, step 3, the 27-function knownGap, clause 1's text and evidence, and the registerStdLib source note now say 29. Step 3 gains the isoDate / isoDatetime known answers. Its joinNonEmpty row was an assertion defect inside the item: cel-js refuses the literal ['a', null, ' ', 'b'] at type-check ('List elements must have the same type'), so the row now feeds the same values through record fields and expects 'a-b'. The receiver method `can` (registerPermissionPredicate, #18781) is registered at the head of registerStdLib but is not a bare-callable catalog function; step 1 and clause 1 now name it as outside the count, as validate.ts's CEL_STDLIB_FUNCTIONS docblock already does. C6: the EXPECTED-FAIL clause anticipated a louder authoring-time refusal, and that refusal has landed (f85a83b83, #22031, fixing #22019). The object save door dispatches the build's expression rule, so a formula calling sqrt(...) is refused 422 INVALID_METADATA (rule expression-invalid, a NAME fault) at the active door and on a draft's promotion, and nothing is stored. Clause 6, step 7, negative 3 and the title now assert that refusal. They cite protocol.runtime-authoring-gate.test.ts's #22019 block and the lint pin, and name the read-path residual (a pre-gate row still reads null, now with a warn line). Measured against the built packages/formula/dist at f931e765b: the literal joinNonEmpty row refused, the record-field row 'a-b', isoDate / isoDatetime as stated, sqrt(4) refused no matching overload", "ref": "#23121" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "steps[2] says which half of the coalesce edge an expression can reach — checklist-accuracy finding of the 18.0 QA sweep (QA run #24541, api-backend.formula-stdlib-matrix acceptance[2] / step 3; wave anchor #24579 §3): undefined is not observable through CEL. Re-derived at base 9a7838edf5: coalesce treats null and undefined alike (packages/formula/src/stdlib.ts), and a missing record key faults 'No such key' first. No clause changed", + "ref": "#24649" } ] }, @@ -1987,7 +2042,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": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -1999,7 +2054,7 @@ "app": "showcase", "requires": [ "stock system objects as free negatives — no provisioning at all: sys_session apiMethods ['get','list'] (sys-session.object.ts), sys_device_code ['get'] (sys-device-code.object.ts), sys_api_key ['get','list','update'] (sys-api-key.object.ts), and sys_oauth_consent enable.apiEnabled false (sys-oauth-consent.object.ts) for the 404 fork", - "runtime metadata authoring (manage_metadata) for the scratch-object legs" + "runtime metadata authoring (manage_metadata) for the scratch-object legs — each scratch object body declares a sharingModel (the author-time rule security-owd-unset refuses one without it, 422) and its whitelist as enable.apiMethods" ], "knownGaps": [ "NO showcase user object narrows apiMethods (grepped examples/app-showcase/src — zero declarations), so the subset-whitelist / deny-all / legacy-strip / bulk∧child legs on a USER object need a scratch object authored through the metadata channel (qa_verb_gate_probe). The system objects above cover the 405/404 forks and the ordering leg with no provisioning; record per clause whether the verdict rests on a system object or the scratch object." @@ -2012,7 +2067,7 @@ "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 — and NEVER restore/purge, and NOT the raw 3-element whitelist); POST createMany → refused (bulk not granted); 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", - "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 (rest-server.ts) and the valid sibling row never lands", + "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" ], "acceptance": [ @@ -2031,7 +2086,7 @@ { "clause": "the three-state contract: undefined → unrestricted (a stock object with no whitelist serves every verb — no regression); a whitelist that is [] — authored empty, stripped-to-empty, or a NON-ARRAY value (fails CLOSED, #3545) — → deny-all (every op 405, allowed: []); a subset → exactly its closure. The flipped #3391 semantics: empty means expose NOTHING, not 'no restriction'", "oracle": "api", - "verify": "one probe per state; the deny-all leg via the stripped-pure-legacy scratch object", + "verify": "one probe per state; the deny-all leg via the stripped-pure-legacy scratch object. The NON-ARRAY arm is not live-reachable: PUT /meta/object refuses a non-array enable.apiMethods at the Zod parse (422) before anything stores it, so score that arm from packages/spec/src/data/api-derivation.test.ts 'a non-array apiMethods resolves to deny-all, not unrestricted' (passing at base 9a7838edf5)", "evidence": "the three per-state traces" }, { @@ -2099,6 +2154,12 @@ "date": "2026-08-30", "change": "new — the enable.apiMethods verb gate had no item of its own: identity-auth.session-list-revoke asserts one consequence on one object, and nothing covered the three-state contract, the derived allowed[] closure, bulk∧child, writeMode-precise import, the legacy strip-and-warn (with its deny-all cliff), or the batch per-op ordering. Register corrections folded in during source verification: the strip lives at object.zod.ts wired via z.preprocess (the register's '' pointed at the guidance table); the enforcement docblock is rest-server.ts with the denial producer; and the stock free negatives are richer than 'three system objects' — sys_session/sys_device_code/sys_api_key give three whitelist shapes and sys_oauth_* adds the apiEnabled:false 404 fork", "ref": "#sweep-2026-08-30" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "fixtures.requires[1], steps[6] and acceptance[2].verify corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24541, api-backend.api-methods-verb-gate fixtures and step 7; wave anchor #24579 §3). A scratch object needs a sharingModel (security-owd-unset, error tier); the cross-object /batch refusal names `bulk` (batch-routes.ts checks bulk with the row verb as its child); and a non-array apiMethods is refused by Zod at PUT (\"only producible by a raw/out-of-band metadata write\", api-derivation.ts), so its fail-closed arm is pinned, not live — api-derivation.test.ts measured at base 9a7838edf5 under os-verify-lock (32 passed). Re-derived at base 9a7838edf5. Left alone from the same finding: the OS_METADATA_WRITABLE=object half (object declares allowRuntimeCreate: true, so a NEW object needs no hatch; QA run #24558 authored scratch objects without one)", + "ref": "#24649" } ] }, @@ -2107,7 +2168,7 @@ "title": "RestServerConfig.crud is the CRUD surface's construction contract: five switches gate six mounts, dataPrefix moves the routes and their discovery advertisement together, and both tombstones refuse at construction", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -2122,7 +2183,7 @@ ], "knownGaps": [ "NO stock boot authors this sub-object. `os serve` constructs the REST plugin with a fixed config (only project scoping is threaded) and `os dev` passes none at all, so `crud.operations` / `crud.dataPrefix` are reachable only through createRestApiPlugin({ api: ... }) in embedder code. CORRECTED 2026-09-08 (#15543): this gap used to name a second door, createHonoServerPlugin({ restConfig }) — no such function exists (definition probe: zero across the tree, positive control finds createRestApiPlugin at packages/rest/src/rest-api-plugin.ts#createRestApiPlugin). HonoServerPlugin is a class whose restConfig option has one reader taking api.basePath for the SPA fallback; it never constructs a REST server, so a harness must not try to reach these keys through it. Every non-default clause below is therefore scored with oracle `test` against a real RestServer, and the run record must say so instead of claiming a live deployment was reconfigured. RULED 2026-09-07 (#15543; director seat, summon #17, decision batch #2): this is the RECORDED POSTURE, not a gap awaiting a fix. The keys stay and keep their runtime reads, threading a config through is not taken and retiring them is refused, so the harness scoring described here is the PERMANENT method for every non-default clause rather than a workaround to be retired when some future boot path grows a flag. The schema now says so in its own docblocks (packages/spec/src/api/rest-server.zod.ts, WHO CAN WRITE THIS CONFIG) and the per-key reachability rows are in packages/spec/liveness/{crud,metadata,batch}_endpoints.json, so a runner no longer has to re-derive it from the boot paths.", - "the MOUNT half has no permanent pin: packages/rest/src/rest-sub-config-parse-not-cast.test.ts asserts the NORMALIZED CONFIG a switch produces, and nothing asserts that the route disappears from the route table — the scratch harness is the only observation until that pin exists (recorded in FOLLOW-UPS.md)" + "the MOUNT half IS pinned since #15544: packages/rest/src/rest-config-mount-table.pin.test.ts asserts, as an exact route-set diff against the all-true baseline in both directions, that each crud.operations.* switch (and api.enableCrud) removes exactly its routes — list removing two. rest-sub-config-parse-not-cast.test.ts asserts the NORMALIZED CONFIG a switch produces. Run both before the scratch harness; the harness is now the method only for what neither pins" ] }, "steps": [ @@ -2149,10 +2210,10 @@ "evidence": "the six route-table diffs" }, { - "clause": "an unmounted route is NOT the object-level 404: `enable.apiEnabled: false` answers a handler envelope with OBJECT_API_DISABLED, while a switched-off operation has no handler at all and falls to the routing 404 — the two absences must stay distinguishable", - "oracle": "api", - "verify": "compare the switched-off route's answer with the routing-404 control and with an OBJECT_API_DISABLED body from api-backend.api-methods-verb-gate; a coded envelope where the mount was removed means the switch did not gate the mount", - "evidence": "the three bodies side by side" + "clause": "an unmounted route is NOT the object-level 404: `enable.apiEnabled: false` answers a handler envelope with OBJECT_API_DISABLED, while a switched-off operation leaves no RestServer handler at all — the two absences must stay distinguishable. Scored in the harness: no stock boot carries a non-default crud block (knownGaps[0]), so a switched-off route's wire answer is unobservable, and on a composed server the dispatcher's own data domain (packages/runtime/src/domains/data.ts) serves /data routes too, so a RestServer route table cannot speak for what the composed server answers there", + "oracle": "test", + "verify": "the switched-off operation is absent from getRoutes() (acceptance[1]'s harness and the mount pin), set beside a live OBJECT_API_DISABLED body from api-backend.api-methods-verb-gate and the step-2 routing-404 control; a coded envelope from a route the switch should have removed means the switch did not gate the mount. ⛔ Never record the harness absence as the composed server's wire answer (the dispatcher-vs-hono-route trap)", + "evidence": "the harness route-table diff + the two live bodies side by side" }, { "clause": "`dataPrefix` is the single knob that moves the data surface, and the advertisement moves with it: the CRUD, data-action, sharing and batch registrars all build their base from it, and registerDiscoveryEndpoints sets discovery.routes.data from the same value (ADR-0076 D12 — advertise only what is mounted)", @@ -2194,7 +2255,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/rest/src/rest-sub-config-parse-not-cast.test.ts (the construction half only — §C/§D normalized defaults, §E the two tombstone refusals; the mount-table half has no pin)" + "ref": "packages/rest/src/rest-sub-config-parse-not-cast.test.ts (the construction half — §C/§D normalized defaults for acceptance[5], §E the two tombstone refusals for acceptance[4]) + packages/rest/src/rest-config-mount-table.pin.test.ts (#15544 — the mount half, acceptance[1]: each crud.operations.* row and api.enableCrud as an exact route-set diff); acceptance[0] and [3] need the run" }, "source": [ "packages/spec/liveness/crud_endpoints.json (the ADR-0049 ledger this item answers: five live `operations.*` switches + `dataPrefix`, two dead keys)", @@ -2215,6 +2276,12 @@ "date": "2026-09-08", "change": "the knownGaps were semantically re-characterised, not merely annotated: what they describe as the harness-scoring workaround is now the PERMANENT method, by ruling (#15543, director seat, summon #17, decision batch #2, 2026-09-07) that the crud / metadata / batch keys stay, are embedder-only, and get their reachability written into the schema docblocks and the liveness ledger instead of being re-derived from the boot paths at every run. A runner reading the old text would be waiting for a boot-path flag that is not coming, which is the semantic change this bump records. Two factual corrections ride along, both measured rather than inherited: the door `createHonoServerPlugin({ restConfig })` these gaps named DOES NOT EXIST (HonoServerPlugin is a class whose restConfig reader takes only api.basePath for the SPA fallback and never constructs a REST server), and `os serve` does read a config file — the stack's own top-level `api:` block — forwarding exactly two keys out of it (`packages/cli/src/commands/serve.ts#apiConfig`), so 'nothing the CLI reads' was an over-claim. The acceptance clauses, steps, variants and oracles are untouched: what moved is what a runner should conclude from a non-default clause being unreachable, not what it must measure.", "ref": "#15543" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "knownGaps[1], automated.ref and acceptance[2] corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24541, api-backend.rest-crud-config-contract knownGaps[1] + automated.ref and acceptance[2]; wave anchor #24579 §3). \"The MOUNT half has no permanent pin\" is stale: rest-config-mount-table.pin.test.ts (#15544) pins every crud.operations.* radius, measured at base 9a7838edf5 under os-verify-lock (24 passed). acceptance[2] was an api clause no stock boot can observe (knownGaps[0] makes every non-default clause harness-scored) and its dispatcher-vs-hono-route trap is not discharged by a RestServer-only table, since the dispatcher's data domain also serves /data; it now reads oracle test and says so. Re-derived at base 9a7838edf5; FOLLOW-UPS.md is not edited here", + "ref": "#24649" } ] }, @@ -2223,7 +2290,7 @@ "title": "RestServerConfig.batch is the bulk surface's construction contract: three switches and enableBatchEndpoint gate four per-object mounts (each ANDed with its protocol member), maxBatchSize is the one cap every bulk door measures, and both tombstones refuse at construction", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -2239,7 +2306,7 @@ ], "knownGaps": [ "NO stock boot authors this sub-object (same door as the crud block: `os serve` fixes the config, `os dev` passes none), so every non-default clause is scored with oracle `test` against a real RestServer and the run record must say so. RULED 2026-09-07 (#15543; director seat, summon #17, decision batch #2): this is the RECORDED POSTURE, not a gap awaiting a fix. The keys stay and keep their runtime reads, threading a config through is not taken and retiring them is refused, so the harness scoring described here is the PERMANENT method for every non-default clause rather than a workaround to be retired when some future boot path grows a flag. The schema now says so in its own docblocks (packages/spec/src/api/rest-server.zod.ts, WHO CAN WRITE THIS CONFIG) and the per-key reachability rows are in packages/spec/liveness/{crud,metadata,batch}_endpoints.json, so a runner no longer has to re-derive it from the boot paths.", - "the mount half of `enableBatchEndpoint` / `operations.*Many` has no permanent pin — the existing pins assert the normalized config and the cap, not the route table (recorded in FOLLOW-UPS.md)" + "the mount half of `enableBatchEndpoint` / `operations.*Many` IS pinned since #15544: packages/rest/src/rest-config-mount-table.pin.test.ts asserts each batch.* switch (and api.enableBatch) removes exactly its route as a route-set diff, and its §2 the OTHER conjunct — the four bulk mounts drop when the protocol lacks the member, with every switch still true. Run it before the scratch harness" ] }, "steps": [ @@ -2316,7 +2383,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/rest/src/rest-batch-size-cap.test.ts (the cap on all five doors) + packages/rest/src/rest-sub-config-parse-not-cast.test.ts (bounds, defaults, both tombstone refusals); the mount-table half has no pin" + "ref": "packages/rest/src/rest-batch-size-cap.test.ts (the cap on all five doors) + packages/rest/src/rest-sub-config-parse-not-cast.test.ts (bounds, defaults, both tombstone refusals) + packages/rest/src/rest-config-mount-table.pin.test.ts (#15544 — the mount half: each batch.* row as a route-set diff, and the protocol-member conjunct in §2)" }, "source": [ "packages/spec/liveness/batch_endpoints.json (the ADR-0049 ledger this item answers: maxBatchSize, enableBatchEndpoint and three operations.* live, upsertMany and defaultAtomic dead)", @@ -2338,6 +2405,12 @@ "date": "2026-09-08", "change": "the knownGaps were semantically re-characterised, not merely annotated: what they describe as the harness-scoring workaround is now the PERMANENT method, by ruling (#15543, director seat, summon #17, decision batch #2, 2026-09-07) that the crud / metadata / batch keys stay, are embedder-only, and get their reachability written into the schema docblocks and the liveness ledger instead of being re-derived from the boot paths at every run. A runner reading the old text would be waiting for a boot-path flag that is not coming, which is the semantic change this bump records. Two factual corrections ride along, both measured rather than inherited: the door `createHonoServerPlugin({ restConfig })` these gaps named DOES NOT EXIST (HonoServerPlugin is a class whose restConfig reader takes only api.basePath for the SPA fallback and never constructs a REST server), and `os serve` does read a config file — the stack's own top-level `api:` block — forwarding exactly two keys out of it (`packages/cli/src/commands/serve.ts#apiConfig`), so 'nothing the CLI reads' was an over-claim. The acceptance clauses, steps, variants and oracles are untouched: what moved is what a runner should conclude from a non-default clause being unreachable, not what it must measure.", "ref": "#15543" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "knownGaps[1] and automated.ref corrected — checklist-accuracy finding of the 18.0 QA sweep (QA run #24541, api-backend.rest-batch-config-contract knownGaps[1] + automated.ref; wave anchor #24579 §3): the mount half has a permanent pin, rest-config-mount-table.pin.test.ts (#15544), covering batch.enableBatchEndpoint, operations.*Many and the protocol-member conjunct — measured at base 9a7838edf5 under os-verify-lock (24 passed). No clause changed; FOLLOW-UPS.md is not edited here", + "ref": "#24649" } ] }, @@ -2346,12 +2419,12 @@ "title": "RestServerConfig.metadata is the metadata surface's construction contract: prefix moves eleven mounts and their discovery advertisement, four endpoint switches each gate exactly the face its name states (#15542/#15854), maskObjectFields is the ADR-0106 D8 disclosure gate, and both tombstones refuse at construction", "since": "v17", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "api", "personas": [ "admin, authenticated — the live-boot half", - "a restricted member who cannot read every field of showcase_account — the masking clause is only observable as a non-admin", + "a restricted member who cannot read every field of showcase_project — a member bound to the client_liaison position (showcase_client_liaison set: budget, budget_remaining and spent unreadable) — the masking clause is only observable as a non-admin", "no persona for the variant half: a non-default `metadata` block is authorable only by a programmatic embedder (knownGaps)" ], "fixtures": { @@ -2375,7 +2448,7 @@ "harness: construct with { metadata: { endpoints: { items: false } } } and enumerate what leaves: GET {prefix}/:type and NOTHING else — exactly the one mount its describe() names (#15542 moved the whole-store family off this switch; a run that still sees /diagnostics, /_drafts or POST /_migrate-stored leave with it is the old radius returning)", "harness: construct with { metadata: { endpoints: { maintenance: false } } } and enumerate: GET {prefix}/diagnostics, GET {prefix}/_drafts and the POST {prefix}/_migrate-stored write door — three whole-store operations, the key #15542 created so that closing a listing read stops disarming a migration door", "harness: construct with { metadata: { endpoints: { item: false } } } and enumerate: the WHOLE per-item face — GET, PUT and DELETE {prefix}/:type/:name, /:type/:name/references, /:type/:name/layers, the history family (/history, /audit, /diff, /published, /publish, /rollback) and {prefix}/book/:name/tree; twelve mounts, writes included (#15854)", - "harness: construct with { metadata: { enableCache: false } } and confirm the item read takes the uncached branch; then with enableCache true against an app-type, a dashboard-type, a draft read, a preview-drafts read, a package-scoped read and an audience-gated type — each is a carve-out that bypasses the cached path even when the switch is on", + "harness: construct with { metadata: { enableCache: false } } and confirm the item read takes the uncached branch; then with enableCache true against an app-type, a dashboard-type, a draft read, a preview-drafts read, a package-scoped read, a member-addressed read (?member=, #22877) and an audience-gated type — each is a carve-out that bypasses the cached path even when the switch is on", "harness: construct with { metadata: { maskObjectFields: false } }; separately construct with the default and metaType != 'object'", "harness: construct with { metadata: { cacheTtl: 3600 } }, then 0, then -1, then { metadata: { endpoints: { schema: false } } }; capture each refusal text" ], @@ -2405,9 +2478,9 @@ "evidence": "the two served documents + the normalized value" }, { - "clause": "`enableCache` selects the protocol's cached item read, and the cached path is additionally conditional: app types, dashboard types, draft reads, preview-drafts reads, package-scoped reads and audience-gated types each bypass it with the switch still on — so a cache-miss is not evidence the switch is dead", + "clause": "`enableCache` selects the protocol's cached item read, and the cached path is additionally conditional: app types, dashboard types, draft reads, preview-drafts reads, package-scoped reads, member-addressed reads (?member=) and audience-gated types each bypass it with the switch still on — so a cache-miss is not evidence the switch is dead", "oracle": "test", - "verify": "with enableCache false the item read calls the uncached protocol member; with it true, the six carve-outs still call the uncached member. Score each carve-out separately and name it in the record", + "verify": "with enableCache false the item read calls the uncached protocol member; with it true, the seven carve-outs still call the uncached member. Score each carve-out separately and name it in the record", "evidence": "per-branch call trace" }, { @@ -2474,6 +2547,12 @@ "date": "2026-10-16", "change": "the masking step named showcase_account, but no stock persona has field-level security there (#23878 checklist-accuracy finding), so the diff was empty for everyone and the clause could only pass vacuously or block. Re-measured on main c91af8ed6: the served /meta/object/showcase_project for a client_liaison member lacks exactly budget, budget_remaining and spent against the admin's copy, while showcase_account is identical for the liaison and a contributor. The fixture line, step 3 and clause 4's verify now name showcase_project and the client_liaison persona", "ref": "#24036" + }, + { + "revision": 5, + "date": "2026-10-19", + "change": "steps[8], acceptance[4] (clause, verify) and personas[1] corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24541, api-backend.rest-metadata-config-contract acceptance[4] / step 9 and personas[1]; wave anchor #24579 §3). The enableCache carve-outs are SEVEN: a ?member= read also bypasses getMetaItemCached (packages/rest/src/meta-item-read-routes.ts, memberScoped, #22877, at base 9a7838edf5); and personas[1] still named showcase_account, which revision 4 moved to showcase_project / client_liaison everywhere else in the item", + "ref": "#24649" } ] }, @@ -2579,7 +2658,7 @@ "title": "Every read that reaches a NON-addressed object asks that object's own `enable` block (nested-relation filter → 404/405, $expand withheld to the bare id, search skips it), and an `internal: true` field is refused 400 INVALID_FIELD in every evaluate position (filter, sort, groupBy, aggregate, $expand where/orderBy)", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -2595,8 +2674,8 @@ ] }, "steps": [ - "GET /api/v1/data/S?filter=[[\"t_ref.name\",\"=\",\"x\"]] (nested-relation filter into T)", - "GET /api/v1/data/S/ID?$expand=t_ref", + "GET /api/v1/data/S?filter={\"t_ref\":{\"name\":\"x\"}} (a nested-relation condition into T — the NESTED spelling is the one the exposure gate judges; the dotted filter=[[\"t_ref.name\",\"=\",\"x\"]] is refused first, 400 INVALID_FIELD as an unsupported dotted path prescribing the nested form)", + "GET /api/v1/data/S/ID?expand=t_ref (the by-id door spells it `expand`: its closed set is select, expand — `$expand` answers 400 VALIDATION_ERROR there, while the list door takes `$expand`)", "GET /api/v1/search?q=TERM and confirm T never appears", "GET /api/v1/data/S?filter=[[\"internal_f\",\"=\",\"x\"]], ?sort=internal_f, POST /api/v1/data/S/query with groupBy / aggregate on internal_f", "repeat as admin AND member — the answers must be identical (exposure is not a permission)" @@ -2609,7 +2688,7 @@ "evidence": "the vitest summary line per pin file, with the package name and the subject sha" }, { - "clause": "a nested-relation filter into an apiEnabled:false target → 404 OBJECT_API_DISABLED naming where.t_ref (405 OBJECT_API_METHOD_NOT_ALLOWED for a whitelist without the verb), for admin and member alike", + "clause": "a nested-relation filter into an apiEnabled:false target → 404 OBJECT_API_DISABLED located at filter.t_ref (both the GET and the POST door name the position `filter`) (405 OBJECT_API_METHOD_NOT_ALLOWED for a whitelist without the verb), for admin and member alike", "oracle": "api", "verify": "status + code + located path, both personas", "evidence": "the four lines" @@ -2650,6 +2729,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "steps[0], steps[1] and acceptance[1].clause corrected — checklist-accuracy findings of the 18.0 QA sweep (QA runs #24541 and #24558, api-backend.second-object-exposure-and-internal-fields; wave anchor #24579 §3). The dotted filter path is refused INVALID_FIELD before the exposure gate, the by-id door takes `expand` not `$expand`, and the refusal locates the condition at filter.t_ref. Re-derived at base 9a7838edf5: protocol.ts refuseInternalInFilter passes the position 'filter' for where and filter alike and refuseUnservedRelationTarget names `${param}.${field}`; DATA_RECORD_READ_PARAMS = ['select', 'expand'] (packages/rest/src/rest-server.ts)", + "ref": "#24649" } ] }, @@ -2658,7 +2743,7 @@ "title": "Request-shape gates on v18: a /data write with a null / array / scalar body or an empty version token is refused 400 (never an unguarded 200), a repeated single-valued query name is refused 400 on the dispatcher doors (a doubled keepData no longer drops tables), and a stale If-Match on an unreadable row answers 404", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -2677,7 +2762,7 @@ "PATCH with {\"expectedVersion\": \"\"} and with an empty If-Match header", "POST /api/v1/data/showcase_task/query with body `null`", "GET /api/v1/packages?status=a&status=b; DELETE /api/v1/packages/com.example.showcase?keepData=true&keepData=true (expect refusal BEFORE any work — run on a throwaway boot only)", - "as the member PATCH with a stale If-Match a row the member cannot read" + "as the member PATCH with a stale If-Match a row the member cannot read — a showcase_private_note the admin owns (owner-private OWD). Not a showcase_task row: the object is public_read_write, so every member reads every task" ], "acceptance": [ { @@ -2734,6 +2819,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "steps[4] names a row the member really cannot read — checklist-accuracy finding of the 18.0 QA sweep (QA run #24558, api-backend.data-request-shape-and-query-arity step 5; wave anchor #24579 §3): showcase_task is public_read_write. Re-derived at base 9a7838edf5 (examples/app-showcase/src/data/objects: task.object.ts sharingModel public_read_write, private-note.object.ts private). No clause changed", + "ref": "#24649" } ] }, @@ -2742,7 +2833,7 @@ "title": "/meta read doors on v18: GET /meta/app/PACKAGE_ID returns that package's app, `?member=` selects one bundle member on by-name doors, an unknown query parameter on any by-name door is refused 400, `?package=all` folds to the no-parameter answer, and the object definition marks an FLS-denied field unsortable for that caller", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -2753,7 +2844,7 @@ "app": "showcase", "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", - "a showcase email template with two locale members (or one authored through PUT /api/v1/meta/email_template/NAME?member=zh-CN)" + "a showcase email template with two locale members: the showcase ships only the en-US member of showcase_task_done_email (examples/app-showcase/src/system/emails), so author the zh-CN member with PUT /api/v1/meta/email_template/showcase_task_done_email carrying `locale: 'zh-CN'` in the BODY — the save door's closed set is force, package, mode and surface, so ?member= is a READ parameter only" ] }, "steps": [ @@ -2761,7 +2852,7 @@ "GET /api/v1/meta/email_template/NAME?member=zh-CN; GET /api/v1/meta/object/showcase_task?member=x", "GET /api/v1/meta/object/showcase_task?memeber=x (typo)", "GET /api/v1/meta/view?package=all vs GET /api/v1/meta/view", - "as a member whose FLS hides a field F: GET /api/v1/meta/object/showcase_task and read sortability for F; GET /api/v1/data/showcase_task?sort=F" + "as a member whose FLS hides a field F: the stock showcase declares field-level security on showcase_project only (showcase_client_liaison hides budget, budget_remaining and spent), and no demo persona holds the client_liaison position, so mint the member (POST /api/v1/auth/admin/create-user, then the password change) and bind it with a sys_user_position row for client_liaison; GET /api/v1/meta/object/showcase_project and read sortability for budget; GET /api/v1/data/showcase_project?sort=budget" ], "acceptance": [ { @@ -2789,9 +2880,9 @@ "evidence": "the two id lists" }, { - "clause": "for the FLS-restricted caller F is marked unsortable and the data sort on F is 403 — the two agree", + "clause": "for the FLS-restricted caller F is ABSENT from sortability.fields (never offered — it is not listed with sortable: false) and the data sort on F is 403 — the two agree", "oracle": "api", - "verify": "sortability entry + data answer", + "verify": "sortability.fields without F (packages/rest/src/meta-object-sortability-caller-fls.test.ts: 'sortability never offers the unreadable field') + the data answer", "evidence": "both excerpts" } ], @@ -2815,6 +2906,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "fixtures.requires[1], steps[4] and acceptance[4] (clause, verify) corrected — checklist-accuracy findings of the 18.0 QA sweep (QA runs #24541 and #24558, api-backend.meta-read-doors-v18 step 5 / acceptance[4] and fixtures; wave anchor #24579 §3). The FLS step named showcase_task, which declares no FLS; the restricted field is showcase_project.budget for a client_liaison-bound member. The pin's contract is that the unreadable field is absent from sortability, not marked unsortable. Only the en-US email member ships, and PUT takes no ?member= (META_ITEM_SAVE_PARAMS: force, package, mode, surface — packages/rest/src/meta-item-read-gate.ts at base 9a7838edf5), so the zh-CN member is authored with a body locale", + "ref": "#24649" } ] }, @@ -2823,7 +2920,7 @@ "title": "Error answers made honest on v18: a hook refusal on a hand-built write route answers 400 with the hook's own sentence, a crash answers a generic 500 that withholds the caught message, and a unique-constraint refusal is 409 UNIQUE_VIOLATION with details at the action door, MCP and sandboxed bodies too", "since": "v18", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -2834,13 +2931,13 @@ "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", "a SCRATCH copy of examples/app-showcase (or a scaffolded blank app) for every planted-metadata step — never edit the shared fixtures; a file DB under /tmp/RUN/ for every boot", - "in the scratch app: a beforeInsert hook on sys_sharing_rule (or another hand-built write route's object) that throws a business sentence, and an object with a unique field plus a script action inserting a duplicate" + "in the scratch app: a beforeInsert hook on one of the scratch app's OWN objects that throws a business sentence, and an object with a unique field plus a script action inserting a duplicate; every scratch object declares a sharingModel (the author-time rule security-owd-unset refuses compile otherwise). ⛔ Not a hook on sys_sharing_rule: defineStack refuses a package hook on an object the stack does not define ('Hook … references object 'sys_sharing_rule' which is not defined in objects'), so the hand-built nested-envelope write rows are reached through their pins only" ] }, "steps": [ - "POST the hand-built write route whose hook refuses; read the body", + "POST a /data write route on the object whose hook refuses (POST /api/v1/data/OBJECT, or its /batch or /createMany door); read the body", "invoke the script action that inserts a duplicate value: POST /api/v1/actions/OBJECT/ACTION", - "force a server fault on a storage route (e.g. a malformed adapter config on the scratch boot) and read error.message" + "force a server fault on a storage route (e.g. a malformed adapter config on the scratch boot — QA run #24558 set storage.local_root under /dev/null through PUT /api/settings/storage) and read error.message" ], "acceptance": [ { @@ -2850,9 +2947,9 @@ "evidence": "the vitest summary line per pin file, with the package name and the subject sha" }, { - "clause": "the hook refusal answers 400 VALIDATION_ERROR carrying the hook's sentence — not 500", + "clause": "the hook refusal answers 400 carrying the hook's own sentence — not 500. On a /data door (the live leg) the envelope carries no `code` when the hook declared none, by pinned design; the hand-built nested-envelope rows (sharing rules, suggested bindings, approvals, package publish) re-dress the same refusal as 400 VALIDATION_ERROR — pin-only, since no package-authorable hook reaches their objects", "oracle": "api", - "verify": "status + message", + "verify": "status + message on the live /data door, and the absence of error.code when the hook declared none (rest-hook-script-fault-envelope.test.ts 'the refusal envelope carries no `code` when the hook declared none'); the VALIDATION_ERROR rows from rest-write-route-hook-refusal-sentence.ledger.test.ts (REFUSED_400) — both in automated.ref", "evidence": "the body" }, { @@ -2891,6 +2988,12 @@ "date": "2026-10-19", "change": "acceptance[2] asserts the composite details shape (QA run #24541 acceptance[2] fail, disposition assertion-defect; parked on #24579 §1). #24541: \"details carry field only when the driver names a single column; a tenancy-scoped composite unique names none\" — the pin packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts asserts the details keys are exactly developerMessage and object for an engine write, adding field only when DuplicateRecordError states a column, and driver-sql builds a field's unique: true as the (organization, field) composite. The clause asked for details naming the field, which no stock unique field produces. acceptance[1] is unchanged: QA run #24558's fail there is the product defect #24561; re-derived from main 5a871e4e41, where every source cited here is byte-identical to the run subject 5b14b7f3", "ref": "#24622" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "fixtures.requires[2], steps[0], steps[2] and acceptance[1] (clause, verify) corrected — checklist-accuracy findings of the 18.0 QA sweep (QA runs #24541 and #24558, api-backend.error-sentences-and-unique-violation-wire-code fixtures / step 1 / acceptance[1] / step 3; wave anchor #24579 §3). A package hook on sys_sharing_rule is refused at compile (packages/spec/src/stack.zod.ts hook → object reference check, at base 9a7838edf5); the /data door answers a hook refusal 400 with no code by pinned design, and VALIDATION_ERROR is the hand-built rows' re-dress (REFUSED_400 in the ledger pin). The live leg moves to a /data door. acceptance[1]'s product defect #24561 closed with PR #24631 (4ff18d3f0b, a batch row answers the hook's words), which this re-derivation includes; acceptance[2] (round 1's re-point) is unchanged", + "ref": "#24649" } ] } diff --git a/docs/qa/platform-checklist/areas/approvals.json b/docs/qa/platform-checklist/areas/approvals.json index 26c2ad037a2..9ffb93a422f 100644 --- a/docs/qa/platform-checklist/areas/approvals.json +++ b/docs/qa/platform-checklist/areas/approvals.json @@ -1145,69 +1145,65 @@ "title": "The anonymous /approvals/act door: a minted one-tap token renders a confirm page and one POST decides as exactly the bound approver — every dead-token shape is a designed refusal page with no state change", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", - "personas": ["dev admin (submitter of the seeded invoice request — remind is submitter-only, and the remind fan-out is what mints the tokens)", "the ANONYMOUS bearer of an action link: a clean HTTP client holding NO session cookie and NO bearer token"], + "personas": ["dev admin (submitter of the seeded invoice request — remind is submitter-only). Since PR #24620 the remind fan-out mints no token, so the admin drives only the live legs: the reminder at-rest read and the server reads beside the anonymous probes", "the ANONYMOUS bearer of an action link: a clean HTTP client holding NO session cookie and NO bearer token"], "fixtures": { "app": "showcase", "requires": [ - "a pending request the admin SUBMITTED (the seeded invoice dual sign-off request — seed-approval-demo.ts submits it as the admin) so POST /:id/remind is permitted; the reminder fan-out mints per-approver approve/reject tokens (ADR-0043) for every CONCRETE pending approver", - "token capture WITHOUT a mail harness: messaging's emit() persists the notification payload verbatim on the sys_notification event row (writeEvent, packages/services/service-messaging/src/messaging-service.ts; NOTIFICATION_EVENT_OBJECT = 'sys_notification') — so after a remind, GET /api/v1/data/sys_notification filtered to topic approval.reminder + source_id= as the admin and read the two /api/v1/approvals/act?token=... URLs out of payload.actions. The raw tokens exist ONLY there and in the outbound mail: sys_approval_token stores SHA-256 hashes (a DB leak yields no usable links)" + "a pending request the admin SUBMITTED (the seeded invoice dual sign-off request — seed-approval-demo.ts submits it as the admin) so POST /:id/remind is permitted — for the at-rest leg only. Since PR #24620 (65a8eeb7d4, the fix of #24564) the reminder fan-out mints NO action token and attaches no link: each concrete pending approver gets an approval.reminder notification carrying its title, its body and the /system/approvals deep link, and nothing else", + "the pinned tests in automated.ref, run with the plugin's own vitest (`pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 FILE`, never through a bare `--`). They mint with issueActionTokens in-process and hold the raw token in memory, which is the only place a live token exists now: sys_approval_token stores SHA-256 hashes, and since PR #24620 no persisted row (a notification payload, an audit row, a log line) holds a raw token or a URL built from one — issueActionTokens' caller contract (approval-service.ts)" ], "knownGaps": [ - "the EMAIL-RENDERING leg (the Approve/Reject links arriving as buttons in an actual message) is not covered by the sys_notification capture — it needs the dev log mail transport the forgot-password item uses (EMAIL_TRANSPORT_PROVIDERS 'log' → LogTransport, see identity-auth.self-service-password-reset fixtures); without it that one leg is blocked(fixture) while every token/door clause still runs off the persisted payload", + "NO STOCK DOOR MINTS AN ACTION TOKEN since PR #24620 (65a8eeb7d4, 2026-10-19). remind() no longer calls issueActionTokens or attaches a link (no channel ever read payload.actions, so no recipient received one), issueActionTokens has no production caller, and no REST route reaches it. So every leg that needs a live raw token — the per-approver mint, the confirm page, the redemption, the replay, the not_pending / not_approver pages, the expiry and the bearer probe — is blocked(fixture) on a live boot and is scored from the pins in automated.ref. QA run #24542's capture (the raw links read out of sys_notification.payload.actions after a remind) no longer exists: the absence of a link there is now this item's at-rest leg, not a fixture failure. ⛔ Never plant a token row or patch remind() to get a live link", + "the EMAIL-RENDERING leg (the Approve/Reject links arriving as buttons in an actual message) has NO stock producer at all: a reminder names no channel, so messaging materializes it in the in-app inbox only, no approval.reminder email template exists, and since PR #24620 the payload carries no link — the dev log mail transport the forgot-password item uses (EMAIL_TRANSPORT_PROVIDERS 'log' → LogTransport, identity-auth.self-service-password-reset fixtures) would capture nothing to render. Record the leg blocked(fixture), never a defect", "the EXPIRED leg has no live door on this build: ACTION_TOKEN_TTL_MS is 72h (approval-service.ts), issueActionTokens' ttlMs override is not REST-reachable, the service clock is a constructor option the plugin never threads (approvals-plugin.ts constructs ApprovalService without one), and since PR #22633 (#22616) sys_approval_token declares apiEnabled: false + apiMethods: [], so every data-API verb answers 404 OBJECT_API_DISABLED to every caller, the administrator included — the old recipe (backdate expires_at over the data API) is gone, and update was never in the object's whitelist even before. Score the expired shape from the pinned unit test instead: approval-service.test.ts 'redeem: dead tokens — invalid, expired, decided request, reassigned slot' mints with ttlMs: 1 under the fake clock and pins reason 'expired' (RESULT_COPY.expired in action-link-pages.ts is the page that reason renders); a live run records this one leg as blocked(fixture), never as a defect" ] }, "steps": [ "boot showcase isolated (dogfood §0); sign in as the dev admin", - "mint: POST /api/v1/approvals/requests/:id/remind on the admin-submitted invoice request — the response's notified count equals the number of concrete pending approvers, and GET /:id/actions gains one remind row. ⛔ Do NOT read sys_approval_token through the data API: since PR #22633 the object is apiEnabled: false with apiMethods: [], so GET /api/v1/data/sys_approval_token answers 404 OBJECT_API_DISABLED for every caller (the administrator included) — that 404 is the designed answer, not a finding. The per-approver mint (one approve + one reject row per concrete pending approver, SHA-256 at rest, FORBIDDEN for a non-pending approver) is pinned by the two integration tests named in source; live, step 3's payload.actions is the per-approver oracle", - "capture the raw links: GET /api/v1/data/sys_notification (topic approval.reminder, source_id = the request id) and extract the two /api/v1/approvals/act?token=... URLs from payload.actions", - "switch to a CLEAN anonymous client (no cookies, no Authorization header — prove it: GET /api/v1/approvals/requests from the same client answers 401); GET the approve URL — 200 HTML confirm page naming the action, the acting approver id and the request summary, with a POST form; GET it again — the confirm page renders a second time, not the 'Already used' page (peek validates without consuming), then re-read the request and GET /:id/actions as admin: nothing changed (mail-gateway prefetch safety — the GET never mutates). The token row is served through no door, so the second confirm page IS the 'still live' oracle", - "POST the form body (token=) anonymously — 200 result page 'Approved · 已通过'; as admin re-read the request (status moved / group tally advanced) and GET /:id/actions — the approve row's actor is the BOUND approver_id, comment 'Via action link'", - "replay: POST the same token again — 'Already used · 链接已使用' page; no new action row, no state movement", - "dead-slot leg: on a second pending request mint tokens, decide it through the normal UI/API first, then GET the leftover token URL — 'Already decided · 请求已处理' page; and on a third, reassign the BOUND approver's own slot away first — POST /:id/reassign {\"from\": \"\", \"to\": \"\"} as the admin, or the bound approver's own reassign with their token — then GET — 'No longer your approval · 已不在你名下' (the link died with the slot, ADR-0043 invalidation). ⚠️ An admin reassign WITHOUT `from` moves the CALLER's own slot (from defaults to the caller's taken slot, approval-service.ts reassign), so when the admin also holds a slot the bound approver's token stays live and the leg scores nothing", - "garbage: GET and POST with token=garbage — 'Invalid link · 链接无效' page, 200, no summary rows", - "expiry: no live door on this build (see knownGaps) — cite the pinned unit test's 'expired' reason and the RESULT_COPY.expired page copy ('Link expired · 链接已过期'), and record the leg as blocked(fixture)", - "scope probe: replay the raw token as a credential everywhere else — Authorization: Bearer on GET /api/v1/approvals/requests and on GET /api/v1/data/showcase_invoice — every such call is unauthenticated (401); the token opens nothing but the act door" + "at-rest leg: POST /api/v1/approvals/requests/:id/remind on the admin-submitted invoice request — the answer's notified is positive and GET /:id/actions gains one remind row (notified counts the pending SLOTS fanned out to: a slot listed twice counts twice while messaging dedups the second emit, so read it as a positive count, never as distinct people — QA run #24542, PR #24620's acceptance notes); then GET /api/v1/data/sys_notification (topic approval.reminder, source_id = the request id) as the admin and confirm each reminder payload carries NO actions list and no /api/v1/approvals/act URL — only its title, its body and the /system/approvals deep link. ⛔ Do NOT read sys_approval_token through the data API: since PR #22633 the object is apiEnabled: false with apiMethods: [], so GET /api/v1/data/sys_approval_token answers 404 OBJECT_API_DISABLED for every caller (the administrator included) — that 404 is the designed answer, not a finding", + "the live-token legs — the per-approver mint (one approve + one reject row per concrete pending approver, SHA-256 at rest, FORBIDDEN for a non-pending approver), the confirm page that never mutates, the redemption as the bound approver (comment 'Via action link'), the replay, the dead-slot and expired shapes and the bearer probe — need a raw token no stock door mints (knownGaps[0]): run the pins in automated.ref and cite each file's summary line, per clause as each verify names them", + "switch to a CLEAN anonymous client (no cookies, no Authorization header — prove it: GET /api/v1/approvals/requests from the same client answers 401); GET and then POST (form body token=garbage) /api/v1/approvals/act?token=garbage — both answer the 'Invalid link · 链接无效' page, 200 text/html, with no request summary rows, and the admin's before/after reads of the invoice request show no change. It is the one dead-token shape a live boot can still produce without a minted token", + "expiry: no live door on this build (see knownGaps) — cite the pinned unit test's 'expired' reason and the RESULT_COPY.expired page copy ('Link expired · 链接已过期'), and record the leg as blocked(fixture)" ], "acceptance": [ { - "clause": "minting is approver-bound and hashed at rest: remind fans out one approve + one reject token per CONCRETE pending approver (literal type:value slots get a plain nudge, no token), stored as SHA-256 hashes with approver_id and expires_at — and issueActionTokens refuses a non-pending approver with FORBIDDEN", + "clause": "minting is approver-bound and hashed at rest, and NOTHING PERSISTED holds a usable link: issueActionTokens mints one approve + one reject token for a CONCRETE pending approver, stored as SHA-256 hashes with approver_id and expires_at, and refuses a non-pending approver with FORBIDDEN; remind() mints none and attaches no link to the payload messaging stores (PR #24620)", "oracle": "test", - "verify": "run the two pinned integration tests and cite their output: approval-token-internal-hash.integration.test.ts (the digest is internal — the generic read omits it, storage holds the SHA-256, peek and redeem resolve by it) and sys-approval-token-generic-door.integration.test.ts (apiEnabled: false, apiMethods: [], every operation refused for every caller with the api-disabled discriminant, the cross-object search returns no token row, and the Approve link remind() delivered still redeems once as the bound approver and is refused on replay). Live, the sys_notification payload carries one approve + one reject /api/v1/approvals/act?token= URL per concrete pending approver and none for a literal slot — the token rows themselves are served through no door since PR #22633", - "evidence": "pinned test output + notification-payload read" + "verify": "run the pins and cite their output: approval-service.test.ts 'issueActionTokens: stores hashes only and binds approver + action' (the FORBIDDEN arm included) and 'remind: each concrete approver gets their own reminder, and none carries an action link (#24564)'; approval-token-internal-hash.integration.test.ts (the digest is internal — the generic read omits it, storage holds the SHA-256, peek and redeem resolve by it); sys-approval-token-generic-door.integration.test.ts (apiEnabled: false, apiMethods: [], every operation refused for every caller with the api-disabled discriminant, the cross-object search returns no token row); reminder-action-link-at-rest.integration.test.ts (no emit input and no stored row holds an action-page URL or the preimage of a live token). Live, step 2's sys_notification read shows the reminder with no link — the token rows themselves are served through no door since PR #22633", + "evidence": "pinned test output + the at-rest notification read" }, { "clause": "the GET confirm page renders session-less and MUTATES NOTHING — repeated GETs leave the request, its actions timeline and the token's consumed_at byte-identical (a mail gateway's link prefetch must never approve a request)", - "oracle": "api", - "verify": "before/after reads of GET /:id and GET /:id/actions around two anonymous GETs of the confirm URL — no delta; both GETs came back 200 text/html with the POST form (peekActionToken validates without consuming), and the second one rendering the confirm page rather than the 'Already used' page is the consumed_at oracle now that the token row is served through no door", - "evidence": "the two confirm-page responses + before/after reads" + "oracle": "test", + "verify": "pin-scored (no stock door mints a live token, knownGaps[0]): action-page-member.integration.test.ts 'GET renders the confirm page for a live token, and never decides however often it is fetched' (200 text/html with the POST form, five more GETs, the request still pending with no decision, then the first POST decides) and approval-service.test.ts 'peek: validates without consuming (GET never mutates)'", + "evidence": "the vitest summary lines of the two pins" }, { "clause": "one anonymous POST decides as exactly the bound approver on exactly the bound request: the decision is attributed like a UI decision — the approve action row's actor is the token's approver_id (never null, never the submitter), comment 'Via action link', and the request/tally/flow-run all move exactly as a session decision would", - "oracle": "api", - "verify": "post-redemption reads: request status/tally moved; /:id/actions carries one approve row with actor = the bound approver_id ('the token IS the authentication' #3783 — the decision context carries that userId, so the status mirror and every cascade attribute to them)", - "evidence": "the redemption response + request/actions re-reads" + "oracle": "test", + "verify": "pin-scored (knownGaps[0]): approval-service.test.ts 'redeem: approves as the bound approver and burns the token (single-use)' (actor_id the bound approver, comment 'Via action link', the run resumed on the approve branch); action-page-member.integration.test.ts 'POST redeems as the bound approver — the audit row names that approver, not a system actor' (actor_id and acted_as the approver, for approve and reject); reminder-action-link-at-rest.integration.test.ts's positive control (actor_id and acted_as the bound approver, via_override false) — 'the token IS the authentication' (#3783)", + "evidence": "the vitest summary lines of the three pins" }, { "clause": "redemption is single-use and consume-FIRST: the second POST answers the 'Already used' page with no second action row — and because the token is burned before the decide, a decide that fails still burns it (replay-safe by design; a burned-but-undecided token is the designed outcome, not a defect to file)", - "oracle": "api", - "verify": "second POST → the 'Already used' page (the act door's own reading of consumed_at — the row is served through no door); GET /:id/actions count unchanged", - "evidence": "the replayed response + actions count" + "oracle": "test", + "verify": "pin-scored (knownGaps[0]): the replay halves of approval-service.test.ts 'redeem: approves as the bound approver and burns the token (single-use)' (reason consumed), sys-approval-token-generic-door.integration.test.ts 'a minted token peeks, redeems exactly once, and is refused as consumed on replay' and reminder-action-link-at-rest.integration.test.ts's positive control (refuses a replay); action-page-member.integration.test.ts renders the consumed shape as the 'Already used' page", + "evidence": "the vitest summary lines of the pins" }, { "clause": "every dead-token shape answers its DESIGNED 200 refusal page with zero state change: invalid / expired / consumed render WITHOUT request summary rows; not_pending / already-decided and not_approver / reassigned-away render theirs WITH the summary (the bearer legitimately held that link) — and none of the five moves any state", "oracle": "network", - "verify": "one capture per RESULT_COPY shape reachable live (invalid, consumed, not_pending, not_approver) — HTTP 200 text/html, the matching bilingual title, summary-row presence per the rule above; paired before/after request reads show no delta; the expired shape is scored from the pinned unit test (knownGaps). The 200-on-refusal is by design (a human-facing page reached from a mail client) — a run must not file the missing 4xx as a finding", - "evidence": "the four live refusal captures + paired reads + the pinned expired reason" + "verify": "live: the invalid shape only — step 4's token=garbage GET and POST, each HTTP 200 text/html with the 'Invalid link · 链接无效' title and no summary rows, with paired before/after request reads showing no delta. Pinned: action-page-member.integration.test.ts 'a dead link — unknown, expired, consumed — is told on the page with 200, through GET and POST alike' (the invalid, expired and consumed pages byte-equal to renderResultPage with no request) and approval-service.test.ts 'redeem: dead tokens — invalid, expired, decided request, reassigned slot' (the not_pending and not_approver reasons). The not_pending / not_approver PAGES and their summary rows have neither a live door (they need a live token, knownGaps[0]) nor a page-level pin: record that half blocked(fixture). The 200-on-refusal is by design (a human-facing page reached from a mail client) — a run must not file the missing 4xx as a finding", + "evidence": "the live invalid captures + paired reads + the pin summary lines" }, { "clause": "the token grants nothing else: presented as a bearer credential on any authenticated route it is a stranger — the platform's auth stack never resolves it", "oracle": "api", - "verify": "Authorization: Bearer on GET /api/v1/approvals/requests and one data route answers 401 exactly like an anonymous call; no session is minted anywhere", - "evidence": "the 401 responses" + "verify": "blocked(fixture) on this build: the probe needs a raw token, which no stock door mints (knownGaps[0]), and no pin presents one as a bearer on an authenticated route. A garbage string as Bearer proves nothing about an action token. The converse is pinned: action-page-member.integration.test.ts 'the token is the only credential: a cookie or Authorization header is never read, and changes nothing'", + "evidence": "the blocked(fixture) note + the converse pin's summary line" } ], "negative": [ @@ -1217,21 +1213,27 @@ "a raw token readable out of sys_approval_token storage (the engine's system read in the internal-hash test: a raw-token column, or a hash that is really the token) is a FAIL of the hashed-at-rest claim — and a run that reads the data API's 404 OBJECT_API_DISABLED on sys_approval_token as a defect is a FAIL of the method: the door is closed by design (PR #22633)" ], "traps": ["dispatcher-vs-hono-route", "auth-state-leak"], + "automated": { + "kind": "unit", + "ref": "packages/plugins/plugin-approvals/src/approval-service.test.ts (acceptance[0] mint + FORBIDDEN and the remind-carries-no-link pin; acceptance[1] peek; acceptance[2]–[3] redeem + replay; acceptance[4] the dead-token reasons) + packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts (acceptance[1] the confirm page; acceptance[2] the redemption; acceptance[4] the invalid / expired / consumed pages; acceptance[5]'s converse) + packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts (acceptance[0] the closed door; acceptance[3] the replay) + packages/plugins/plugin-approvals/src/reminder-action-link-at-rest.integration.test.ts (acceptance[0] nothing redeemable at rest; acceptance[2]–[3] the positive control) + packages/plugins/plugin-approvals/src/approval-token-internal-hash.integration.test.ts (acceptance[0] the internal digest). The at-rest read and the token=garbage page still need the live run; acceptance[5]'s probe has no pin" + }, "source": [ "packages/plugins/plugin-approvals/src/approvals-plugin.ts#http (the raw Hono mount at /api/v1/approvals/act — GET renders via peekActionToken, POST redeems; mounted on kernel:ready off the canonical http.server handle, #4251 B5)", "packages/plugins/plugin-approvals/src/approval-service.ts#actionLinkUrl (actionLinkUrl · issueActionTokens: hashed-at-rest + FORBIDDEN for non-pending approvers · resolveActionToken: invalid/consumed/expired/not_pending/not_approver chain · peekActionToken 'validate WITHOUT consuming' · redeemActionToken 'consume the token FIRST … then decide as the bound approver', #3783 the-token-IS-the-authentication attribution); ACTION_TOKEN_TTL_MS = 72h", - "packages/plugins/plugin-approvals/src/approval-service.ts#approver (the remind fan-out is the mint path: per-approver Approve/Reject action links on topic approval.reminder; literal slots fall back to a tokenless nudge)", + "packages/plugins/plugin-approvals/src/approval-service.ts#remind (since PR #24620 the fan-out sends each concrete approver an approval.reminder notification with the /system/approvals deep link and NO action link, and mints no token — the ⛔ #24564 note at the fan-out; literal slots fall back to the shared nudge)", "packages/plugins/plugin-approvals/src/action-link-pages.ts#RESULT_COPY (ADR-0043 session-less bilingual pages; 'The GET page NEVER mutates'; RESULT_COPY — which refusal shapes carry summaryRows and which do not)", - "packages/services/service-messaging/src/messaging-service.ts#writeEvent + (writeEvent persists the payload on sys_notification — the stock-boot token-capture seam this item's fixtures rest on)", + "packages/services/service-messaging/src/messaging-service.ts#writeEvent + (writeEvent persists the payload verbatim on the sys_notification event row — why PR #24620 took the link off the reminder payload, and the row this item's at-rest leg reads)", "packages/plugins/plugin-approvals/src/sys-approval-token.object.ts (the token store — enable.apiEnabled: false + apiMethods: [] since PR #22633: every data-API verb 404 OBJECT_API_DISABLED for every caller, and the cross-object search skips it)", "packages/plugins/plugin-approvals/src/approval-token-internal-hash.integration.test.ts (#21197 — the digest is internal; storage holds it; peek + redeem resolve by it)", "packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts (#22616 — the declaration, every operation refused for every caller, the search pin, and the positive control: the link still redeems once)", + "packages/plugins/plugin-approvals/src/reminder-action-link-at-rest.integration.test.ts (#24564, PR #24620 — no emit input and no stored row holds an action-page URL or a live token's preimage; positive control: a token held outside storage still decides once, as its approver)", "packages/plugins/plugin-approvals/src/approval-service.test.ts ('redeem: dead tokens — invalid, expired, decided request, reassigned slot' — the expired reason under ttlMs: 1 and the fake clock)" ], "history": [ { "revision": 1, "date": "2026-08-30", "change": "initial — the anonymous actionable-link door (ADR-0043) had no checklist coverage despite being an unauthenticated decision path. Authored from source with one register correction: the hunter proposed an emailed-link capture harness as the fixture, but messaging persists the reminder payload (raw action URLs included) on the sys_notification event row, so every token/door clause is stock-runnable; only the email-rendering leg still wants the LogTransport capture (recorded in knownGaps)", "ref": "#sweep-2026-08-30" }, { "revision": 2, "date": "2026-10-10", "change": "re-pointed after PR #22633 (#22616) closed sys_approval_token to the automatic API (apiEnabled: false, apiMethods: [] — 404 OBJECT_API_DISABLED for every caller): the token-row oracles move to the act door's outcome pages, the request's action history (GET /:id/actions) and the two pinned integration tests (internal-hash, generic-door); the mint clause is scored by test; the expiry leg, whose only recipe was backdating expires_at over the data API, has no surviving door (ttlMs is not REST-reachable and the plugin threads no clock) and is recorded in knownGaps as blocked(fixture) with the pinned 'expired' reason as its oracle. Two steps had contradicted the door before that PR already: the digest has been withheld since #21197, and update was never in the whitelist", "ref": "#22631" }, - { "revision": 3, "date": "2026-10-16", "change": "step 7's dead-slot leg said 'reassign the bound approver away first' without saying who can (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 on a two-slot request [admin, Ada] minted by remind: the admin's reassign without `from` moved the admin's own slot ([OTHER, Ada]) and Ada's approve token still peeked as the 'Approve · 通过' confirm page; the admin's reassign with from: Ada moved Ada's slot and the same token then rendered 'No longer your approval · 已不在你名下'. The step now passes `from` (or has the bound approver reassign their own slot); the clause is unchanged", "ref": "#24036" } + { "revision": 3, "date": "2026-10-16", "change": "step 7's dead-slot leg said 'reassign the bound approver away first' without saying who can (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 on a two-slot request [admin, Ada] minted by remind: the admin's reassign without `from` moved the admin's own slot ([OTHER, Ada]) and Ada's approve token still peeked as the 'Approve · 通过' confirm page; the admin's reassign with from: Ada moved Ada's slot and the same token then rendered 'No longer your approval · 已不在你名下'. The step now passes `from` (or has the bound approver reassign their own slot); the clause is unchanged", "ref": "#24036" }, + { "revision": 4, "date": "2026-10-19", "change": "re-pointed to main after PR #24620 (65a8eeb7d4, the fix of #24564) took the one-tap link off the reminder payload — wave anchor #24579 §1, the row round 2 added (comment 6272314386), resting on QA runs #24542 and #24558. The live mint leg read raw tokens out of sys_notification.payload.actions after a remind; on main remind() mints no token and attaches no link, issueActionTokens has no production caller and no REST door reaches it, so no stock boot holds a live token. Every live-token leg moves to the pins, now named per clause in a new automated.ref, each measured at base 9a7838edf5 under os-verify-lock (plugin-approvals vitest: approval-service.test.ts 367, action-page-member.integration.test.ts 12, sys-approval-token-generic-door.integration.test.ts 6, reminder-action-link-at-rest.integration.test.ts 4, approval-token-internal-hash.integration.test.ts 1, all passed). The live run keeps the at-rest read (the reminder row carries no link), the token=garbage page and the anonymous-client proof. acceptance[1]–[3] move to oracle test; acceptance[4] keeps its live invalid capture and records the not_pending / not_approver pages as unpinned; acceptance[5]'s bearer probe has no live token and no pin and reads blocked(fixture). The ten steps become five: the mint, capture, confirm-page, redemption, replay, dead-slot and bearer-probe steps fold into one pin step (steps[2]) and the garbage step joins the anonymous-client step (steps[3]); #24542's finding that notified counts slots rides along in steps[1]; knownGaps[0] is new, the email-rendering gap now says no stock producer exists, and personas[0], fixtures.requires and source[2]/[4] follow, plus the at-rest pin in source", "ref": "#24649" } ] }, { @@ -1595,7 +1597,7 @@ "title": "Approvals on v18: the email action door /approvals/act works through the dispatcher exactly as the self-hosted mount (GET renders, POST redeems, a PUT neither door claims an ordinary 404 miss with no Allow header, a dead link a 200 page, a policy-held session not 403'd before the token), and an approval over an unstaffed position opens on the org administrators as ordinary approvers", "since": "v18", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -1606,12 +1608,12 @@ "app": "showcase", "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", - "an approval email action token (dev mail transport capture) for a pending request", + "a live action token for the GET-renders / POST-redeems legs — which no stock door mints since PR #24620 (65a8eeb7d4): a stock boot sends no approval email, and a reminder now carries no link (approvals.email-action-token-door knownGaps[0]), so those two legs are scored from the pins in automated.ref. The PUT and dead-link legs need no live token: any non-token string (token=garbage) drives them", "an approval process whose approver is a position nobody holds (scratch process, default onEmptyApprovers)" ] }, "steps": [ - "GET /api/v1/approvals/act?token=T; POST the same; PUT the same; GET with a consumed token", + "PUT /api/v1/approvals/act?token=garbage; GET and POST /api/v1/approvals/act?token=garbage (the dead-link page) — neither needs a live token. The live-token GET and POST legs are pin-scored: no stock door mints a token since PR #24620", "submit a record into the unstaffed process; as the org admin GET /api/v1/approvals/requests (My Pending); decide it" ], "acceptance": [ @@ -1624,8 +1626,8 @@ { "clause": "GET renders the action page, POST redeems, a dead token → a 200 page (not an error envelope), and PUT — a method neither wire door claims — is that door's ordinary miss: 404 with no Allow header, never reaching the member (ENDPOINT_NOT_FOUND on the self-hosted `os dev` boot, whose raw-app mount registers only GET and POST; ROUTE_NOT_FOUND on the hosted dispatcher door, whose `/approvals/act` domain claims only GET, HEAD and POST). 405 with Allow: GET, POST is the in-process ApprovalService.handleActionPage member's answer only, which no wire request reaches with a PUT", "oracle": "api", - "verify": "status + headers per verb; for the PUT also error.code and the absence of an Allow header. The member's 405 is not a wire leg: it is pinned in-process by packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts, and the dispatcher's 404 miss by packages/runtime/src/domains/approvals-act.test.ts (both in automated.ref)", - "evidence": "four lines (the PUT's with its error.code and its headers)" + "verify": "status + headers per verb; for the PUT also error.code and the absence of an Allow header. The PUT and the dead-link legs run live with token=garbage; the GET-renders and POST-redeems halves need a live token no stock door mints since PR #24620, so they are scored from packages/runtime/src/domains/approvals-act.test.ts (the dispatcher forwards GET and POST to the member, the form body unread) and packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts (the member renders and redeems). The member's 405 is not a wire leg: it is pinned in-process by action-page-member.integration.test.ts, and the dispatcher's 404 miss by approvals-act.test.ts (both in automated.ref)", + "evidence": "the live PUT line (its error.code and its headers) and the dead-link page, plus the two pins' summary lines for the live-token halves" }, { "clause": "the unstaffed request appears in each org admin's pending list and the decision is recorded via_override:false", @@ -1656,6 +1658,12 @@ "date": "2026-10-19", "change": "acceptance[1] (clause, verify, evidence) and the title's PUT leg re-worded — an assertion defect found by the 18.0 QA sweep (QA run #24542, approvals.act-door-and-admin-rescue-slate clause 1: FAIL; card #24543). The product is right and the clause was wrong: on a stock `os dev` showcase boot `PUT /api/v1/approvals/act?token=…` answered 404 ENDPOINT_NOT_FOUND with no Allow header (twice; DELETE and PATCH the same), which is the design — the self-hosted mount registers only GET and POST (changeset 22578), the dispatcher domain claims only GET, HEAD and POST and lets every other method fall through (packages/runtime/src/domains/approvals.ts), and the item's own pin packages/runtime/src/domains/approvals-act.test.ts asserts that miss (404 ROUTE_NOT_FOUND) with the member never run. 405 with Allow: GET, POST belongs to the in-process member ApprovalService.handleActionPage alone (packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts). The GET, POST and dead-token legs held and are unchanged, as are the other two clauses", "ref": "#24592" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "fixtures.requires[1], steps[0] and acceptance[1] (verify, evidence) follow PR #24620 (65a8eeb7d4) — wave anchor #24579 §1, round 2's out-of-scope note (report 6274548573) that #24620 refutes QA run #24542's reading (\"no mail harness needed — the raw links sit in sys_notification.payload.actions after a remind\"); QA run #24558 recorded that a stock boot sends no approval email. No stock door mints an action token now, so the GET-renders / POST-redeems legs are scored from the pins already in automated.ref, and the PUT and dead-link legs run live with a non-token string. Re-derived at base 9a7838edf5: issueActionTokens has no production caller. No clause changed", + "ref": "#24649" } ] } diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index bd0fed61a69..7ec137e50c9 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -78,7 +78,7 @@ "title": "Flow Runs render loop/region iterations as a nested execution tree", "since": "v16", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "mixed", "personas": [ @@ -109,9 +109,9 @@ "evidence": "run-detail API read" }, { - "clause": "each of the three guard_reminder steps carries {parentNodeId:'loop_tasks', regionKind:'loop-body', iteration 0|1|2}, each send_reminder carries {parentNodeId:'guard_reminder', regionKind:'try'} with the same iteration, no two share an iteration; start/loop_tasks/end carry no parentNodeId.", + "clause": "each of the three guard_reminder steps carries {parentNodeId:'loop_tasks', regionKind:'loop-body', iteration 0|1|2}, each send_reminder carries {parentNodeId:'guard_reminder', regionKind:'try'} with the same iteration, no two share an iteration; start/loop_tasks carry no parentNodeId — a TAKEN end node logs no step at all, so end has no row to tag, and its absence is not a finding.", "oracle": "api", - "verify": "assert the three guard_reminder (try_catch) steps each carry {parentNodeId:'loop_tasks', regionKind:'loop-body'} with iterations 0, 1 and 2, no two alike; each send_reminder carries {parentNodeId:'guard_reminder', regionKind:'try'} and the iteration of the guard it ran under (a try region has no index of its own, so the enclosing loop's iteration rides through — ExecutionStepLogSchema iteration / regionKind, the engine's innermost-identity rule #15230); top-level steps (start/loop_tasks/end) carry NO parentNodeId", + "verify": "assert the three guard_reminder (try_catch) steps each carry {parentNodeId:'loop_tasks', regionKind:'loop-body'} with iterations 0, 1 and 2, no two alike; each send_reminder carries {parentNodeId:'guard_reminder', regionKind:'try'} and the iteration of the guard it ran under (a try region has no index of its own, so the enclosing loop's iteration rides through — ExecutionStepLogSchema iteration / regionKind, the engine's innermost-identity rule #15230); top-level steps (start/loop_tasks) carry NO parentNodeId; the run logs no end step (executeNode returns on a taken end before any step is pushed — only an end on an UNTAKEN branch is logged, as a skipped step)", "evidence": "step-log excerpt with the tags" }, { @@ -183,6 +183,12 @@ "date": "2026-10-05", "change": "clause 2 and the batch-reminders fixture line re-pointed (stale, #21784 VF4). examples/app-showcase's BatchRemindersFlow wraps send_reminder in the try_catch guard_reminder (#13681 / #14394, present before 17.6.0). The engine's innermost-identity rule (service-automation engine, #15230) tags the body step with the try region, so send_reminder carries parentNodeId guard_reminder and regionKind 'try', and the guard is the loop-body step. The verifier's wording is used", "ref": "#21797" + }, + { + "revision": 6, + "date": "2026-10-19", + "change": "acceptance[1] (clause, verify) stops asking a tag of a step that is never logged — checklist-accuracy finding of the 18.0 QA sweep (QA run #24545, automation.flow-run-step-nesting acceptance[1]; wave anchor #24579 §3): both showcase_batch_reminders and showcase_resilient_sync runs end at the last real node, so the clause held vacuously for end. Re-derived at base 9a7838edf5 (packages/services/service-automation/src/engine.ts: a taken end pushes no step; recordSkipped logs an untaken edge's target as skipped)", + "ref": "#24649" } ] }, @@ -290,7 +296,7 @@ "title": "Filtered roll-up summaries recompute only through their own filter", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": [ @@ -310,7 +316,7 @@ "PATCH one line's status approved→rejected over /api/v1/data/showcase_expense_line/:id; re-read the parent and recompute again", "PATCH a different line's billable true→false; re-read the parent", "DELETE one line; re-read the parent — a delete must move every roll-up whose filter matched the deleted row, including the unfiltered total", - "on a writable base: POST /api/v1/packages to create a scratch base, author a parent object with a filtered summary field over a child object into it, then in Studio's visual filter editor edit that summary field's child-row filter, save, and re-read the persisted metadata over the meta API. ⛔ Not POST /packages/com.example.showcase/duplicate — it answers 422 DUPLICATE_SOURCE_NOT_A_BASE (#14451)" + "on a writable base: POST /api/v1/packages to create a scratch base, author a parent object with a filtered summary field over a child object into it — both NAMED with the base's namespace prefix: POST /api/v1/packages derives the namespace from the id's last segment (deriveNamespaceFromPackageId: com.qa.rollup → rollup, so rollup_parent / rollup_child), and publish-drafts refuses an unprefixed object NAMESPACE_PREFIX, so the Studio edit below would never reach the persisted metadata (an active PUT /meta/object?package= saves the unprefixed name anyway — the open product defect #24547; do not read that save as a prefix-free fixture), then in Studio's visual filter editor edit that summary field's child-row filter, save, and re-read the persisted metadata over the meta API. ⛔ Not POST /packages/com.example.showcase/duplicate — it answers 422 DUPLICATE_SOURCE_NOT_A_BASE (#14451)" ], "acceptance": [ { @@ -340,7 +346,7 @@ { "clause": "the child-row filter is settable in Studio's visual filter editor on a writable base package (create a scratch base via POST /api/v1/packages and author the parent+summary there), and persists per the meta API; duplicate of the code showcase package is refused DUPLICATE_SOURCE_NOT_A_BASE (#14451).", "oracle": "screenshot", - "verify": "POST /api/v1/packages creates the scratch base; author the parent with a filtered summary field and its child there; open the summary field in Studio's visual filter editor, edit its child-row filter, save, and confirm the persisted metadata via the meta API. An org overlay is NOT an alternative route: the object type declares supportsOverlay and allowEnvironmentOverlay false (DEFAULT_METADATA_TYPE_REGISTRY). The duplicate route's 422 DUPLICATE_SOURCE_NOT_A_BASE on the code showcase package is the expected refusal (requireDuplicableSource), not a blocker", + "verify": "POST /api/v1/packages creates the scratch base; author the parent with a filtered summary field and its child there, both carrying the base's namespace prefix (steps[6]); open the summary field in Studio's visual filter editor, edit its child-row filter, save, and confirm the persisted metadata via the meta API. An org overlay is NOT an alternative route: the object type declares supportsOverlay and allowEnvironmentOverlay false (DEFAULT_METADATA_TYPE_REGISTRY). The duplicate route's 422 DUPLICATE_SOURCE_NOT_A_BASE on the code showcase package is the expected refusal (requireDuplicableSource), not a blocker", "evidence": "editor screenshot + saved metadata read" } ], @@ -380,6 +386,12 @@ "date": "2026-10-05", "change": "clause 5, step 7, the fixture line and the source re-pointed (stale, #21784 VF4). Duplicating the code showcase package is refused with DUPLICATE_SOURCE_NOT_A_BASE (requireDuplicableSource, #14451); the run proved the editor half on a scratch writable package. This supersedes the duplicate route revision 3 adopted under the #9788 ruling. The clause uses the verifier's wording, with one measured cut: the verifier also offered 'or overlay per ADR-0005', but the object type declares supportsOverlay: false and allowOrgOverride: false in DEFAULT_METADATA_TYPE_REGISTRY, so an overlay cannot carry a summary field's filter. The clause names the scratch base only", "ref": "#21797" + }, + { + "revision": 5, + "date": "2026-10-19", + "change": "steps[6] and acceptance[4].verify name the namespace prefix the scratch base requires — checklist-accuracy finding of the 18.0 QA sweep (QA run #24545, automation.rollup-summary-filter step 7 / acceptance[4]; wave anchor #24579 §3): without it publish-drafts refuses the objects NAMESPACE_PREFIX and the persisted-metadata half is unreachable. Re-derived at base 9a7838edf5 (packages/spec/src/kernel/namespace-prefix.ts deriveNamespaceFromPackageId / validateObjectNamespacePrefix; metadata-protocol publish preflight NAMESPACE_PREFIX). The active-save asymmetry the run met is the product defect #24547 (open), not edited here. No clause changed", + "ref": "#24649" } ] }, @@ -388,7 +400,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": 6, + "revision": 7, "priority": "P1", "surface": "mixed", "personas": [ @@ -429,7 +441,7 @@ ], "steps": [ "boot showcase isolated (dogfood §0); sign in as the dev admin", - "GET /api/v1/automation/actions (the designer palette feed) and record the descriptor list; open the Studio flow-designer palette and screenshot it", + "GET /api/v1/automation/actions (the designer palette feed) and record the descriptor list; open the Studio flow-designer palette on a WRITABLE scope and screenshot it — the stock showcase package is read-only, so its canvas offers no Add node: clone a flow first (POST /api/v1/automation/NAME/clone, or Setup › Packaged Automation, as QA run #24545 did) and open the clone under Studio's ~org scope", "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)", "browser chain: run the Tasks row action showcase_bulk_reassign → showcase_reassign_wizard (screen + update_record)", @@ -447,9 +459,9 @@ "evidence": "actions API read + palette screenshot" }, { - "clause": "each non-structural variant plus `start` appears as an executed step; `end` is proven by the run reaching status completed (it logs no step).", + "clause": "each non-structural variant plus `start` appears as an executed step; `end` is proven by the run reaching status completed — a TAKEN end logs no step, while an end on an UNTAKEN branch is logged as a `skipped` step (that row proves the closed gate, not the end).", "oracle": "api", - "verify": "for each of the 20 step-logging variants cite the run id + step whose nodeType matches (status success, or the documented pause for suspending types), and for end cite a completed run — executeNode returns on end before any step is pushed, while start logs a success step. A variant with no located evidence is UNPROVEN for that variant, not a partial pass of the matrix", + "verify": "for each of the 20 step-logging variants cite the run id + step whose nodeType matches (status success, or the documented pause for suspending types), and for end cite a completed run — executeNode returns on a taken end before any step is pushed, while start logs a success step; a skipped end row (skippedBy naming the gate, the #4354 trace — e.g. showcase_inquiry_purge) is no evidence for end. A variant with no located evidence is UNPROVEN for that variant, not a partial pass of the matrix", "evidence": "per-variant table of run id / step / status" }, { @@ -487,7 +499,7 @@ ], "automated": { "kind": "e2e", - "ref": "packages/qa/dogfood/test/flow-node.dogfood.test.ts" + "ref": "packages/qa/dogfood/test/flow-node.dogfood.test.ts — covers ONE variant of acceptance[1] only: an update_record node runs over the HTTP trigger with its input variable wired into the filter (the target stamped, the bystander untouched); the matrix, the palette, the suspending types, the region tags and the unknown-type contract need the run" }, "source": [ "packages/spec/src/automation/flow.zod.ts#FlowNodeAction (FlowNodeAction — the built-in seed set; type validated at registerFlow, not by a closed enum)", @@ -537,6 +549,12 @@ "date": "2026-10-16", "change": "the record_change chain's 'POST a task, then PATCH done' failed as written (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows. Step 3 now creates the task with status 'in_review'. The http knownGap names the self-pinging scratch-flow recipe the #23878 automation slice located success on (finding 8): a runtime flow GETting the server's own /api/v1/health completed with its http step success, no egress needed", "ref": "#24036" + }, + { + "revision": 7, + "date": "2026-10-19", + "change": "acceptance[1] (clause, verify), steps[1] and automated.ref corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24545, automation.flow-node-type-matrix acceptance[1] and steps[1]) and round 2's Tier 1 record (#24598, via wave anchor #24579 §3, comment 6272032736), which names this ref among those that over-claim clause coverage. \"end logs no step\" is true only of a taken end: an untaken branch's end is logged skipped (recordSkipped, service-automation engine.ts, at base 9a7838edf5); the palette has no Add node on the read-only package; and the one dogfood pin runs a single update_record node", + "ref": "#24649" } ], "enumSource": { @@ -550,7 +568,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": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": [ @@ -610,7 +628,7 @@ { "clause": "the screen flow's run pauses at its screen node and the resume completes it", "oracle": "network", - "verify": "capture the trigger POST and the /runs/:runId/resume POST; run detail shows paused→completed", + "verify": "capture the launch POST — POST /api/v1/actions/showcase_task/showcase_bulk_reassign (or its /:recordId form): a declared type:'flow' action launches through the action door since objectui#12037, as automation.screen-flow-roundtrip records — and the /runs/:runId/resume POST; run detail shows paused→completed", "evidence": "network trace + run reads" }, { @@ -630,7 +648,7 @@ ], "automated": { "kind": "test", - "ref": "packages/qa/dogfood/test/flow-trigger-conformance.test.ts" + "ref": "packages/qa/dogfood/test/flow-trigger-conformance.test.ts — covers acceptance[5] ONLY (the ADR-0060 D5 build ratchet: every Flow.type value classified with a live runtime); acceptance[0]–[4] need live trigger runs" }, "source": [ "packages/spec/src/automation/flow.zod.ts#Flow (Flow.type enum: autolaunched|record_change|schedule|screen|api)", @@ -663,6 +681,12 @@ "date": "2026-10-16", "change": "the schedule and time_relative legs assumed scheduled work fires on a stock boot (#23878 automation slice finding 1; #23879 out_of_scope_findings). Re-measured on main c91af8ed6: a default boot leaves both flows unbound by deployment policy (banner lines, _status bound:false, zero digest runs), while a boot with OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 binds them (21 of 32 flows bound instead of 19) and showcase_scheduled_digest ticked three times 60 s apart with trigger.type 'schedule'. The fixture line and step 1 now name the switch. Step 3's status→done patch is also walked through in_review with a project: from the default backlog it answers 400 invalid_transition, and without a project the create answers 400", "ref": "#24036" + }, + { + "revision": 5, + "date": "2026-10-19", + "change": "acceptance[4].verify and automated.ref corrected — checklist-accuracy finding of the 18.0 QA sweep (QA run #24545, automation.trigger-type-matrix acceptance[4] / steps[5]: the screen flow launches through the action door, as screen-flow-roundtrip revision 4 already records) and round 2's Tier 1 record (#24598, via wave anchor #24579 §3, comment 6272032736), which names this ref among those that over-claim clause coverage: its two tests are the ledger ratchet only. Re-read at base 9a7838edf5. No clause changed", + "ref": "#24649" } ] }, @@ -671,7 +695,7 @@ "title": "A failing node inside try_catch is handled (catch region, $error binding) and the failed attempt is still recorded; outside it fails the run loudly", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": [ @@ -681,7 +705,7 @@ "app": "showcase", "requires": [ "showcase_resilient_sync — try region POSTs https://api.example.com/v1/tasks (unroutable from the isolated env, so the failure is deterministic); retry {maxRetries:3, backoffMs:1000, backoffMultiplier:2, maxRetryDelayMs:10000}; catch region writes sync_status/sync_error onto the task with the caught $error", - "a writable/scratch package to author the UNPROTECTED probe flow into (the showcase itself ships read-only)" + "a place to author the UNPROTECTED probe flow (the showcase itself ships read-only): POST /api/v1/automation reads no package selector, so the probe lands package-unbound (Studio's ~org scope, 'Organization flows'), which serves this item; to put it in a writable scratch package instead, author it through PUT /api/v1/meta/flow/?package=" ] }, "steps": [ @@ -689,7 +713,7 @@ "handled case: POST a showcase_task carrying its required `project` (a seeded showcase_project id; without it the create answers 400 VALIDATION_FAILED 'Project is required'), status 'in_review' and an assignee, then PATCH status→'done' (allowed from in_review only) — showcase_resilient_sync runs, its try-region http node fails against the unroutable host, retries, then the catch region runs", "GET /api/v1/automation/showcase_resilient_sync/runs/:runId — record the overall status, the try_catch container step's own status, EVERY step carrying regionKind='try' (nodeId, status, error, retryAttempt) in log order, the catch step's status/regionKind, and the run duration", "re-read the task over /api/v1/data/showcase_task/:id — sync_status and sync_error", - "unhandled case: POST /api/v1/automation a minimal autolaunched probe flow (start → http POST to the same unroutable URL, NOT wrapped in try_catch → end) in the scratch package. Fire it from a TRIGGER (a record-change specimen, or a shipped trigger-fired flow such as showcase_declarative_connector_ping / showcase_task_completed_rest_ping) — ⛔ NOT via POST /api/v1/automation//trigger, because clause 7 cannot observe an api-triggered failure: the ERROR line exists precisely because no caller holds the result, and a manual/api trigger returns the envelope to its caller. Use the api-triggered run for clauses 5-6 (run status and run-level error) if convenient, but score clause 7 from a trigger-fired specimen", + "unhandled case: POST /api/v1/automation a minimal autolaunched probe flow (start → http POST to the same unroutable URL, NOT wrapped in try_catch → end) — the door takes no package selector, so the probe lands package-unbound (fixtures.requires[1]). Fire it from a TRIGGER (a record-change specimen, or a shipped trigger-fired flow such as showcase_declarative_connector_ping / showcase_task_completed_rest_ping) — ⛔ NOT via POST /api/v1/automation//trigger, because clause 7 cannot observe an api-triggered failure: the ERROR line exists precisely because no caller holds the result, and a manual/api trigger returns the envelope to its caller. Use the api-triggered run for clauses 5-6 (run status and run-level error) if convenient, but score clause 7 from a trigger-fired specimen", "GET the probe run: overall status and the run-level error", "open the designer Runs panel for both runs; screenshot the failed step marked with its error and the catch-body nesting", "capture the server log lines for the probe run's failure" @@ -773,7 +797,8 @@ "ref": "#7546 (ruling 2026-08-11; QA run #7516; #1479 splice plumbing)" }, { "revision": 3, "date": "2026-08-18", "change": "re-pointed step 5 and clause 7 at a trigger-fired specimen. The step prescribed an api-triggered probe, whose failure clause 7 structurally cannot observe: service-automation/src/engine.ts emits the ERROR line only inside trigger.start's callback, and its own text and #4632 verdict give the reason — 'no caller holds this result'. For a manual or api trigger the caller DOES hold it, so no line is written and a clean log reads as a missing diagnostic (#9467 CF-5)", "ref": "#9386" }, - { "revision": 4, "date": "2026-10-16", "change": "the handled case's 'POST a showcase_task and PATCH status→done' failed as written (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows. Step 2 now creates the task with project and status 'in_review'; on that path showcase_resilient_sync ran and completed (the catch region handled the failure)", "ref": "#24036" } + { "revision": 4, "date": "2026-10-16", "change": "the handled case's 'POST a showcase_task and PATCH status→done' failed as written (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows. Step 2 now creates the task with project and status 'in_review'; on that path showcase_resilient_sync ran and completed (the catch region handled the failure)", "ref": "#24036" }, + { "revision": 5, "date": "2026-10-19", "change": "fixtures.requires[1] and steps[4] say where the probe flow lands — checklist-accuracy finding of the 18.0 QA sweep (QA run #24545, automation.flow-error-handling step 5 / fixtures.requires; wave anchor #24579 §3): POST /api/v1/automation reads no package selector, so \"in the scratch package\" could not be honoured. Re-derived at base 9a7838edf5 (packages/runtime/src/domains/automation-flow-door-package-address.test.ts 'POST / of a flow no package holds saves the package-unbound row'). No clause changed", "ref": "#24649" } ] }, { @@ -989,7 +1014,7 @@ "title": "A paused record-change run's stored state serves its trigger record masked — credential-class fields as the mask, internal fields absent — to a run-state reader, hot and after a cold boot", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -1001,7 +1026,7 @@ "requires": [ "a FILE-backed database (the cold-boot clause is structurally unreachable on the in-memory store — record the db path in the run env)", "an object declaring one ordinary field, one password field, one secret field and one internal: true field, and an ACTIVE record-change flow on it (record-after-update) that pauses at a screen node and, after the resume, copies {record.} and {previous.} into a second object", - "a member persona whose permission set grants read on sys_automation_run (and on the echo object) but is not a platform admin" + "a member persona whose permission set grants read on sys_automation_run (and on the echo object) but is not a platform admin — mint the member with POST /api/v1/auth/admin/create-user (then its password change), author the permission set at runtime, and grant it as the admin with POST /api/v1/data/sys_user_permission_set {user_id, permission_set, organization_id} (QA run #24545)" ], "knownGaps": [ "Stock showcase has the credential-class object (showcase_field_zoo carries f_password and f_secret) but no ACTIVE record-change flow on it that pauses: showcase_approver_bindings is draft on purpose. Author the fixture flow at runtime (and the member's permission set) or score the item from its pin, recording which the verdict rests on.", @@ -1073,6 +1098,12 @@ "date": "2026-10-06", "change": "initial — the run-state path had no item reading a paused run's stored state as a non-privileged holder; adds it with the hot, cold-boot and privileged-path clauses", "ref": "#21867" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "fixtures.requires[2] says how the member gets its grant — checklist-accuracy finding of the 18.0 QA sweep (QA run #24545, automation.paused-run-trigger-record-masked fixtures; wave anchor #24579 §3): the run applied the read grant through a sys_user_permission_set row written by the admin. Re-derived at base 9a7838edf5 (sys_user_permission_set declares user_id, permission_set and organization_id). No clause changed", + "ref": "#24649" } ] }, @@ -1364,10 +1395,10 @@ "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": 2, + "revision": 3, "priority": "P1", "surface": "api", - "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "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"], "fixtures": { "app": "showcase", "requires": [ @@ -1385,10 +1416,10 @@ "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 showcase isolated (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; qa_always_fails; the disabled showcase_urgent_task_alert; qa_no_start_node; a flow that succeeds; 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; 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)", "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 to obtain a live paused runId, then drive each refusal arm against POST /api/v1/automation/:name/runs/:runId/resume — a bodyless/valid resume (200, next screen or completion), a resume missing a required screen input (400 INVALID_SCREEN_INPUT), a resume whose signal writes the engine's `$` namespace (400 INVALID_SIGNAL), a resume of an unknown runId (404 RUN_NOT_FOUND), and a resume as a caller who is neither the trigger identity nor a resumeAuthority holder (403 PERMISSION_DENIED)", + "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 bodyless/valid resume (200, next screen or completion); a resume missing a required screen input (400 INVALID_SCREEN_INPUT); a resume whose signal writes the engine's `$` namespace (400 INVALID_SIGNAL) — spelled {\"output\": {\"$x\": 1}}, which lands as collect.$x: a `$` key inside `inputs` meets the screen check first and answers INVALID_SCREEN_INPUT, and a body carrying both `inputs` and `variables` is refused 400 VALIDATION_FAILED since #24594; 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 the SAME disabled flow through POST /api/v1/actions/... with a type:'flow' action and through a declared type:'flow' endpoint, and record what each answers", "capture the per-row × per-door verdict table" @@ -1484,6 +1515,12 @@ "date": "2026-10-16", "change": "clause 4 asked for error.details.errorMessage from the qa-flow-status-doors recipe's qa_always_fails, which declared no errorMessage, so the key could never appear (#23878 automation slice finding 3). The recipe's step 3 now authors one, and the verify says where the key comes from. Re-measured on main c91af8ed6: without errorMessage the trigger answers 400 FLOW_FAILED with details {summary}; with the top-level errorMessage it answers 400 FLOW_FAILED with details.errorMessage and details.summary", "ref": "#24036" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "personas, steps[2] and steps[5] name the specimens and spellings each arm needs — checklist-accuracy findings of the 18.0 QA sweep (QA run #24545 steps[2] and steps[5], QA run #24596 acceptance[4]'s 409 arm; wave anchor #24579 §3, comment 6272314386). The success row now names showcase_notify_owner; the wizard is triggered with recordId + objectName; INVALID_SIGNAL is spelled through `output`, since a `$` key in `inputs` meets the screen check first (refuseInvalidScreenInput runs before applyResumeSignal, engine.ts) and the `inputs` + `variables` body #24545 measured as silently dropped is refused since PR #24594 (c3954057fe, the fix of #24546); the 403 member must be past its password change; and 409 RESUME_IN_PROGRESS reproduces with a slow http node before a second screen. Re-derived at base 9a7838edf5. The 503 STORE_UNAVAILABLE arm stays blocked(fixture) (§4)", + "ref": "#24649" } ] }, @@ -1599,7 +1636,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": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -1626,7 +1663,7 @@ ], "acceptance": [ { - "clause": "the subflow-caller disable is refused 409 with code DELETE_RESTRICTED — the standard catalog's cannot-do-this-due-to-dependencies member, NO new code minted — and the message names every packaged caller verbatim plus the remedy ('Disable the calling flow(s) first, or leave this one armed')", + "clause": "the subflow-caller disable is refused 409 with code DELETE_RESTRICTED — the standard catalog's cannot-do-this-due-to-dependencies member, NO new code minted — and the message names every packaged caller verbatim plus the remedy, built per caller — 'Disable the calling flow 'NAME' first — or leave this one armed.' (one clause per armed caller and per parked run, engine.ts): match on the caller's name and 'leave this one armed', never on a fixed sentence", "oracle": "api", "verify": "step 2's answer: HTTP 409, error.code DELETE_RESTRICTED (packages/spec/src/api/errors.zod.ts; the engine throws {code:'DELETE_RESTRICTED', status:409, subflowCallers:[…]} — engine.ts), message containing 'showcase_task_done_notify_owner' and the ADR-0126 §7.3 rationale (breaks mid-run at its subflow node). A 500, a bare 409 with a generic code, or a message that does not name the caller is a FAIL — the named caller IS the actionability §7.3 exists for", "evidence": "the full 409 body, callers quoted" @@ -1682,7 +1719,8 @@ "ref": "#12438" }, { "revision": 2, "date": "2026-10-02", "change": "retired clause 5's expected-fail note and re-ordered step 6. The remedy sequence now holds (36d043b #20724, 0d9349f #20759): on 17.6.0 the caller's disable then the child's retry both answer 200 and the ledger reads active=false. Step 6's caller-first enable is refused by design since 679f95e (#20711, 409 RESOURCE_CONFLICT naming the disabled subflow), so the step enables the child first and clause 4 is scoped to a child's enable. Measured by the 17.6.0 release-verification run", "ref": "#21330" }, - { "revision": 3, "date": "2026-10-16", "change": "the 'still armed' probe patched a showcase_task to done without saying how to get there (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows. Step 4 now creates the task with project and status 'in_review' first; on that path showcase_task_done_notify_owner ran and completed", "ref": "#24036" } + { "revision": 3, "date": "2026-10-16", "change": "the 'still armed' probe patched a showcase_task to done without saying how to get there (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows. Step 4 now creates the task with project and status 'in_review' first; on that path showcase_task_done_notify_owner ran and completed", "ref": "#24036" }, + { "revision": 4, "date": "2026-10-19", "change": "acceptance[0].clause quotes the remedy the engine writes — checklist-accuracy finding of the 18.0 QA sweep (QA run #24545, automation.packaged-flow-subflow-disable-refusal acceptance[0]; wave anchor #24579 §3): the remedy names the calling flow, so a substring match on the generic quote fails although the meaning is identical. Re-derived at base 9a7838edf5 (packages/services/service-automation/src/engine.ts, the subflow-caller refusal: 'disable the calling flow 'NAME' first' … ' — or leave this one armed.')", "ref": "#24649" } ] }, { @@ -1690,7 +1728,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": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -1722,9 +1760,9 @@ "evidence": "the three 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 9 FLOW_CLONE_DROPPED_KEYS (_diagnostics, _draft, _lock, _lockReason, _lockSource, _provenance, _packageId, _packageVersion, _lockDocsUrl) is present on the clone; no clonedFrom-shaped key exists in the response or the stored definition", + "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", "oracle": "api", - "verify": "the step-4 diff: differences are exactly {name, label, status:'draft'} (FLOW_CLONE_MUTATED_FIELDS, flow-clone.ts) plus the ABSENCE of the dropped keys (flow-clone.ts — derived from MetadataProtectionFields + METADATA_READ_DECORATIONS, 7+2=9 today; count them from the spec at run time rather than trusting this line). A missing facet (nodes, edges, variables, runAs, retry…) is the #11703 param-list failure shape; a surviving _packageId or _lock defeats the feature (the clone would be package-owned or locked); any ancestry key violates amendment ruling 2 / §9", + "verify": "the step-4 diff: differences are exactly {name, label, status:'draft'} (FLOW_CLONE_MUTATED_FIELDS, flow-clone.ts) plus the ABSENCE of the dropped keys (flow-clone.ts — METADATA_READ_DECORATIONS + MetadataProtectionFields, derived, plus the package author's `protection` block, named (#23120): 2+7+1=10 at base 9a7838edf5; count them from the source at run time rather than trusting this line). A missing facet (nodes, edges, variables, runAs, retry…) is the #11703 param-list failure shape; a surviving _packageId or _lock defeats the feature (the clone would be package-owned or locked); any ancestry key violates amendment ruling 2 / §9", "evidence": "the key-by-key diff + the dropped-keys absence check" }, { @@ -1787,7 +1825,8 @@ "ref": "#12438" }, { "revision": 2, "date": "2026-10-02", "change": "clause 7's expected-fail note now names only the reachability half. The durability half was fixed by cb4c31d (#20907, with #20761): on 17.6.0 the clone survives a cold restart, reads back with its edited label and still fires (release-verification run). The reachability half still fails — no Studio surface lists a package-less flow — and is now filed as #21332 instead of resting on the #12438 sweep", "ref": "#21330" }, - { "revision": 3, "date": "2026-10-17", "change": "clause 6's reachability EXPECTED FAIL is stale (checklist-accuracy finding, automation slice report on #23878): objectui#11553 gave Studio an org scope whose Automations rail lists package-less flows, and #21332 closed as a duplicate. The verify now asserts the clone is listed and opens at /_console/studio/~org/automations, steps[7] drives it after the restart, and the objectui source line names the org scope and its pin. Re-run on main c91af8ed6 with the console at objectui f8267df93aba, file DB: POST …/showcase_urgent_task_alert/clone {name qa_urgent_alert_clone, label} → 200 status draft; the Organization flows rail listed 'QA urgent alert clone' and opened it (Trigger: Record created or updated · showcase_task, Enabled, no lock banner); after a cold restart over the same DB the GET answered 200, the rail listed and opened it again, and one urgent task create added one run to the clone (0 → 1) and one to the base (1 → 2); teardown DELETE → deleted true", "ref": "#24036" } + { "revision": 3, "date": "2026-10-17", "change": "clause 6's reachability EXPECTED FAIL is stale (checklist-accuracy finding, automation slice report on #23878): objectui#11553 gave Studio an org scope whose Automations rail lists package-less flows, and #21332 closed as a duplicate. The verify now asserts the clone is listed and opens at /_console/studio/~org/automations, steps[7] drives it after the restart, and the objectui source line names the org scope and its pin. Re-run on main c91af8ed6 with the console at objectui f8267df93aba, file DB: POST …/showcase_urgent_task_alert/clone {name qa_urgent_alert_clone, label} → 200 status draft; the Organization flows rail listed 'QA urgent alert clone' and opened it (Trigger: Record created or updated · showcase_task, Enabled, no lock banner); after a cold restart over the same DB the GET answered 200, the rail listed and opened it again, and one urgent task create added one run to the clone (0 → 1) and one to the base (1 → 2); teardown DELETE → deleted true", "ref": "#24036" }, + { "revision": 4, "date": "2026-10-19", "change": "acceptance[1] (clause, verify) counts the dropped keys the clone really drops — checklist-accuracy finding of the 18.0 QA sweep (QA run #24545, automation.packaged-flow-clone-contract acceptance[1]; wave anchor #24579 §3): FLOW_CLONE_DROPPED_KEYS is 10, not 9, since packages/runtime/src/flow-clone.ts appends 'protection' (#23120, ADR-0010 §3.5) to the 2 read decorations and 7 protection fields. Re-derived at base 9a7838edf5", "ref": "#24649" } ] }, { @@ -1795,7 +1834,7 @@ "title": "The Setup packaged-automation page: reachable from Setup nav, lists packaged flows + packaged actions, toggle persists, clone on flow rows only, no lineage surface, server refusals verbatim in role=alert", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "browser", "personas": [ @@ -1818,8 +1857,8 @@ "steps": [ "boot showcase isolated (dogfood §0, OS_PORT exported); build/serve the pinned console; sign in as the dev admin", "nav probe: GET /api/v1/meta/app?id=setup and search the served navigation for a type:'component' item with componentRef 'automation:packaged'; then look for the entry in the rendered Setup sidebar and click it", - "regardless of the nav verdict, reach the page and screenshot it settled (only if the sidebar entry is MISSING — a regression of #12457 — fall back to the typed URL /apps/setup/component/automation/packaged, recording that the typed URL was needed, so the rest of the item is still scored)", - "inventory: compare the flows table rows against GET /api/v1/meta/flow (which items carry a real _packageId and _provenance != 'org') and the actions section against its own packaged-actions read; confirm every switch reads On", + "regardless of the nav verdict, reach the page and screenshot it settled (only if the sidebar entry is MISSING — a regression of #12457 — fall back to the typed URL /_console/apps/com.objectstack.setup/component/automation/packaged — where the sidebar entry lands, the app segment being the package id (QA run #24545) — recording that the typed URL was needed, so the rest of the item is still scored)", + "inventory: compare the flows table rows against GET /api/v1/meta/flow (which items carry a real _packageId and _provenance != 'org') and the actions section against its own packaged-actions read; confirm every switch reads On except a flow authored status: 'obsolete' (showcase_approver_bindings, examples/app-showcase/src/automation/flows/approver-bindings.flow.ts), whose switch correctly reads Off — 31 On / 1 Off at QA run #24545's subject", "toggle: flip showcase_urgent_task_alert OFF; reload the page fully; record the switch state and the sys_metadata_activation row; flip it back ON", "clone dialog: open Clone on a flow row — try submitting with name empty, label empty, then name 'Bad-Name!' + a label; record what renders; then a legal clone and the post-clone notice; DELETE the clone via the API afterwards", "refusal rendering (admin): flip showcase_notify_owner OFF — screenshot the row after the 409 lands", @@ -1834,7 +1873,7 @@ "evidence": "the meta/app body (item present or absent) + sidebar screenshot" }, { - "clause": "inventory: the flows table lists exactly the packaged flows (default all On) and the packaged-actions section renders beside it; tenant/org-authored flows are ABSENT", + "clause": "inventory: the flows table lists exactly the packaged flows (default all On, an obsolete-status flow excepted: the engine holds it disabled, so its switch reads Off) and the packaged-actions section renders beside it; tenant/org-authored flows are ABSENT", "oracle": "dom", "verify": "rows (data-testid packaged-flow-) match the isPackagedFlowItem filter — a real _packageId, not 'sys_metadata', and _provenance != 'org', joined against the engine runtime list as spine (objectui packagedFlows.ts). The clone created later must NOT appear (it is a tenant artifact). Erring either way is a real defect: a tenant flow shown gets an install-wide switch Studio owns; a packaged flow filtered out leaves no off-switch at all", "evidence": "row list vs the two API reads, side by side" @@ -1921,6 +1960,12 @@ "date": "2026-10-13", "change": "knownGaps[2] (new) and acceptance[7].clause record the member posture QA run #23163 measured, without adjudicating it (maintainer question, #23163 A7; parked on #23121, carried by #23169). At objectstack 36b2889c with objectui 1fb1ced36243 a plain member could not open the Setup app, and the sys_metadata_activation read answered 403, where the clause expected a reachable page and open reads. The clause asks the run to record, not judge, so the edit records the measured posture beside the authored one, and notes that acceptance[5]'s member cell is unreachable through the page while that posture holds. The maintainer question stays open", "ref": "#23169" + }, + { + "revision": 4, + "date": "2026-10-19", + "change": "steps[2], steps[3] and acceptance[1].clause corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24545, automation.setup-packaged-automation-board steps[1] and acceptance[1] / steps[3]; wave anchor #24579 §3). The fallback URL was spelled with the app name where the sidebar lands on the package-id path, and \"every switch reads On\" did not exempt showcase_approver_bindings, authored status 'obsolete'. Re-derived at base 9a7838edf5: the engine reports a flow enabled: false when its status is obsolete (getFlowRuntimeStates, service-automation engine.ts)", + "ref": "#24649" } ] }, @@ -1929,7 +1974,7 @@ "title": "A stored flow the engine refused to register no longer reads as running: GET /meta/flow/:name carries registration {state:\"refused\", reason} beside the authored status, the list carries registrations keyed by name, and a good flow reads registered", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -1939,7 +1984,7 @@ "app": "showcase", "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", - "a scratch flow the engine refuses (store it on a scratch boot with OS_ALLOW_UNLINTED_METADATA_WRITES=1, e.g. a node whose config is refused by contract)" + "a scratch flow the save door stores but the engine REFUSES at registration, under a name NEVER registered (a refused re-registration keeps the previous registration, which reads `registered` — engine.ts recordFlowRefusal): e.g. a type:'api' flow whose start node carries no config.secret — the parse accepts it and registerFlow refuses it — PUT on a scratch boot with OS_ALLOW_UNLINTED_METADATA_WRITES=1 (the save door's lint gate refuses flow-api-trigger-secret-missing otherwise). ⛔ Not a builtin node whose config the contract refuses: FlowSchema refuses that at the save door (422), so nothing is stored to read a verdict from" ] }, "steps": [ @@ -1982,6 +2027,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "fixtures.requires[1] names a refused flow that can be stored — checklist-accuracy finding of the 18.0 QA sweep (QA run #24545, automation.flow-registration-verdict-on-read fixtures; wave anchor #24579 §3): the suggested contract-refused builtin node config is refused at parse, and a refused re-registration keeps reading registered. Re-derived at base 9a7838edf5 (packages/services/service-automation/src/engine.ts: recordFlowRefusal ignores a name the engine holds; registerFlow refuses an api flow with no secret after the parse accepts it). Left alone from the same finding: the OS_METADATA_WRITABLE=flow half (flow declares allowRuntimeCreate: true, so a new flow needs no hatch). No clause changed", + "ref": "#24649" } ] }, @@ -1990,7 +2041,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": 2, + "revision": 3, "priority": "P1", "surface": "cli", "personas": [ @@ -2005,7 +2056,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; a REST-triggered flow reading record.x; `user.id` in CEL; job retryPolicy:{maxRetry:3}; an authRequired:false flow endpoint targeting a runAs:\"system\" flow", + "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", "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" ], @@ -2017,9 +2068,9 @@ "evidence": "the vitest summary line per pin file, with the package name and the subject sha" }, { - "clause": "each arm fails `os validate` (exit ≠ 0) with its rule (expression-invalid / node-config-refused-by-contract / unrecognized_keys with did-you-mean) at the right path, and the message names the canonical spelling", + "clause": "each arm fails `os validate` (exit ≠ 0) with its located refusal at the right path — a lint rule id for the lint-layer arms (expression-invalid, the CEL root scope), and for the schema-layer arms STACK_SCHEMA_INVALID carrying the contract's own sentence (e.g. 'refused at `bogusKey` by the notify contract') or unrecognized_keys with did-you-mean — and the message names the canonical spelling", "oracle": "build", - "verify": "exit code + JSON issues per arm", + "verify": "exit code + JSON issues per arm. Match the schema-layer arms on path + sentence: 'node-config-refused-by-contract' is the spec refusal's internal code (packages/spec/src/automation/flow-node-config-refusals.ts), not a rule id `os validate` prints", "evidence": "one line per arm" }, { @@ -2061,6 +2112,12 @@ "date": "2026-10-19", "change": "acceptance[2] narrowed to what the PUT door refuses (QA run #24545 acceptance[2] fail, disposition stale-spec; parked on #24579 §1; QA run #24558 flagged the same clause's \"PUT 422\" as too broad). #24545: the runtime PUT gate \"deliberately stands down on the CEL root-scope judgment (whole-stack, owned by os validate — which still refuses both arms, so negative clauses hold)\", by rulings 5791822697 (item 3) and 6104584601 (letter B) as packages/lint/src/flow-cel-root-scope.ts#perWriteSnapshotEntrance records, and the item's own pin validate-expressions.flow-cel-root.test.ts asserts the stand-down. So the record.x and user.id arms now expect 200 and a stored row at PUT. The job arm and the endpoint arm are not PUT /meta/flow bodies — job and api are code-only types, 403 NOT_CREATABLE (#24558) — so the clause names the flow-side twins the door does refuse: a try_catch node's retry.maxRetry, and the runAs: \"system\" flow a code-declared anonymous endpoint names (422 at runAs, #24545). steps[1] and the title follow; re-derived from main 5a871e4e41, where every source cited here is byte-identical to the run subject 5b14b7f3", "ref": "#24622" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "steps[0] and acceptance[1] (clause, verify) name arms and verdict shapes os validate really has — checklist-accuracy findings of the 18.0 QA sweep (QA run #24545 acceptance[1], QA run #24558 steps; wave anchor #24579 §3). \"A REST-triggered flow reading record.x\" was ambiguous: an api-type flow binds record (packages/lint/src/flow-cel-root-scope.ts hands record to the api kind); the refused shape is an autolaunched flow. The anonymous-endpoint arm needs an armed rateLimit or ADR-0121 D6 fires beside it (packages/spec/src/api/endpoint-publish-gate.ts). Schema-layer arms surface as STACK_SCHEMA_INVALID with the contract sentence, not a rule id. Re-derived at base 9a7838edf5; round 1's acceptance[2] re-point is unchanged", + "ref": "#24649" } ] }, @@ -2069,7 +2126,7 @@ "title": "Flow run semantics on v18: current_user is bound in CEL (null on a schedule run), `record` exists only when the run was handed one, a non-system caller starting a runAs:\"system\" self-triggered flow is refused 403 with nothing run, and a decision with overlapping true edges takes the FIRST", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -2081,11 +2138,11 @@ "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", "a SCRATCH copy of examples/app-showcase (or a scaffolded blank app) for every planted-metadata step — never edit the shared fixtures; a file DB under /tmp/RUN/ for every boot", - "scratch flows: a record-triggered flow writing current_user.id; an autolaunched runAs:\"system\" flow; a decision node whose two edges are both true" + "scratch flows: a record-triggered flow writing current_user.id, on an object the MEMBER may update — the stock member's own set (showcase_member_default) grants showcase_task read + create, NOT edit, so a member's PATCH of showcase_task answers 403 PERMISSION_DENIED: either bind the member to the contributor position (showcase_contributor edits the tasks assigned to them; the search:qa-contributor-bound-member recipe), or trigger the flow on the member's own showcase_private_note (QA run #24558); an autolaunched runAs:\"system\" flow; a decision node whose two edges are both true" ] }, "steps": [ - "as the member update a record that fires the record-triggered flow; read the stamped field", + "as the member (contributor-bound, or on its own showcase_private_note — fixtures.requires[2]) update a record that fires the record-triggered flow; read the stamped field", "as the member POST /api/v1/automation/qa_elevated/trigger", "trigger the decision flow and read which branch ran (run log)" ], @@ -2137,6 +2194,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it; D3 entry flow-decision-edge-branching-first-match", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "fixtures.requires[2] and steps[0] name a write the member is allowed — checklist-accuracy findings of the 18.0 QA sweep (round 2 record #24598, via wave anchor #24579 §3 comment 6272032736: acceptance[1]'s fixture should name the contributor persona; QA run #24558: a minted member gets 403 on PATCH showcase_task). Re-derived at base 9a7838edf5 (examples/app-showcase/src/security/permission-sets.ts: showcase_member_default grants showcase_task read + create and showcase_private_note edit; showcase_contributor grants showcase_task edit). No clause changed", + "ref": "#24649" } ] }, diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index e871c5652a9..4815a651a8d 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -126,7 +126,7 @@ "title": "os build's own contract: no computed value in the exit slot, located errors for schema and author-time rule failures, artifact + stats output, warnings never flip the exit", "since": "v16", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "build", "personas": ["operator (local shell)"], @@ -139,10 +139,10 @@ "steps": [ "on the clean config run `os build; echo $?` and capture the summary (metadata stats, Artifact path/size)", "run `os build --json` and capture the success payload", - "break the schema: give one field an impossible shape (e.g. delete its type) and run `os build; echo $?` — capture the located Zod error", + "break the schema: give one field an impossible shape (e.g. delete its type) and run `os build; echo $?` — capture the located Zod error. Make the break on an object authored as a PLAIN LITERAL handed to defineStack: ObjectSchema.create() parses at import (data/object.zod.ts), so the same break inside a helper-authored object (ObjectSchema.create / Field.*) dies at 'Loading configuration…' with the raw Zod issue array (path [fields, body, type], no object name) and never reaches the located 'defineStack validation failed … objects.0.fields.body.type' block this clause scores", "break an author-time rule: author a flow whose Approval node approver expression does not parse (the #4409 worked example that once built green while os lint rejected it) and run `os build; echo $?`", "re-run both failing builds with --json and capture the failure payloads", - "restore the config and add one UNDECLARED authoring key (the #3786 advisory class) where it is still an advisory: object, field, report and enable shapes are CLOSED, so an unknown key ON a field is a hard refusal (exit 2 out of defineStack, exit 1 'Validation failed … unrecognized_keys' under strict:false) — that is clause 2's territory, not this one. The advisory survives only at a nested strip-mode site, e.g. inside a field's object-form visibleWhen ({ dialect: 'cel', source: …, qaUndeclared: 1 }). Build it under defineStack(…, { strict: false }) and run `os build; echo $?` — the 'Undeclared authoring keys (1) — dropped at load; reported here, never refused' block prints and the build passes; under the default defineStack the same key draws defineStack's own one-line warning and exit 0, without the compile-side block", + "restore the config and add one UNDECLARED authoring key (the #3786 advisory class) where it is still an advisory: object, field, report and enable shapes are CLOSED, so an unknown key ON a field is a hard refusal (exit 2 out of defineStack, exit 1 'Validation failed … unrecognized_keys' under strict:false) — that is clause 2's territory, not this one. The advisory survives only at a nested strip-mode site, e.g. inside a field's object-form visibleWhen ({ dialect: 'cel', source: …, qaUndeclared: 1 }), on a RAW field object of a plain-literal object: written inside ObjectSchema.create() the key is stripped by that helper's own parse, with no advisory under either defineStack mode (build exit 0, the key silently absent). Build it under defineStack(…, { strict: false }) and run `os build; echo $?` — the 'Undeclared authoring keys (1) — dropped at load; reported here, never refused' block prints and the build passes; under the default defineStack the same key draws defineStack's own one-line warning and exit 0, without the compile-side block", "the exit-slot pair: make the config throw at LOAD time and run BOTH `os build; echo $?` (human path — expect exit 2 out of compile.ts's human-path catch: its own ✗ line, then this.exit(2)) and `os build --json; echo $?` (expect 1 via emitJson) — the pair that separates the framework's error exit from the #4873 leak", "repeat one successful run and one failing run verbatim and compare the two exit codes each time — a code that MOVES between identical runs is the #4873 signature, and only a repeat can see it", "confirm `os build` and `os compile` produce identical behavior on the same input (build is the documented alias)" @@ -203,7 +203,8 @@ { "revision": 2, "date": "2026-08-11", "change": "narrowed the exit-code clause to the #4873 duration-leak it exists to pin. As phrased ('exactly 0 or 1') it also forbade oclif's standard ExitError 2, which compile.ts's human-path this.error() catches legitimately produce on a config-load-time throw — so it contradicted cli.flag-command-error-ux, which asserts that same 2 is correct, and a run reported the collision as a product violation (#7648). The clause now pins what #4873 was: a computed value (duration/count) reaching the exit slot, detectable as a nonzero on success or a code that moves between identical runs; 0/1 via CliExitCode and oclif's fixed 2 on the human path are named as legitimate. Still load-bearing — a duration in the exit slot FAILS it — with a repeat-run step and a negative added to keep it falsifiable, and the CliExitCode source note corrected to say it narrows the emitJson/emitText slot, not oclif's error exit", "ref": "#7648" }, { "revision": 3, "date": "2026-08-18", "change": "reconciled clause 2 with clause 5, which contradicted each other. Clause 2 demanded exit 1 on a schema violation while clause 5 explicitly blesses 'an oclif 2 from a this.error() path'. Measured in compile.ts (build is an alias of it): the --json failure path emits the payload and calls this.exit(1); the human path falls through the same catch to this.error(), which exits 2. The clause now names both and asks which was run (#9417)", "ref": "#9386" }, { "revision": 4, "date": "2026-10-03", "change": "text refresh only, no status change: six live texts named compile.ts's this.error() as the mechanism of the human path's exit 2. Since bf36edd0a1 that catch prints its own ✗ line and ends in this.exit(2), which raises the same status without oclif's second Error: rendering; every oracle (exit 2 on the human path, 1 under --json) still holds. Revisions 2 and 3 above keep their wording: they record what was true when written", "ref": "#21492" }, - { "revision": 5, "date": "2026-10-17", "change": "steps[5] re-pointed (checklist-accuracy finding, #23878 close-out): as written ('add one undeclared authoring key') it is now a hard refusal, because object, field, report and enable shapes are closed, so it could only fail clause 5 for the wrong reason. The step now places the key at a nested strip-mode site (a field's object-form visibleWhen) under defineStack(…, { strict: false }) and names what the closed site does instead; clause 5's verify says the same. Re-run on main c91af8ed6 on a scratch stack: the key on a field → exit 2 'Unrecognized key(s) on this field: qaUndeclared' (default) and exit 1 'Validation failed … unrecognized_keys' (strict:false); the key inside visibleWhen under strict:false → exit 0 with '⚠ Undeclared authoring keys (1) — dropped at load; reported here, never refused' naming objects.qa_thing.fields.note.visibleWhen.qaUndeclared, and the artifact's visibleWhen without it; the same key under the default defineStack → exit 0 with defineStack's own one-line warning and no compile-side block", "ref": "#24036" } + { "revision": 5, "date": "2026-10-17", "change": "steps[5] re-pointed (checklist-accuracy finding, #23878 close-out): as written ('add one undeclared authoring key') it is now a hard refusal, because object, field, report and enable shapes are closed, so it could only fail clause 5 for the wrong reason. The step now places the key at a nested strip-mode site (a field's object-form visibleWhen) under defineStack(…, { strict: false }) and names what the closed site does instead; clause 5's verify says the same. Re-run on main c91af8ed6 on a scratch stack: the key on a field → exit 2 'Unrecognized key(s) on this field: qaUndeclared' (default) and exit 1 'Validation failed … unrecognized_keys' (strict:false); the key inside visibleWhen under strict:false → exit 0 with '⚠ Undeclared authoring keys (1) — dropped at load; reported here, never refused' naming objects.qa_thing.fields.note.visibleWhen.qaUndeclared, and the artifact's visibleWhen without it; the same key under the default defineStack → exit 0 with defineStack's own one-line warning and no compile-side block", "ref": "#24036" }, + { "revision": 6, "date": "2026-10-19", "change": "steps[2] and steps[5] name where the deliberate breaks must sit — checklist-accuracy findings of the 18.0 QA sweep (QA run #24551, cli.build-own-contract; wave anchor #24579 §3). Both plants were helper-authored on the stock fixtures, and ObjectSchema.create() parses at import (re-derived at base 9a7838edf5, packages/spec/src/data/object.zod.ts): the schema break died at config load with a raw Zod issue dump, and the nested strip-mode key was dropped by the helper with no advisory. The plants now sit on plain-literal objects. No clause changed", "ref": "#24649" } ] }, { @@ -211,7 +212,7 @@ "title": "os migrate: bare command is a read-only plan, apply is safe-by-default, re-runs are idempotent, and --json exits 0 on success (#4873)", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "cli", "personas": ["operator (local shell)"], @@ -223,7 +224,7 @@ }, "steps": [ "boot the scratch app once with `os dev -d file:/tmp//migrate.db` so the base schema exists; stop it", - "add a new field to one object in the config and `os build`; run bare `os migrate` twice — both runs must print the SAME pending add-column plan and mutate nothing", + "add a new field to one object in the config and `os build`; run bare `os migrate` twice — both runs must print the SAME pending add-column plan and mutate nothing. On a DB an `os dev` boot synced, that plan also carries create_table entries for the lifecycle-classed objects (seven at 5b14b7f3, QA run #24551): the dev boot kept them in its telemetry sibling (ADR-0057 §3.6), and a plain `os migrate` mirrors a non-dev boot, which has no sibling, so it plans them against the primary. Expect them beside the add-column, or run plan and apply under NODE_ENV=development (or OS_TELEMETRY_DB naming the sibling) so the one-shot boot provisions it as the syncing boot did (schema-migrate.ts, #22579)", "run `os migrate plan --json; echo $?` and capture the payload ({ database, managedTables, total, changes, pending })", "run `os migrate apply --yes; echo $?` and capture the applied list; then `os migrate plan` again and `os migrate apply --json --yes; echo $?` — the idempotent re-run. On the showcase (it declares federated showcase_external objects, which have no managed table) expect the PARTIAL reading: plan prints 'No drift over the N object(s) this plan examined — but M of T declared object(s) were NOT examined … PARTIAL plan', and the JSON re-apply answers message in_sync_partial with composition.coverage.reasons.federated > 0. Plain in_sync is the answer only for an app whose every declared object was examined", "remove a field from the config + `os build`; run `os migrate apply --yes` WITHOUT --allow-destructive (the drop must be SKIPPED with a warning), then with `--allow-destructive --yes` (applied)", @@ -300,7 +301,7 @@ "comment-parent-pair" ], "traps": ["stale-dist"], - "automated": { "kind": "unit", "ref": "packages/cli/src/utils/format.exit-code.test.ts" }, + "automated": { "kind": "unit", "ref": "packages/cli/src/utils/format.exit-code.test.ts — covers acceptance[2] ONLY (#4873: emitJson / emitText leave the exit code alone on success and a duration can no longer reach the exit-code slot); acceptance[0], [1] and [3]–[6] need the run" }, "source": [ "packages/cli/src/commands/migrate/index.ts + plan.ts (#2186 bare-command-is-plan; #3917 enforced never-mutates; occupancy warning) + apply.ts (--allow-destructive / --force / --yes / in_sync)", "packages/cli/src/utils/format.ts#CliExitCode (CliExitCode narrows the emitJson exit slot — the #4873 fix, commit 83df2fd)", @@ -321,6 +322,12 @@ "date": "2026-10-13", "change": "variants re-measured against the tree: `os migrate --help` lists 16 registered subcommands and the item named 13. Added the three it lacked: organization-ownership, security-catalog-overlays and comment-reactions (the v18 conversion of stored sys_comment.reactions into sys_comment_reaction records, whose verified run `apply --allow-destructive` waits for before dropping the column). The source note's count moves from 13 to 16 with them", "ref": "#23281" + }, + { + "revision": 5, + "date": "2026-10-19", + "change": "steps[1] and automated.ref corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24551, cli.migrate-plan-apply-json step 2; round 2's Tier 1 record #24598, which names this ref among those that over-claim clause coverage; wave anchor #24579 §3). On an os-dev-synced DB the pending plan also lists the lifecycle-classed tables: at base 9a7838edf5 the one-shot boot provisions the telemetry sibling only under isDevelopmentBoot (packages/cli/src/utils/schema-migrate.ts) and resolveTelemetryDbPath declines on a non-dev boot without OS_TELEMETRY_DB. The one pin asserts only the #4873 exit-code contract, so the ref now says it covers acceptance[2] alone. No clause changed", + "ref": "#24649" } ] }, @@ -329,7 +336,7 @@ "title": "objectstack verify: verdict vocabulary is closed, exit is nonzero exactly on failure verdicts, and the inconclusive split (needs-fixture/skipped) never fails the run", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "cli", "personas": ["operator (local shell)"], @@ -394,14 +401,15 @@ "skipped (inconclusive — never fails)" ], "traps": ["stale-dist", "wrong-persona"], - "automated": { "kind": "unit", "ref": "packages/cli/src/commands/verify-tenancy-posture.test.ts" }, + "automated": { "kind": "unit", "ref": "packages/cli/src/commands/verify-tenancy-posture.test.ts — covers acceptance[4] ONLY (#5262: a walled OS_TENANCY_POSTURE selects the multi-tenant suite, and single-org deployments keep the single-org suite); acceptance[0]–[3] (the verdict vocabulary, the hardFailures mapping, the needs-fixture split, the --rls hole) need the run" }, "source": [ "packages/cli/src/commands/verify.ts#hardFailures (hardFailures sum, exit contract, resolveVerifyMultiTenant / #5262 / ADR-0105 D1, separate RLS stack rationale)", "packages/verify/src/verify.ts#ObjectVerifyResult (the six-member status union; 400-VALIDATION_FAILED→needs-fixture vs 5xx→create-failed classification — re-pointed #18104: VALIDATION_FAILED survives in this file only inside a regex literal; the union this item means is declared on ObjectVerifyResult and the classification that reads it is isValidation)", "packages/verify/src/rls.ts (#1994 class: you can't write what you can't read)" ], "history": [ - { "revision": 1, "date": "2026-08-07", "change": "new item: verdict-to-exit-code mapping read from verify.ts + verify.ts (verdict union), with the inconclusive split and the #5262 posture-honesty negative as load-bearing clauses; the unstageable always-failing fixture recorded as a knownGap", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 1, "date": "2026-08-07", "change": "new item: verdict-to-exit-code mapping read from verify.ts + verify.ts (verdict union), with the inconclusive split and the #5262 posture-honesty negative as load-bearing clauses; the unstageable always-failing fixture recorded as a knownGap", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-10-19", "change": "automated.ref says which clause its pin covers — round 2's Tier 1 record (#24598, via wave anchor #24579 §3, comment 6272032736) names this ref among those that over-claim clause coverage: its seven tests assert the #5262 posture selection (acceptance[4]) and nothing of acceptance[0]–[3]. Re-read at base 9a7838edf5. No clause changed", "ref": "#24649" } ] }, { @@ -489,7 +497,7 @@ "title": "os test: a Quality Protocol suite is validated at LOAD, executed against a booted app, and its verdict is the exit code — capture/interpolation thread state, an unevaluable assertion FAILS", "since": "v17", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "cli", "personas": ["operator (local shell)", "suite author (writes qa/*.test.json)"], @@ -514,7 +522,7 @@ "prove the teardown ran: list `GET /api/v1/data/showcase_category` before and after and confirm the row the run created is gone (the run leaves the fixture exactly as it found it)", "prove the run repeats: execute the same command a second time and confirm it is green again with no leftover row — a suite that only passes once is a suite whose teardown is decorative", "stage the negative probes in a scratch dir's qa/: (a) a suite with `steps` misspelled as `stpes`, (b) a suite asserting `data.status equals degraded` against /api/v1/health, (c) a suite asserting `contains` over `data.nope`, a path the health response does not carry. Run `os test 'qa/*.test.json' --url http://localhost:; echo $?` and capture all three refusals in one transcript", - "prove the malformed suite never ran: the server log carries no request attributable to it, and the transcript shows the two OTHER staged suites still executing after the refusal", + "prove the malformed suite never ran from the transcript: its refusal carries no `Scenario:` line (the load-site refusal precedes execution), while the two OTHER staged suites still print their '📄 Running suite:' heading and their `Scenario:` lines after it. The server log is no oracle here: the stock dev boot writes no per-request access line, so 'no request in the log' holds whether the suite ran or not", "teardown-after-failure: stage a scenario that creates a record, captures its id, then fails a deliberate assertion, with a teardown that deletes `{{id}}`; run it and confirm via the API that the record is gone despite the red verdict", "variant sweep: stage one scenario per TestActionTypeSchema member (8) and run them in one suite; record each member's own verdict and the exact error text of every refusal — no member inferred from a sibling", "zero-match glob: `os test 'qa/nothing-matches-*.test.json' --url http://localhost:; echo $?` and record BOTH the message and the exit code", @@ -610,7 +618,8 @@ {"revision": 3, "date": "2026-08-17", "change": "the record action types stopped ASSUMING the mount (#7983). They now resolve it from the server: one memoised `GET {apiBase}/discovery` per run, addressing whatever `routes.data` advertises, with the RestApiConfigSchema + CrudEndpointsConfigSchema convention as the fallback — the `@objectstack/client` `getRoute` pattern, copied rather than re-invented. Measured on a booted stack (REST generator + dispatcher bridge) before and after, three configs: stock stays green; `crud.dataPrefix: '/objects'` went from `HTTP Error 404` to a created record, so that row of the gap is CLOSED; `api.apiPath: '/api/2026-01'` still 404s and is NOT closed — `apiPath` moves the discovery document itself, and the one fixed-path document (`/.well-known/objectstack`) advertises the dispatcher's `/api/v1/data` under all three configs, so trusting it would attach a false provenance to the same 404. That row is narrowed instead: the fallback is announced (a warning naming the mount, the failed probe and the remedy) and every 404 from a record action now carries the mount it addressed and where that mount came from, so the failure can no longer read as the suite author's own URL mistake. `api_call` is unchanged and probes nothing — it remains the escape hatch for a host the probe cannot reach", "ref": "#7983"}, { "revision": 4, "date": "2026-08-18", "change": "corrected the flag count from two to three. packages/cli/src/commands/test.ts declares url, token AND fail-on-empty; the same revision's own acceptance already describes --fail-on-empty, so the knownGap contradicted its own item. The gap's point is unchanged and now stated directly: none of the three flags selects scenarios (#9417)", "ref": "#9386" }, { "revision": 5, "date": "2026-09-28", "change": "the knownGap and the qa.json source note were falsified by #20289 (PR #20341) and are corrected: `os test` gained `--tags` (any-of, exact, case-sensitive; deselected scenarios are counted and never passed; a selection that matches no scenario exits 0, or 1 under --fail-on-empty), and the report now heads each suite with its `name` and prints each scenario's `name` beside its id. What stays true is narrower: `scenario.requires` is declared but NOT CHECKED, pending the maintainer's decision on #20289. Steps and acceptance are unchanged — the green-path clause's scenarioId is still on every scenario line, now in brackets after the name, or bare when the name equals the id", "ref": "#20289" }, - { "revision": 6, "date": "2026-09-28", "change": "the knownGap and the qa.json source note were falsified again by #20289's `requires` round (ruling B) and are corrected: `scenario.requires` is now CHECKED before the first step — `params` against the `os test` process environment, `services` against the target's discovery `services` (enabled and `available`) — and an unmet entry SKIPS the scenario with its reason, counted apart and never passed; an all-skipped run exits 0, or 1 under --fail-on-empty. `requires.plugins` is retired (refused at load, prescription names `requires.services`). Steps and acceptance are unchanged: the fixture suite declares no `requires`, so the green path runs every scenario exactly as before", "ref": "#20289" } + { "revision": 6, "date": "2026-09-28", "change": "the knownGap and the qa.json source note were falsified again by #20289's `requires` round (ruling B) and are corrected: `scenario.requires` is now CHECKED before the first step — `params` against the `os test` process environment, `services` against the target's discovery `services` (enabled and `available`) — and an unmet entry SKIPS the scenario with its reason, counted apart and never passed; an all-skipped run exits 0, or 1 under --fail-on-empty. `requires.plugins` is retired (refused at load, prescription names `requires.services`). Steps and acceptance are unchanged: the fixture suite declares no `requires`, so the green path runs every scenario exactly as before", "ref": "#20289" }, + { "revision": 7, "date": "2026-10-19", "change": "steps[6] names an oracle that can be consulted — checklist-accuracy finding of the 18.0 QA sweep (QA run #24551, cli.qa-suite-execution steps[6]; wave anchor #24579 §3): \"the server log carries no request attributable to it\" cannot be read, because the stock dev boot logs no per-request lines. Re-derived at base 9a7838edf5: the Hono server plugin logs a handler throw with method and path and no access line (packages/plugins/plugin-hono-server), and os test prints one `Scenario:` line per executed scenario under its suite heading (packages/cli/src/commands/test.ts). The step now reads the transcript. No clause changed", "ref": "#24649" } ] }, { @@ -858,19 +867,20 @@ "title": "The documented newcomer verification loop closes on a FRESHLY SCAFFOLDED project: os validate passes and the Console actually paints at /_console/ — the seam between the scaffold item and the showcase boot item", "since": "v17", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": ["a brand-new developer following content/docs/getting-started/quick-start.mdx, with no prior project"], "fixtures": { "app": "scaffold", "requires": [ - "a scratch dir and network access for `npx create-objectstack` (same prerequisite as cli.scaffold-first-run)" + "a scratch dir and network access for `npx create-objectstack` (same prerequisite as cli.scaffold-first-run)", + "for an unreleased candidate (steps[0]'s pre-release route): a localhost npm registry with NO upstream for @objectstack/* and create-objectstack, exactly as cli.scaffold-first-run's pre-release variant prescribes — the container image carries none (QA run #24551 installed verdaccio@6 into the run dir with npm and packed the public workspace packages, ~1 min)" ], "knownGaps": [ "This item deliberately does NOT re-prove what two existing items already cover. The scaffold→install→validate→build→artifact-boot chain belongs to cli.scaffold-first-run (automated by .github/workflows/scaffold-e2e.yml); the console-on-a-populated-app half belongs to cli.dev-boot-contract, which drives examples/app-showcase. What is untested is the SEAM: the Console rendering against a freshly scaffolded BLANK project — one object and no seed data. Score only the seam; cite the two siblings for the halves.", "A blank scaffold DOES have a loginable identity out of the box: `seed-admin` is default-ON in dev (commands/dev.ts: flags['seed-admin'] ?? true, 'Default: on (idempotent — it acts only while no account holds the seed address and no local password login exists anywhere, never overwrites an existing account)'), so bare `objectstack dev` seeds admin@objectos.ai / admin123 on an empty DB and the banner prints the credentials. Clause 4 therefore scores whether the newcomer can COMPLETE the documented visual check with that identity, not whether one exists. Still do not import showcase credentials.", - "the scaffold serves the console its OWN install put in node_modules (@objectstack/console from the registry at the scaffold's version), not this repository's vendored .objectui-sha bundle: the seam this item scores is the PUBLISHED console against a blank project, and a console change pinned in .objectui-sha but not yet released is not exercised here (cli.dev-boot-contract on examples/app-showcase is where the vendored bundle is served)" + "the scaffold serves the console its OWN install put in node_modules. On the PUBLISHED path that is @objectstack/console from the registry at the scaffold's version, not this repository's vendored .objectui-sha bundle: the seam is then the published console against a blank project, and a console change pinned in .objectui-sha but not yet released is not exercised (cli.dev-boot-contract on examples/app-showcase is where the vendored bundle is served). Under the PRE-RELEASE variant it is the candidate's own packed @objectstack/console — the dist scripts/build-console.sh builds from .objectui-sha, so build it (`pnpm objectui:build`) before packing or the tarball carries no dist/index.html — and a pinned-but-unreleased console change IS exercised. The run record names which path it took" ] }, "steps": [ @@ -935,7 +945,8 @@ }, { "revision": 2, "date": "2026-08-18", "change": "corrected the stale 'no seeded admin unless one is requested' assumption in both knownGaps. commands/dev.ts resolves seed-admin as flags['seed-admin'] ?? true and documents it 'Default: on', so a bare `objectstack dev` on an empty DB seeds admin@objectos.ai / admin123 and prints it in the banner. Clause 4's open question ('is there any identity a newcomer can sign in with?') has a definite answer, and leaving the gap as written told the runner not to assume the answer the tree already fixes (#9467 CF-6)", "ref": "#9386" }, { "revision": 3, "date": "2026-10-05", "change": "two corrections from run #21845. (1) Step 1's trailing `npm install` is dropped: the scaffolder already installs (pnpm when found, npm otherwise), the quick-start prescribes no separate install, and a trailing npm install fails on the pnpm layout on 17.6.0 and 17.7 alike. This matches the choice made on cli.scaffold-first-run, and the npm run dev step no longer presumes it. (2) acceptance[3] states its limit: a blank scaffold ships no app or action, so the docs' 'watch an action show/hide, confirm the nav and filters' half is recorded as not-applicable here, never as a FAIL. The record-create half stays scored. The clause names studio-authoring.first-run-loop as the in-console continuation and says outright that it covers the app/nav half and no action (card #21851 entries 8 and 10)", "ref": "#21845" }, - { "revision": 4, "date": "2026-10-17", "change": "two runner facts the item lacked (checklist-accuracy finding, #23878 close-out): it probes port 3000 with no port lever, and it did not say whose console the scaffold serves. steps[2] now names the OS_PORT / `npm run dev -- -p PORT` substitution (and step 4's), and a knownGap records that the scaffold serves its installed @objectstack/console, not the .objectui-sha bundle. Re-run of the port leg on 2026-10-17 from create-objectstack@next (18.0.0-next.2; latest was 17.7.0): `OS_PORT=48734 npm run dev -- -p 48734` → '➜ Console: http://localhost:48734/_console/', /api/v1/health 200 and /_console/ 200 on that port, nothing listening on 3000; node_modules/.pnpm carried @objectstack/console@18.0.0-next.2 and the page loaded its ./assets/index-*.js from it", "ref": "#24036" } + { "revision": 4, "date": "2026-10-17", "change": "two runner facts the item lacked (checklist-accuracy finding, #23878 close-out): it probes port 3000 with no port lever, and it did not say whose console the scaffold serves. steps[2] now names the OS_PORT / `npm run dev -- -p PORT` substitution (and step 4's), and a knownGap records that the scaffold serves its installed @objectstack/console, not the .objectui-sha bundle. Re-run of the port leg on 2026-10-17 from create-objectstack@next (18.0.0-next.2; latest was 17.7.0): `OS_PORT=48734 npm run dev -- -p 48734` → '➜ Console: http://localhost:48734/_console/', /api/v1/health 200 and /_console/ 200 on that port, nothing listening on 3000; node_modules/.pnpm carried @objectstack/console@18.0.0-next.2 and the page loaded its ./assets/index-*.js from it", "ref": "#24036" }, + { "revision": 5, "date": "2026-10-19", "change": "fixtures.requires gains the pre-release registry and knownGaps[2] covers both install paths — checklist-accuracy findings of the 18.0 QA sweep (QA run #24551, cli.scaffold-console-first-paint knownGaps[2]; round 2 record #24598 via wave anchor #24579 §3, comment 6272032736): under the pre-release variant the scaffold's console is the locally packed @objectstack/console, not the published one, and that variant needs a local registry the item never named. Re-derived at base 9a7838edf5: packages/console ships only dist/ (files [dist, README.md, CHANGELOG.md]), which scripts/build-console.sh builds from .objectui-sha. No clause changed", "ref": "#24649" } ] }, { @@ -943,7 +954,7 @@ "title": "os doctor: every check emits an attributed row, an unparseable posture is an ERROR not a swallow, ledger rows print under every posture, the ✓ is withheld when the read was incomplete — and the two monorepo-anchored scans announce a named skip in a user app instead of blessing a tree they never walked", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "cli", "personas": ["operator (local shell)"], @@ -961,7 +972,7 @@ }, "steps": [ "baseline: from the untouched scaffold run `os doctor; echo $?` and `os doctor -v; echo $?` — capture both full reports (the unconditional environment rows print first: Node.js, pnpm, TypeScript, Dependencies, Git, Environment files — then whichever finding-only rows are live, then the extended checks)", - "stage the config findings in the scratch copy: a view whose object does not exist (orphan), two objects whose lookup fields point at each other (circular), and one object nothing references (unused); run `os doctor` and capture the three warning rows — then restore and capture the three ✓ rows (Dependencies / Object usage / View integrity). Stage the orphan as a WELL-FORMED view container whose name equals its object key and whose object does not exist, e.g. views: [{ name: 'qa_missing', object: 'qa_missing', list: { columns: [...] } }]: the default defineStack load accepts it and all three checks run. A malformed view body (e.g. a single view's viewKind/config keys on a container) is refused by the default load, and doctor then prints '⚠ Config load Could not load config for analysis (config checks skipped)' and runs none of the three — a staging fault, not a doctor finding; defineStack(…, { strict: false }) loads any shape, and is the route when the staging itself needs a shape the closed schema refuses", + "stage the config findings in the scratch copy: a view whose object does not exist (orphan), two objects whose lookup fields point at each other (circular), and one object nothing references (unused); run `os doctor` and capture the three warning rows — then restore and capture the three ✓ rows (Dependencies / Object usage / View integrity). The untouched blank scaffold is NOT that clean baseline: its starter object — the template's blank_note, renamed to the project's namespace (qa_doctor_note for the qa-doctor scaffold) — is referenced by no view, app or flow (its src/views, src/apps and src/flows barrels are empty), so findUnusedObjects warns on it and the report never reaches its healthy line. Add one view over it to the scratch copy (e.g. views: [{ name: 'qa_doctor_note', object: 'qa_doctor_note', list: { columns: ['title'] } }]) before the clean run. Stage the orphan as a WELL-FORMED view container whose name equals its object key and whose object does not exist, e.g. views: [{ name: 'qa_missing', object: 'qa_missing', list: { columns: [...] } }]: the default defineStack load accepts it and all three checks run. A malformed view body (e.g. a single view's viewKind/config keys on a container) is refused by the default load, and doctor then prints '⚠ Config load Could not load config for analysis (config checks skipped)' and runs none of the three — a staging fault, not a doctor finding; defineStack(…, { strict: false }) loads any shape, and is the route when the staging itself needs a shape the closed schema refuses", "unparseable posture: `OS_TENANCY_POSTURE=isolatd os doctor; echo $?` — the Tenancy posture ERROR row must print naming the variable and the allowed set, the REST of the report must still run, and the exit must be 1 (#5382: report-not-refuse, resolved OUTSIDE the config-analysis try at doctor.ts, row pushed)", "env provenance: put the same bad posture in a .env FILE (not the shell) and re-run — the finding must still fire (#5387 dotenv cascade) and the Environment files row must attribute the value to the file that supplied it", "ledger: write .objectstack/installed-packages/broken.json holding non-JSON; run `os doctor` with NO posture set (default `single`) and capture the Installed packages row; then with a valid config and OS_TENANCY_POSTURE=isolated, confirm the run does NOT print the 'Unique scope ✓' success line while that entry is unreadable", @@ -1000,9 +1011,9 @@ "evidence": "the two isolated-run transcripts diffed on that line" }, { - "clause": "config-analysis rows both ways: the staged orphan view, circular lookup and unused object each produce a warning row naming the offender, and the restored config produces the three ✓ rows", + "clause": "config-analysis rows both ways: the staged orphan view, circular lookup and unused object each produce a warning row naming the offender, and the restored config, plus one view over the blank scaffold's starter note object, produces the three ✓ rows", "oracle": "log", - "verify": "detectCircularDependencies / findUnusedObjects / findOrphanViews rows (called at doctor.ts) name the staged shapes; the clean run prints the 'Dependencies', 'Object usage' and 'View integrity' successes", + "verify": "detectCircularDependencies / findUnusedObjects / findOrphanViews rows (called at doctor.ts) name the staged shapes; the clean run prints the 'Dependencies', 'Object usage' and 'View integrity' successes — an Object usage ⚠ naming the starter note object on that run is the missing staging view, not a doctor finding", "evidence": "the staged and clean transcripts" }, { @@ -1036,7 +1047,8 @@ "history": [ { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os doctor` as a whole had NO checklist item; it appeared only as a --help variant on cli.flag-command-error-ux while carrying seven unit-pinned seams (env provenance #5387, posture honesty #5382, ledger independence #5429, withheld ✓ #5412/#5413/#5644). Authored from the run body at doctor.ts with the withhold-the-✓ discipline the command itself established turned back on its own monorepo-anchored Test-coverage/Deprecations ✓s, which bless a tree they never examined in every user app — encoded as an expected-fail probe rather than silently accepted", "ref": "claude/new-session-0pv25p" }, { "revision": 2, "date": "2026-08-23", "change": "text refresh only, no capability change: #10679 landed the fix this item's expected-fail predicted, so that clause inverts into an ordinary pass. findMissingTests/findDeprecatedUsages now return a MonorepoTreeScan and the ✓ is reachable only from the scanned arm; outside a monorepo each check prints 'ℹ Skipped — no packages/spec/src in this directory (monorepo-only check)', with the resolved directory under -v. Rewritten against a REAL run of the shipped CLI in a `create-objectstack -t blank` scaffold and again from a monorepo checkout, not from source: (a) the expected-fail clause, step 7 and the item title inverted; (b) the row enumeration in acceptance[0] and step 1 corrected — measurement falsified the premise that only @objectstack/spec became conditional: `Installed packages` was ALREADY findings-only (installedPackageLedgerChecks returns [] on a clean ledger) and had never been an unconditional row, and NODE_ENV/Tenancy posture are finding-only too, so the enumeration now splits unconditional from finding-only rather than naming one exception; (c) the '@objectstack/spec Not built' negative flipped from an accepted note into a regression, its probe being gated; (d) two negatives added for the skip row's own wrong shapes (a ⚠ instead of a ℹ, and the row vanishing); (e) doctor-unexamined-spec-tree.test.ts added to the pins; (f) every line anchor in the item re-derived at this base — doctor.ts grew ~90 lines, so every number quoted at revision 1 had moved", "ref": "#10798" }, - { "revision": 3, "date": "2026-10-17", "change": "steps[1] said how to stage the three config findings but not that the staged config must load (checklist-accuracy finding, #23878 close-out: 'orphan-view staging needs defineStack strict:false, the default load refuses it, and doctor then skips config analysis'). Re-measured on main c91af8ed6, the finding holds for a malformed view body only: a well-formed container bound to a missing object loads under the default defineStack and doctor reports it, so strict:false is an option, not a requirement. The step names the well-formed shape, the skip row a refused load prints, and the strict:false route; knownGaps[0] says the staged config must load. Re-run on a scratch config (circular lookup pair qa_a/qa_b, unreferenced qa_unused): with the container { name: 'qa_missing', object: 'qa_missing', list } under the default load, os doctor exit 0 with '⚠ Circular dependency: qa_a → qa_b → qa_a', '⚠ Object \"qa_unused\" is defined but not referenced …' and '⚠ View \"qa_missing\" references non-existent object \"qa_missing\"'; the same three under strict:false with a mismatched-name container; a container carrying viewKind/config → '⚠ Config load Could not load config for analysis (config checks skipped) — defineStack validation failed …' and none of the three rows; the restored config → '✓ Dependencies No circular references detected', '✓ Object usage All objects are referenced', '✓ View integrity All views reference valid objects', exit 0", "ref": "#24036" } + { "revision": 3, "date": "2026-10-17", "change": "steps[1] said how to stage the three config findings but not that the staged config must load (checklist-accuracy finding, #23878 close-out: 'orphan-view staging needs defineStack strict:false, the default load refuses it, and doctor then skips config analysis'). Re-measured on main c91af8ed6, the finding holds for a malformed view body only: a well-formed container bound to a missing object loads under the default defineStack and doctor reports it, so strict:false is an option, not a requirement. The step names the well-formed shape, the skip row a refused load prints, and the strict:false route; knownGaps[0] says the staged config must load. Re-run on a scratch config (circular lookup pair qa_a/qa_b, unreferenced qa_unused): with the container { name: 'qa_missing', object: 'qa_missing', list } under the default load, os doctor exit 0 with '⚠ Circular dependency: qa_a → qa_b → qa_a', '⚠ Object \"qa_unused\" is defined but not referenced …' and '⚠ View \"qa_missing\" references non-existent object \"qa_missing\"'; the same three under strict:false with a mismatched-name container; a container carrying viewKind/config → '⚠ Config load Could not load config for analysis (config checks skipped) — defineStack validation failed …' and none of the three rows; the restored config → '✓ Dependencies No circular references detected', '✓ Object usage All objects are referenced', '✓ View integrity All views reference valid objects', exit 0", "ref": "#24036" }, + { "revision": 4, "date": "2026-10-19", "change": "steps[1] and acceptance[5] (clause, verify) name the clean baseline — checklist-accuracy finding of the 18.0 QA sweep (QA run #24551, cli.doctor-health-report fixtures; wave anchor #24579 §3): the stock blank scaffold always carries an Object usage ⚠, so the restored config never reached the three ✓ rows. Re-derived at base 9a7838edf5: the blank template ships src/objects/note.object.ts (blank_note, renamed to the project namespace at scaffold) and empty views/apps/flows barrels (packages/create-objectstack/src/templates/blank), and findUnusedObjects (doctor.ts) counts only view, flow and app references. The clean run now adds one view over that object", "ref": "#24649" } ] }, { @@ -1135,7 +1147,7 @@ "title": "os migrate duplicates: a read-only JSON inventory of identifiers minted across partitions — within-partition repeats excluded, nothing written, the live two-counter condition reported, and runnable BEFORE the #8686 repair destroys the evidence", "since": "v17", "status": "active", - "revision": 7, + "revision": 8, "priority": "P1", "surface": "cli", "personas": ["operator (local shell, pre-repair audit)"], @@ -1155,7 +1167,7 @@ "seed via direct SQL per the fixture recipe: the cross-partition duplicate, the within-partition repeat, an organizations row for '', and the paired sequence counters", "dump the DB's CONTENT before the run — every user table's rows (sqlite3 '.dump' with the CREATE lines aside, or one ordered SELECT * per table), `SELECT * FROM _objectstack_sequences ORDER BY 1`, and `SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY name` — and checksum each dump; run `os migrate duplicates > report.json; echo $?`; dump and checksum again and byte-compare the three pairs. ⛔ Do not use the whole-file md5 as the oracle: it is not what the read-only contract promises (rows, counters and schema)", "jq the report: .report/.reportVersion/.generatedAt/.database/.globalPartition/.filter/.counters/.scanned/.skipped/.duplicates/.liveConditions/.runtimeIndexPreflight/.summary", - "seed the kernel:ready blocker too (#8725). sys_metadata carries NO organization_id column: the runtime index is idx_sys_metadata_overlay_active ON sys_metadata (type, name, COALESCE(package_id, ''), COALESCE(bundle_member, '')) WHERE state = 'active', and the declared NULL-distinct fallback an untightened install has is UNIQUE (type, name, package_id, bundle_member) (sys-metadata.object.ts). Read the index's current SQL first; if it is the tightened form, put the declared one back (DROP INDEX idx_sys_metadata_overlay_active, then CREATE UNIQUE INDEX idx_sys_metadata_overlay_active ON sys_metadata (type, name, package_id, bundle_member)). Then insert two ACTIVE sys_metadata rows with the SAME type and name and package_id and bundle_member both NULL — legal under the NULL-distinct form, colliding under the COALESCE one — and jq .runtimeIndexPreflight and .summary.runtimeIndexesBlocked. Seed a metadata body the spec accepts for that type, or expect a [metadata_spec_invalid] hydration line naming the row on any later boot or plan (the boot reading it, not a report finding)", + "seed the kernel:ready blocker too (#8725). sys_metadata carries NO organization_id column: the runtime index is idx_sys_metadata_overlay_active ON sys_metadata (type, name, COALESCE(package_id, ''), COALESCE(bundle_member, '')) WHERE state = 'active', and the declared NULL-distinct fallback an untightened install has is UNIQUE (type, name, package_id, bundle_member) (sys-metadata.object.ts). Read the index's current SQL first; if it is the tightened form, put the declared one back (DROP INDEX idx_sys_metadata_overlay_active, then CREATE UNIQUE INDEX idx_sys_metadata_overlay_active ON sys_metadata (type, name, package_id, bundle_member)). Then insert two ACTIVE sys_metadata rows with the SAME type and name and package_id and bundle_member both NULL — legal under the NULL-distinct form, colliding under the COALESCE one — and jq .runtimeIndexPreflight and .summary.runtimeIndexesBlocked. Seed a metadata body the spec accepts for that type — for a view, identity keys alone ({ name, label, object }) are refused 'Not a `view` body: no recognized `view` key' (packages/spec/src/ui/view.zod.ts assertViewIdentity), so carry a container slot such as list: { columns: ['name'] } — or expect a [metadata_spec_invalid] hydration line naming the row on any later boot or plan (the boot reading it, not a report finding)", "run `os migrate duplicates --object ` and `--object ` — capture .filter in both payloads", "run `os migrate duplicates --database-url file:/tmp//dup.db` and confirm it reaches the same DB (the flag also honors OS_DATABASE_URL)", "negative (no_sql_seam): run `pnpm --filter @objectstack/cli exec vitest run src/commands/migrate/duplicates.null-seam.test.ts` and cite its pass — the seam double that answers no result set, driving the real command; then run `os migrate duplicates --database-url memory://qa; echo $?` and capture the boot_failed payload (the retired engine is refused at boot, naming the replacement)", @@ -1183,7 +1195,7 @@ { "clause": "the kernel:ready pre-flight (#8725) reports the RUNTIME-migration class os migrate plan cannot see: one entry per index the two kernel:ready migrations tighten (three — the overlay migration owns two; the third migration, sys_view_definition's, retired with its table under ADR-0131 D13), each blocked|clear|table-absent|unreadable, a blocked one naming every colliding key group and its row count", "oracle": "log", - "verify": ".runtimeIndexPreflight names idx_sys_metadata_overlay_active as blocked with one key group carrying the seeded type and name, package_id_key '' and bundle_member_key '', and its row count — and the SAME database run through `os migrate plan` mentions neither the index nor the seeded name (the matched control: the declared-index duplicate above IS reported by plan, this one is not); no entry names sys_view_definition", + "verify": ".runtimeIndexPreflight names idx_sys_metadata_overlay_active as blocked with one key group carrying the seeded type and name, package_id_key '' and bundle_member_key '', and its row count — and the SAME database run through `os migrate plan` mentions neither the index nor the seeded name in its plan REPORT (the boot's own log lines are not the report: plan's stdout carries '[Registry] Registered view: NAME' for every row it hydrates, and stderr a [metadata_spec_invalid] line for a body the spec refuses) (the matched control: the declared-index duplicate above IS reported by plan, this one is not); no entry names sys_view_definition", "evidence": "the pre-flight section beside the plan output for one database" }, { @@ -1246,6 +1258,12 @@ "date": "2026-10-17", "change": "steps[4]'s index recipe is stale (checklist-accuracy finding, #23878 close-out): sys_metadata has no organization_id column, and idx_sys_metadata_overlay_active is keyed (type, name, package_id, bundle_member) — the runtime form folds both nullable parts through COALESCE under WHERE state = 'active'. The step now names both forms, says to read the index first, and seeds two ACTIVE rows with NULL package_id and bundle_member; clause 3's verify names the key group the report actually carries. Re-run on main c91af8ed6 on a scratch database from a minimal stack's boot (its overlay index was the declared NULL-distinct form): two ACTIVE view rows named qa_dup_view inserted; os migrate duplicates --database-url file:SCRATCH exit 0, .runtimeIndexPreflight idx_sys_metadata_overlay_active status blocked with one group {type view, name qa_dup_view, package_id_key '', bundle_member_key ''} rowCount 2, idx_sys_metadata_overlay_draft and the sys_setting identity index clear, no sys_view_definition entry, summary runtimeIndexesBlocked 1 and runtimeIndexBlockingRows 2; os migrate plan over the same file listed neither the index nor qa_dup_view among its changes (its only mention of the name is a hydration line, because the seeded '{}' body is not a valid view)", "ref": "#24036" + }, + { + "revision": 8, + "date": "2026-10-19", + "change": "steps[4] and acceptance[3].verify narrowed to what can be read — checklist-accuracy findings of the 18.0 QA sweep (QA run #24551, cli.migrate-duplicates-inventory steps[4] and acceptance[3]; wave anchor #24579 §3). A minimal { name, label, object } view body is refused for carrying identity keys only (view.zod.ts assertViewIdentity at base 9a7838edf5), so the step names a container slot; and plan's stdout does name the seeded row in the registry's boot lines (objectql registry.ts logs '[Registry] Registered TYPE: NAME' through console.log), so the clause's control reads the plan report, not the boot log", + "ref": "#24649" } ] }, @@ -1254,7 +1272,7 @@ "title": "os datasource list-tables/introspect/validate: the federation door answers, the generated draft is cwd-jailed and compiles, and schema drift maps to ✗ diffs with exit 1", "since": "v16", "status": "active", - "revision": 5, + "revision": 6, "priority": "P2", "surface": "cli", "personas": ["operator (local shell) whose token HOLDS manage_platform_settings — all three commands address federation READ routes, which require it since #9901/#10255", "integration author (adopting a remote table), same capability"], @@ -1268,12 +1286,12 @@ "knownGaps": [ "the three CLI commands are unit-tested against the server's wrapped envelope since #10675 (packages/cli/src/commands/datasource/envelope-unwrap.test.ts). That suite drives each command over a stubbed fetch, so this item stays their only end-to-end coverage against a live server", "the #5594 carve-out applies to the fixture DB: .objectstack/data/showcase_external.db is a cwd-relative app-declared path that survives --fresh by documented design — do not file its persistence as a bug", - "acceptance[0]'s NARROWING half (--schema narrows; a non-matching schema prints 'No remote tables found.') cannot be observed on the stock fixture. SQLite tables carry no schema, and listRemoteTables applies ?schema= only to a table it can attribute to one (packages/services/service-datasource/src/external-datasource-service.ts: a table with no schema part is never filtered), so every --schema run lists all tables (QA run #21721, parked on #22017 row 24). Score that half from the pins in `automated`. The unpinned remainder, the CLI turning --schema into ?schema= (list-tables.ts), is observable live only against a schema-bearing driver such as postgres" + "acceptance[0]'s NARROWING half (--schema narrows; a non-matching schema prints 'No remote tables found.') cannot be observed on the stock fixture. SQLite tables carry no schema, and listRemoteTables applies ?schema= only to a table it can attribute to one (packages/services/service-datasource/src/external-datasource-service.ts: a table with no schema part is never filtered), so every --schema run lists all tables (QA run #21721, parked on #22017 row 24). Score that half from the pins in `automated`. The unpinned remainder, the CLI turning --schema into ?schema= (list-tables.ts), is observable live against a schema-bearing driver such as postgres, or with no driver at all: the request line a local echo server receives (steps[1])" ] }, "steps": [ "boot showcase on a scratch port and mint the admin token (the cli.qa-suite-execution recipe)", - "`os datasource list-tables showcase_external --url http://localhost: --token ` — capture the table listing; repeat with `--schema ` and with a schema that matches nothing ('No remote tables found.'). On the stock SQLite fixture both runs list every table, since there is no schema to filter on: record that and score the narrowing from the pins", + "`os datasource list-tables showcase_external --url http://localhost: --token ` — capture the table listing; repeat with `--schema ` and with a schema that matches nothing ('No remote tables found.'). On the stock SQLite fixture both runs list every table, since there is no schema to filter on: record that and score the narrowing from the pins. The forwarding half (the CLI turning --schema into ?schema=) needs no schema-bearing driver: re-run list-tables with --url pointed at a local echo server (any listener that prints the request line) and read GET /api/v1/datasources/showcase_external/external/tables?schema=S off it (QA run #24551 observed ?schema=mart)", "`os datasource introspect showcase_external --table --url … --token ` — draft printed to stdout; then re-run with `--out objects/qa_external.object.ts` and capture 'Wrote objects/qa_external.object.ts' plus any 'REVIEW: column …' warnings", "escape probes: re-run with `--out ../qa-escape.object.ts` and `--out /tmp/qa-escape.object.ts` — capture both refusals and `echo $?`, and confirm neither file exists afterwards", "wire the generated object into the scratch config and run `os build; echo $?`. The draft is named after the datasource's own prefix (introspecting showcase_external's customers table yields showcase_customers), and defineStack's namespace-prefix check refuses an object whose name lacks the package namespace — so give the scratch manifest namespace: 'showcase' (the draft's prefix), or rename the object to NAMESPACE_…, before reading the build verdict; a mismatched namespace scores the scratch wiring, not the generated draft", @@ -1357,6 +1375,12 @@ "date": "2026-10-17", "change": "steps[4] left out what wiring the draft needs (checklist-accuracy finding, #23878 close-out): the generated object carries the datasource's prefix, and a scratch manifest with any other namespace refuses it at defineStack. The step now says to match the namespace or rename. Re-run on main c91af8ed6 against the running showcase boot (admin token): list-tables showcase_external → customers (7 cols), orders (7 cols); introspect --table customers --out objects/qa_external.object.ts → 'Wrote objects/qa_external.object.ts', export showcase_customers bound to datasource showcase_external; wired into a scratch config with namespace 'qa' → os build exit 2 \"Object 'showcase_customers' is missing the package namespace prefix. Rename it to 'qa_showcase_customers' (namespace = 'qa').\"; with namespace 'showcase' → exit 0, 1 object, 7 fields", "ref": "#24036" + }, + { + "revision": 6, + "date": "2026-10-19", + "change": "steps[1] and knownGaps[2] add the live reading of the unpinned forwarding half — checklist-accuracy finding of the 18.0 QA sweep (QA run #24551, cli.datasource-introspect-codegen acceptance[0]; wave anchor #24579 §3): the knownGap said the CLI's flag-to-query forwarding is observable only against a schema-bearing driver, and the run proved it against a local echo server (GET …/external/tables?schema=mart). Re-derived at base 9a7838edf5: list-tables.ts builds `?schema=${encodeURIComponent(flags.schema)}` and sends it. No clause changed", + "ref": "#24649" } ] }, @@ -1365,7 +1389,7 @@ "title": "Hook/action body extraction: --strict-body refuses every forbidden pattern with its own worded reason, capability tokens are inferred (and crypto.hash is NOT, #4391), free identifiers fall back to bundling — and the DEFAULT build's warn-and-bundle fallback SURFACES every refusal (#10678)", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "cli", "personas": ["build-time author (no session)"], @@ -1381,7 +1405,7 @@ ] }, "steps": [ - "author the forbidden seven, one hook each: dynamic import(, require(, fetch(, process., globalThis., eval(, new Function( — then run `os build --strict-body; echo $?` and capture every per-callable diagnostic verbatim", + "author one hook per FORBIDDEN_PATTERNS entry (packages/cli/src/utils/extract-hook-body.ts) — fourteen at base 9a7838edf5: the seven sandbox escapes, dynamic import(, require(, fetch(, process., globalThis., eval(, new Function(, and the seven host members a sandboxed body cannot reach, .sudo(, .create( (Object.create( is exempt), ctx.dispatch.scope, ctx.submitted, and the hook-face-only ctx.provenance, ctx.ql and ctx.transaction (ctx.api.transaction(fn) is unaffected) — then run `os build --strict-body; echo $?` and capture every per-callable diagnostic verbatim. Recount the array before the run: an entry added since is one more hook to author, never a clause to skip", "the default-path pair on the SAME config: `os build; echo $?` — capture the exit, the 'Bundling N handler(s)…' line, and jq the artifact for each hook's body (expect ABSENT) and handler ref (expect present); capture the 'N handlers could not be lowered to a metadata body' warning block with its per-hook lines, and run `os build --json` on the same config to capture its bodyExtractionWarnings array", "the inference matrix, one hook each on a clean config: ctx.api.object('x').find(…) · ctx.api.object('x').update(…) · const api = ctx.api; api.object('x').find(…) · ctx.crypto.randomUUID() · ctx.log.info(…) · a body whose ONLY crypto call is ctx.crypto.hash(…) · a body carrying the retired @capabilities first-line comment and no matching calls (#10917 — it must contribute nothing) · a hook supplying body.capabilities as data instead of a handler · an implicit-return arrow — `os build` then jq each hook's body.capabilities and isExpression handling", "free-identifier probe: a handler calling a module-scope helper function — `os build; echo $?` (expect green, handler bundled, no body) then `os build --strict-body; echo $?` (expect exit 1 naming the identifier)", @@ -1390,10 +1414,10 @@ ], "acceptance": [ { - "clause": "--strict-body refuses EACH forbidden pattern with that pattern's own worded reason — fetch and import prescribe 'declare a Connector recipe instead', require/process/globalThis/eval/new Function each name themselves — never a bare parse error, and the run exits 1 (the --json arm emits the issues payload then exits 1)", + "clause": "--strict-body refuses EACH forbidden pattern with that pattern's own worded reason — fetch and import prescribe 'declare a Connector recipe instead', require/process/globalThis/eval/new Function each name themselves, and each unreachable host member (.sudo(, .create(, ctx.dispatch.scope, ctx.submitted, ctx.provenance, ctx.ql, ctx.transaction) names itself and its in-sandbox alternative — never a bare parse error, and the run exits 1 (the --json arm emits the issues payload then exits 1)", "oracle": "build", - "verify": "the seven diagnostics each quote the matching FORBIDDEN_PATTERNS reason (extract-hook-body.ts) under the '--strict-body: N callable(s) lack a metadata body' error (compile.ts); echo $? is 1 on both the human and --json paths", - "evidence": "the seven verbatim diagnostics + exit codes + the --json payload" + "verify": "the diagnostics — one per FORBIDDEN_PATTERNS entry, fourteen at base 9a7838edf5 — each quote the matching FORBIDDEN_PATTERNS reason (extract-hook-body.ts) under the '--strict-body: N callable(s) lack a metadata body' error (compile.ts); echo $? is 1 on both the human and --json paths", + "evidence": "one verbatim diagnostic per FORBIDDEN_PATTERNS entry + exit codes + the --json payload" }, { "clause": "read/write inference from the artifact: .object(x).find → api.read and .object(x).update → api.write land in body.capabilities — and the const-api ALIAS is still caught, because the regex deliberately matches any chain ending in .object(…). (over-inclusive by design; a false-positive token is rejected at runtime by the sandbox, not silently honored at build)", @@ -1451,7 +1475,8 @@ "history": [ { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): a security grant derived from a regex had no checklist item — only the runtime side of one token (records-forms) and the build's generic exit contract were covered. RE-VERIFIED against source with one material correction to the hunter brief: a forbidden pattern does NOT fail a default `os build` — lower-callables.ts catches every extraction error and falls back to the bundle, the warnings print nowhere on that path, and only --strict-body (compile.ts) produces the worded refusals; hook-bodies.mdx documents exactly that, while the extractor's own header still promises fail-with-no-fallback. The worded-refusal clauses are therefore pinned to --strict-body, and the default path's silence is encoded as the expected-fail contradiction clause", "ref": "claude/new-session-0pv25p" }, { "revision": 2, "date": "2026-08-22", "change": "the '// @capabilities' first-line override was RETIRED (#10917, maintainer ruling under ADR-0049 enforce-or-remove): measured on all four ordinary authoring shapes (.ts/.js/.mjs/an imported handler) it reached the extractor from NONE of them, so a documented directive silently taught a wrong convention and the mismatch surfaced far from its cause as a sandbox refusal. Its acceptance clause asserted behaviour that no longer exists — a runner would have filed a FAIL against the ruling — and is replaced by the covered route (body.capabilities as data) plus the inertness guarantee owed to configs that still carry the comment. Steps, fixtures and the extract-hook-body.ts line citations moved with it. No other clause changed; note the expected-fail clause still describes the pre-#10678 silent default and is stale on its own account, which is NOT this revision's subject", "ref": "claude/issue-10917-retire-capabilities-hook-directive" }, - { "revision": 3, "date": "2026-10-17", "change": "clause 6's EXPECTED-FAIL has flipped (checklist-accuracy finding, #23879 and the #23878 close-out; the r2 history already flagged it stale): since #10678 the default build prints the refused handlers and --json carries them as bodyExtractionWarnings. The title, clause 6 (now a positive assertion that fails if the default path goes silent again), its verify and evidence, steps[1], knownGaps[1] and two source parentheticals now describe the surfaced default. Re-run on main c91af8ed6 on a scratch stack with the forbidden seven, a module-scope helper and a clean find hook: default os build exit 0 with '⚠ 8 handlers could not be lowered to a metadata body — bundled via the legacy runtime module instead (build still succeeds)' and one bullet per hook carrying its own reason (import and fetch prescribe a Connector recipe, require, process, globalThis, eval and new Function name themselves, the helper hook names 'helper'); --json exit 0, success true, bodyExtractionWarnings 8 records; --strict-body exit 1 '--strict-body: 8 callable(s) lack a metadata body'; the artifact carries no body on the eight, body.capabilities ['api.read'] on the find hook, and one objectstack-runtime bundle in dist", "ref": "#24036" } + { "revision": 3, "date": "2026-10-17", "change": "clause 6's EXPECTED-FAIL has flipped (checklist-accuracy finding, #23879 and the #23878 close-out; the r2 history already flagged it stale): since #10678 the default build prints the refused handlers and --json carries them as bodyExtractionWarnings. The title, clause 6 (now a positive assertion that fails if the default path goes silent again), its verify and evidence, steps[1], knownGaps[1] and two source parentheticals now describe the surfaced default. Re-run on main c91af8ed6 on a scratch stack with the forbidden seven, a module-scope helper and a clean find hook: default os build exit 0 with '⚠ 8 handlers could not be lowered to a metadata body — bundled via the legacy runtime module instead (build still succeeds)' and one bullet per hook carrying its own reason (import and fetch prescribe a Connector recipe, require, process, globalThis, eval and new Function name themselves, the helper hook names 'helper'); --json exit 0, success true, bodyExtractionWarnings 8 records; --strict-body exit 1 '--strict-body: 8 callable(s) lack a metadata body'; the artifact carries no body on the eight, body.capabilities ['api.read'] on the find hook, and one objectstack-runtime bundle in dist", "ref": "#24036" }, + { "revision": 4, "date": "2026-10-19", "change": "steps[0] and acceptance[0] (clause, verify, evidence) cover every FORBIDDEN_PATTERNS entry — checklist-accuracy finding of the 18.0 QA sweep (QA run #24551, cli.hook-body-extraction-gates steps[0]; wave anchor #24579 §3): the array has grown past the original seven sandbox escapes. Re-derived at base 9a7838edf5 (extract-hook-body.ts): fourteen entries, the seven escapes plus .sudo( (#14010), .create( with Object.create exempt (#16249), ctx.dispatch.scope and ctx.submitted (#22810), and the hook-face-only ctx.provenance, ctx.ql and ctx.transaction (#22837). The clause already said EACH pattern; its count and its evidence now match the array", "ref": "#24649" } ] }, { @@ -1659,7 +1684,7 @@ "title": "autoMigrate off|safe: a dev boot self-heals ONLY the safe-categorized drift and reports each applied op, `off` (the driver default) and NODE_ENV=production never touch the schema, and a duplicates-blocked tighten is never auto-applied", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "cli", "personas": ["operator (local shell)"], @@ -1689,7 +1714,7 @@ "the off side first: boot with the datasource's config carrying `autoMigrate: 'off'` (and once more with the key OMITTED — the driver default); capture the [schema-drift] WARNING for each of the three divergences in both boots and confirm the schema dump before/after the two boots is identical. ⚠ `os migrate plan` is not the pending oracle here: with the primary on a config-declared datasource it composes no driver for it and prints a PARTIAL plan ('Examined 0 managed table(s)', 'N of N declared object(s) are NOT … bound to no driver'), so the next boot's warning plus the dump stands in for it", "the safe side: boot the same DB with `autoMigrate: 'safe'`; capture the `[schema-drift] auto-reconciled replace_unique_index on TABLE.COLUMN` info line and confirm the dump lost the legacy index, while the relax_not_null and orphaned-column warnings still print; boot AGAIN and confirm no further reconcile lines (the heal converged)", "production force-off: re-plant the legacy index, then boot with NODE_ENV=production and `autoMigrate: 'safe'` still declared; capture the exact warning `[schema-drift] autoMigrate='safe' is ignored under NODE_ENV=production — schema is never auto-altered in production. Run 'os migrate' deliberately.` and confirm via schema dump that nothing was altered", - "the injected arm: in a scratch project with NO config-declared datasource (os dev -d file:HOST_DB), boot once, stop, plant the same legacy index in that file and boot plain `os dev` — the CLI's fallback default datasource carries autoMigrate:'safe' in dev (storage-driver.ts), so the same auto-reconciled line must appear with no authored key anywhere; then re-plant it and boot non-dev `os start` over the same file — warnings only, the index stays", + "the injected arm: in a scratch project with NO config-declared datasource (os dev -d file:HOST_DB), boot once, stop, plant the same legacy index in that file and boot plain `os dev` — the default datasource carries autoMigrate:'safe' on a dev boot (devAutoMigrateConfig, packages/runtime/src/dev-auto-migrate.ts — the one decision createStandaloneStack's default datasource and the CLI's config-load fallback both read since #21733), so the same auto-reconciled line must appear with no authored key anywhere; then re-plant it and boot non-dev `os start` over the same file — warnings only, the index stays", "the destructive and needs_confirm boundary on the same boots: the removed field (orphaned column, destructive) and the relaxed NOT NULL (needs_confirm) stay warnings on every safe boot — `os migrate apply` (with --allow-destructive for the drop) remains the only door (cross-ref cli.migrate-plan-apply-json)", "the duplicates-blocked leg: run the unit pin (packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts + sql-driver-schema-drift.test.ts) and cite its output — or hand-stage duplicate rows in the scratch sqlite file and boot with safe, capturing the blocked report; record which route the verdict rests on" ], @@ -1725,9 +1750,9 @@ "evidence": "the test output (or the staged blocked report)" }, { - "clause": "the CLI injects 'safe' ONLY in dev, and only where it says: the fallback default datasource (no config-declared driver) and the telemetry datasource carry autoMigrate:'safe' when isDev and nothing otherwise — so a plain `os dev` self-heals with no authored key, while a production `os serve` boot leaves the driver at its own 'off' default", + "clause": "one decision injects 'safe' ONLY on a dev boot and only for the SQL kinds whose connection contract declares autoMigrate (sqlite, postgres, mysql): devAutoMigrateConfig, read by the standalone stack's default datasource, the CLI's config-load fallback and the telemetry sibling datasource — so a plain `os dev` self-heals with no authored key, while a production `os serve` boot leaves the driver at its own 'off' default", "oracle": "log", - "verify": "the keyless dev boot shows the auto-reconciled line (injection worked); a non-dev serve boot over the same staged drift shows warnings only (storage-driver.ts `isDev ? { autoMigrate: 'safe' } : {}`; serve.ts telemetry `isDev ? 'safe' : undefined`)", + "verify": "the keyless dev boot shows the auto-reconciled line (injection worked); a non-dev serve boot over the same staged drift shows warnings only (packages/runtime/src/dev-auto-migrate.ts devAutoMigrateConfig: `dev && DEV_AUTO_MIGRATE_DRIVERS.has(driver) ? { autoMigrate: 'safe' } : {}`, spread by standalone-stack.ts, storage-driver.ts resolveStorageDefinition and telemetry-datasource.ts — never an inline isDev branch of their own)", "evidence": "the paired keyless boot logs" } ], @@ -1743,13 +1768,14 @@ "packages/spec/src/data/driver/common.zod.ts#SqlAutoMigrateSchema (SqlAutoMigrateSchema z.enum(['off','safe']) — 'Dev-only, loosen-only schema self-heal (#2186) … force-disabled under NODE_ENV=production') + sqlite.zod.ts (the authored key; the wasm not-honoured guidance)", "packages/drivers/driver-sql/src/sql-driver.ts#SqlDriverConfig (SqlDriverConfig.autoMigrate docblock: non-destructive alters only), (default 'off'), (reconcileAndWarnDrift — the managed+safe gate, the production ignore warning verbatim, safe-only applyMigrationEntries allowDestructive:false, per-op auto-reconciled info line, post-reconcile re-detect, warn-once loop)", "packages/drivers/driver-sql/src/schema-drift.ts#tightenNullSafeOnly (tightenNullSafeOnly: clean probe → safe, duplicates → blocked with a row report, old index left in place; ADR-0120 D4)", - "packages/cli/src/utils/storage-driver.ts#isDev (the ONE dev injection point for the fallback default datasource: isDev ? { autoMigrate: 'safe' } : {}) + packages/cli/src/commands/serve.ts#isDev (telemetry datasource: isDev ? 'safe' : undefined)", + "packages/runtime/src/dev-auto-migrate.ts#devAutoMigrateConfig (the ONE dev self-heal decision since #21733: a dev boot and a driver whose contract declares autoMigrate) + packages/runtime/src/standalone-stack.ts#createStandaloneStack (its default datasource reads it) + packages/cli/src/utils/storage-driver.ts#resolveStorageDefinition (the config-load fallback spreads it per kind) + packages/cli/src/utils/telemetry-datasource.ts#provisionTelemetryDatasource (the telemetry sibling, never self-healed on a one-shot boot)", "packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts#driver + sql-driver-unique-tenancy.test.ts + sql-driver-index-drift.test.ts,322-342 (the pins: off-warns / safe-applies / clean-probe tighten / duplicates block)", "sibling items: cli.dev-boot-contract (the boot this rides), cli.migrate-plan-apply-json (the deliberate apply path — the ONLY door for destructive diffs; cross-referenced, not re-proven)" ], "history": [ { "revision": 1, "date": "2026-08-30", "change": "new item (sweep 2026-08-30, angle 2): the autoMigrate off|safe policy had no coverage although it is the one path that mutates a dev database at boot. Grounded verify-first, correcting the register's sketch on two points: (1) 'safe' is the LOOSEN-only subset (relax NOT NULL, widen varchar, index ops, clean-probe tighten) — not 'additive' diffs; (2) the register's sql-driver.ts/4652-4654 cites the config/field declarations, but the enforcement lives in reconcileAndWarnDrift with the managed-mode gate, the production force-off warning, and allowDestructive:false — all now pinned as clauses. The dev-injection arm (storage-driver.ts / serve.ts, 'safe' only when isDev) was found while grounding and added as its own clause so a keyless dev boot's self-heal is not misread as a driver default. enumSource pins SqlAutoMigrateSchema expect 2", "ref": "#sweep-2026-08-30" }, - { "revision": 2, "date": "2026-10-17", "change": "fixture and steps re-pointed (checklist-accuracy finding, #23878 close-out): a config-declared datasource named `default` is refused by defineStack, `required: true` no longer makes a NOT NULL column (storage.notNull, ADR-0113), and relax_not_null is needs_confirm, so the step-0 drift the safe leg relied on was never auto-appliable. The fixture now names a datasource routed by datasourceMapping default:true; step 0 stages one drift per category (storage.notNull relax = needs_confirm, a planted legacy platform-wide unique index = safe, a removed field = destructive); the steps read a schema dump instead of `os migrate plan`, which composes no driver for a config-declared primary and says its plan is PARTIAL; the safe variant lists the real safe ops; clauses 0, 1 and 3's verify follow. No clause's ruling changed. Re-run on main c91af8ed6 on a minimal scratch stack: datasource named default → build exit 2 with the reserved-name refusal; qa_main + mapping built. off and omitted boots each warned all three drifts and left the dump byte-identical; `os migrate plan` (also with --database-url) reported 'Examined 0 managed table(s)' and a PARTIAL plan. safe boot: '[schema-drift] auto-reconciled replace_unique_index on qa_thing.code', the legacy qa_thing_code_unique gone from the dump, the relax and orphan warnings still printed; the second safe boot printed no reconcile line. NODE_ENV=production with safe: the named ignore warning verbatim, all three warnings, dump unchanged. Keyless os dev over a planted legacy index: auto-reconciled replace_unique_index; non-dev os start over a re-planted one: the warning only, the index kept. Duplicates leg on the pin: driver-sql sql-driver-unique-tenancy.test.ts (incl. 'auto-tightens at boot under autoMigrate: safe when the probe is clean' and 'BLOCKS the tightening on duplicate NULL-organization rows') + sql-driver-schema-drift.test.ts, 38 passed", "ref": "#24036" } + { "revision": 2, "date": "2026-10-17", "change": "fixture and steps re-pointed (checklist-accuracy finding, #23878 close-out): a config-declared datasource named `default` is refused by defineStack, `required: true` no longer makes a NOT NULL column (storage.notNull, ADR-0113), and relax_not_null is needs_confirm, so the step-0 drift the safe leg relied on was never auto-appliable. The fixture now names a datasource routed by datasourceMapping default:true; step 0 stages one drift per category (storage.notNull relax = needs_confirm, a planted legacy platform-wide unique index = safe, a removed field = destructive); the steps read a schema dump instead of `os migrate plan`, which composes no driver for a config-declared primary and says its plan is PARTIAL; the safe variant lists the real safe ops; clauses 0, 1 and 3's verify follow. No clause's ruling changed. Re-run on main c91af8ed6 on a minimal scratch stack: datasource named default → build exit 2 with the reserved-name refusal; qa_main + mapping built. off and omitted boots each warned all three drifts and left the dump byte-identical; `os migrate plan` (also with --database-url) reported 'Examined 0 managed table(s)' and a PARTIAL plan. safe boot: '[schema-drift] auto-reconciled replace_unique_index on qa_thing.code', the legacy qa_thing_code_unique gone from the dump, the relax and orphan warnings still printed; the second safe boot printed no reconcile line. NODE_ENV=production with safe: the named ignore warning verbatim, all three warnings, dump unchanged. Keyless os dev over a planted legacy index: auto-reconciled replace_unique_index; non-dev os start over a re-planted one: the warning only, the index kept. Duplicates leg on the pin: driver-sql sql-driver-unique-tenancy.test.ts (incl. 'auto-tightens at boot under autoMigrate: safe when the probe is clean' and 'BLOCKS the tightening on duplicate NULL-organization rows') + sql-driver-schema-drift.test.ts, 38 passed", "ref": "#24036" }, + { "revision": 3, "date": "2026-10-19", "change": "steps[4], acceptance[5] (clause, verify) and source[3] re-pointed to the injection site that exists — checklist-accuracy finding of the 18.0 QA sweep (QA run #24551, cli.dev-automigrate-policy acceptance[5] / steps[4]; wave anchor #24579 §3): the cited storage-driver.ts `isDev ? { autoMigrate: 'safe' } : {}` and serve.ts telemetry branch moved. Re-derived at base 9a7838edf5: since #21733 the decision lives in packages/runtime/src/dev-auto-migrate.ts (devAutoMigrateConfig), read by createStandaloneStack's default datasource, resolveStorageDefinition's fallback and the telemetry sibling. The run found the behaviour matching the clause; the clause now names the one decision and the three readers", "ref": "#24649" } ] }, { @@ -1757,7 +1783,7 @@ "title": "`os migrate meta` on v18: `--from 17 --write` rewrites the provable mechanical edits in place (byte-preserving otherwise), lists what it cannot write with a reason and restores + exits 1 when a re-run disagrees; `--stored` lists a TODO per refused stored shape; and provably-absent semantic notices collapse into one line that `--all` expands", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "cli", "personas": [ @@ -1767,13 +1793,13 @@ "app": "showcase", "requires": [ "a SCRATCH copy of examples/app-showcase (or a scaffolded blank app) for every planted-metadata step — never edit the shared fixtures; a file DB under /tmp/RUN/ for every boot", - "a scratch app carrying v17 spellings (e.g. an element with a flat object/filter data-binding, an object-grid defaultFilters)", + "a scratch app carrying spellings 17.x accepted that the 17 → 18 step converts — e.g. an element with a flat object/filter data-binding, an object-grid defaultFilters, a dashboard refreshInterval (packages/spec/src/migrations/entries/{retired-keys,semantic}/18.*). ⛔ Not a key retired IN 17.0, such as a view's `striped`: its conversion is the 16 → 17 step, which `--from 17` does not replay, so it stays a schema refusal and --write has nothing to write", "a scratch DB with a stored refused shape for the --stored leg" ] }, "steps": [ "checksum the sources; `os migrate meta --from 17` (report only); checksum again", - "`os migrate meta --from 17 --write --json`; read write.manual[]; diff the sources; re-run (idempotent)", + "`os migrate meta --from 17 --write --json`; read write.manual[]; diff the sources — on a showcase copy expect, beside the planted sites, the three edge-branched decisions the stock tree carries without `mode` (flow-decision-mode-inclusive-explicit, src/automation/flows/index.ts), which --write writes too; re-run (idempotent)", "`os migrate meta --from 17` vs `--all` on a stack without jobs/agents", "`os migrate meta --stored --json` against the scratch DB" ], @@ -1827,6 +1853,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it; cli.migrate-meta-codemod still asserts that no --write exists (its restart condition has fired) — reported, not edited here", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "fixtures.requires[1] and steps[1] say which spellings --from 17 converts — checklist-accuracy findings of the 18.0 QA sweep (QA runs #24551 and #24558, cli.migrate-meta-write-stored-and-all; wave anchor #24579 §3): both runs first planted a view `striped`, which retired in 17.0 (entries/retired-keys/17.ui__ListView__striped.ts) and is not replayed by a 17 → 18 run, and a --write over an unplanted showcase copy also edits the stock tree's three mode-less decisions. Re-derived at base 9a7838edf5. No clause changed", + "ref": "#24649" } ] }, @@ -1848,7 +1880,7 @@ "a COPY of a v17-shaped SQLite DB (platform-core.v18-boot-refusals names how to get one) — never the original" ], "knownGaps": [ - "WITHDRAWN IN 18.0 and deliberately not scored here: the ceremony's own arms — the plan file a bare run writes, its plan-file-required / plan-*-mismatch / database-busy refusals, the sys_migration adr-0131-organization-ownership checkpoint a reviewed apply writes, and a migrated copy booting under v18 — are not clauses of this revision. QA run #24551: \"ruling records 6259608309 (item 1) and 6259698357 then cut 18.0 to fresh installs on the single posture, carried by .changeset/pre/15212-180-guards-fresh-single-only.md, the ADR-0131 release-scope note and organization-ownership.ts …\"; the registered command (packages/cli/src/commands/migrate/organization-ownership.ts#MigrateOrganizationOwnership) refuses before the ceremony runs. A runner scores none of these arms — neither `fail` (QA run #24551's reading of revision 1) nor `skipped` (QA run #24556's, c1/c2/c4): they are not on the item. The 18.x release that ships the upgrade path restores them as clauses in the revision that re-registers the ceremony; their revision-1 wording is in this file's git history", + "WITHDRAWN IN 18.0 and deliberately not scored here: the ceremony's own arms — the plan file a bare run writes, its plan-file-required / plan-*-mismatch / database-busy refusals, the sys_migration adr-0131-organization-ownership checkpoint a reviewed apply writes, and a migrated copy booting under v18 — are not clauses of this revision. QA run #24551: \"ruling records 6259608309 (item 1) and 6259698357 then cut 18.0 to fresh installs on the single posture\" — carried, the record goes on, by the changeset 15212-180-guards-fresh-single-only (consumed into 18.0.0; source lists it with the others), the ADR-0131 release-scope note and organization-ownership.ts; the registered command (packages/cli/src/commands/migrate/organization-ownership.ts#MigrateOrganizationOwnership) refuses before the ceremony runs. A runner scores none of these arms — neither `fail` (QA run #24551's reading of revision 1) nor `skipped` (QA run #24556's, c1/c2/c4): they are not on the item. The 18.x release that ships the upgrade path restores them as clauses in the revision that re-registers the ceremony; their revision-1 wording is in this file's git history", "acceptance[3] scores the drop gate's exit code, `message` and `tables`, not its prose: that prose still prescribes `os migrate organization-ownership --out PLAN` and `--apply --plan-file PLAN`, which 18.0 refuses — a product defect tracked on #24567, not a fail of this clause" ] }, @@ -1922,7 +1954,7 @@ "title": "CLI authoring commands on v18: `os meta delete|register --package ID` address one package's row (an empty --package is refused before send), `os meta register --force` passes a 409 DESTRUCTIVE_CHANGE, a no-op delete prints \"Nothing deleted\", and `os explain RULE_ID` explains a lint rule (unknown id exits 1)", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "cli", "personas": [ @@ -1933,11 +1965,12 @@ "app": "showcase", "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", - "the CLI pointed at the fresh boot (OS_URL / --url and a login token)" + "the CLI pointed at the fresh boot (OS_URL / --url and a login token)", + "a WRITABLE package for the positive package-addressed save — install one with POST /api/v1/packages. The stock code package com.example.showcase is read-only: a create addressed to it answers 422 WRITABLE_PACKAGE_REQUIRED (ADR-0070 D1), which still shows ?package= was sent but is no save" ] }, "steps": [ - "`os meta register view --data qa_view.json --package com.example.showcase`; `os meta delete view qa_view --package \"\"`", + "`os meta register view --data qa_view.json --package PKG` with PKG the writable package, the body a ViewItem record named `.` (e.g. showcase_project.qa_view — a standalone ViewItem's name must be dotted, 422 invalid_format otherwise, and a container named qa_view on showcase_project collides with that object's own container); then `os meta delete view showcase_project.qa_view --package \"\"`", "`os meta register object --data destructive.json` then with `--force`", "`os meta delete view qa_absent_view`", "`os explain field-no-consumers`, `os explain field-no-consumers --json`, `os explain`, `os explain no_such_rule`" @@ -1983,6 +2016,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "fixtures.requires and steps[0] name a save the server can accept — checklist-accuracy findings of the 18.0 QA sweep (QA runs #24551 and #24558, cli.meta-package-flags-and-explain step 1; wave anchor #24579 §3): `--package com.example.showcase` answers 422 WRITABLE_PACKAGE_REQUIRED on the read-only code package, and a view named qa_view is refused by the item-name grammar. Re-derived at base 9a7838edf5: ADR-0070 D1's readOnlyBaseCreateError (metadata-protocol) and the name grammar table in packages/spec/src/ui/view.zod.ts. No clause changed", + "ref": "#24649" } ] }, @@ -2053,7 +2092,7 @@ "title": "`os verify` gains five derived proof families on v18 — --reference-shapes, --delete-behaviors, --unique-scopes, --parent-write-reach, --dashboard-windows — each clean (exit 0) on the showcase with its report in --json, and non-zero on a planted defect", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "cli", "personas": [ @@ -2067,7 +2106,7 @@ }, "steps": [ "in examples/app-showcase run `os verify --reference-shapes --json` and each of the other four flags", - "in the scratch copy plant a wrong deleteBehavior and rerun --delete-behaviors" + "in the scratch copy plant a declaration the runtime CONTRADICTS — a `beforeDelete` hook on the parent that detaches the children a `deleteBehavior: 'cascade'` lookup should delete (the pin's negative control, packages/cli/test/verify-delete-behaviors.test.ts) — and rerun --delete-behaviors. Changing a lookup's deleteBehavior alone is no defect to this family: the engine enforces whatever is declared, so the run stays clean" ], "acceptance": [ { @@ -2083,9 +2122,9 @@ "evidence": "five lines" }, { - "clause": "the planted defect makes --delete-behaviors exit non-zero naming it", + "clause": "the planted contradiction (a hook that undoes a declared cascade) makes --delete-behaviors exit non-zero naming the contradicted lookup", "oracle": "build", - "verify": "exit code + output", + "verify": "exit code 1 + the --json report: a deleteBehaviors.results entry for that object + field reads status 'contradicted', and deleteBehaviors.summary counts it (contradicted 1, hardFailures 1)", "evidence": "excerpt" } ], @@ -2105,6 +2144,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "steps[1] and acceptance[2] (clause, verify) name a plant the family can catch — checklist-accuracy finding of the 18.0 QA sweep (QA run #24558, cli.verify-derived-proof-families step 2; wave anchor #24579 §3): a wrong deleteBehavior alone is enforced as declared, so it verifies clean. Re-derived at base 9a7838edf5 from the pin's own negative control (packages/cli/test/verify-delete-behaviors.test.ts 'NEGATIVE CONTROL: reports a cascade a hook undid, counts it, and exits 1'): the defect is a beforeDelete hook detaching the children of a declared cascade", + "ref": "#24649" } ] } diff --git a/docs/qa/platform-checklist/areas/i18n.json b/docs/qa/platform-checklist/areas/i18n.json index 54db868075d..a435d472a5a 100644 --- a/docs/qa/platform-checklist/areas/i18n.json +++ b/docs/qa/platform-checklist/areas/i18n.json @@ -177,7 +177,7 @@ "title": "Every translatable surface localizes on a zh-CN session — one pass over the full translation-group vocabulary, plus the runtime i18n routes it resolves through", "since": "v15", "status": "active", - "revision": 8, + "revision": 9, "priority": "P1", "surface": "browser", "personas": ["admin on a zh-CN session"], @@ -231,7 +231,7 @@ { "clause": "fallback is honest: an untranslated declared string renders its SOURCE-language label — never a raw dotted key path, never an empty cell", "oracle": "screenshot", - "verify": "spot-check at least two known-untranslated keys (from the coverage baseline debt); each shows the en label, no 'objects.x.fields.y.label' literals anywhere in the session", + "verify": "spot-check at least two known-untranslated keys — derive them as keys the showcase declares but the served zh tree lacks: scripts/i18n-coverage-baseline.json records only a COUNT per config (414 for examples/app-showcase at base 9a7838edf5), never a key list; each shows the en label, no 'objects.x.fields.y.label' literals anywhere in the session", "evidence": "the fallback screenshots" }, { @@ -331,7 +331,8 @@ { "revision": 5, "date": "2026-10-05", "change": "the switch step now names HOW to switch: through the avatar-menu language switcher, which writes the user's locale. Seeding browser storage alone is not a language switch. The 17.7 console run held the relative-dates variant of acceptance[0] as a FAIL, and its re-run on #21782 settled it as a harness gap: the first lane had set the UI language only in browser storage, so the user's stored locale stayed empty and dates followed the browser. Through the switcher the variant passed, so the clauses themselves are unchanged (card #21851 entry 11, filed with the follow-up run #21845)", "ref": "#21782" }, { "revision": 6, "date": "2026-10-06", "change": "partial-coverage gap of QA run #21782 parked on #22017 (row 8). A6's confirm half was blocked(fixture): no zh confirmText. Measured: no showcase action declares a confirmText at all, so there is no live specimen. Disposition: pin. The objectui tests page-header-action-i18n and record-quick-actions.actionText-i18n prove that the confirm dialog body resolves from the `_actions` zh-CN entry through the real ActionRunner. `automated` names them for that half only, and a knownGap records why the half is pin-only on stock", "ref": "#22017" }, { "revision": 7, "date": "2026-10-13", "change": "knownGaps[2], steps[7], variants[6] and the showcase source note re-pointed (knownGap stale, #23163; parked on #23121, carried by #23169). task-detail.page.ts's record:details block has named its sections overview / schedule / details since #22036, so the task record page renders them translated; QA run #23163 read zh headings on the New form, the task record page and the contact form under zh-CN. The step used to name the tabbed / wizard / split formViews, but those are reachable from no console door, and `…/view/tabbed` renders the grid. The negative that keeps a nameless section out of resolver FAILs stands", "ref": "#23169" }, - { "revision": 8, "date": "2026-10-16", "change": "variants[5], acceptance[7].clause, steps[8] and [9], source[0] and automated.ref corrected (checklist-accuracy note 4 and the fixture gaps of QA run #23951, wave #23878). variants[5] still marked the view-empty-state variant blocked(fixture), contradicting knownGaps[1] and steps[6] — it ran and passed. acceptance[7] counted the served tree as an eleven-group platform face over a ten-group per-app one; the spec declares eleven per-app groups (appTranslationDataShape, picklists included — source[0]'s list had dropped it) and twelve platform groups (plus settings). The dashboard and page steps now name a translated specimen each, because no showcase page carries a translated title (the run scored pages on platform page connect_agent). automated.ref's revision note moves to the current pin. Wording and specimen names only: no request or oracle changed", "ref": "#24036" } + { "revision": 8, "date": "2026-10-16", "change": "variants[5], acceptance[7].clause, steps[8] and [9], source[0] and automated.ref corrected (checklist-accuracy note 4 and the fixture gaps of QA run #23951, wave #23878). variants[5] still marked the view-empty-state variant blocked(fixture), contradicting knownGaps[1] and steps[6] — it ran and passed. acceptance[7] counted the served tree as an eleven-group platform face over a ten-group per-app one; the spec declares eleven per-app groups (appTranslationDataShape, picklists included — source[0]'s list had dropped it) and twelve platform groups (plus settings). The dashboard and page steps now name a translated specimen each, because no showcase page carries a translated title (the run scored pages on platform page connect_agent). automated.ref's revision note moves to the current pin. Wording and specimen names only: no request or oracle changed", "ref": "#24036" }, + { "revision": 9, "date": "2026-10-19", "change": "acceptance[2].verify says how to find an untranslated key — checklist-accuracy finding of the 18.0 QA sweep (QA run #24553, i18n.surface-matrix; wave anchor #24579 §3): the coverage baseline debt is a count, not a key list. Re-derived at base 9a7838edf5 (scripts/i18n-coverage-baseline.json maps each config path to a number). No clause changed", "ref": "#24649" } ] }, { @@ -500,7 +501,7 @@ "title": "Flow copy is translatable on v18: a flow refusal message and a paused screen's title/description resolve in the caller's Accept-Language with holes filled, `os i18n extract` scaffolds the refusal / screen / option keys, a translated screen title is judged by the {{ }} text-slot rule, and a host translation bundle overrides a platform key in either load order", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "mixed", "personas": [ @@ -512,13 +513,13 @@ "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", "a SCRATCH copy of examples/app-showcase (or a scaffolded blank app) for every planted-metadata step — never edit the shared fixtures; a file DB under /tmp/RUN/ for every boot", - "a showcase (or scratch) flow with an end-node refusal message and a screen, plus a zh-CN translation bundle carrying their keys" + "a SCRATCH flow with an end-node refusal message and a screen with an authored title, plus a zh-CN translation bundle carrying their keys — the showcase carries neither specimen: no showcase flow ends on a refusing end node, and showcase_reassign_wizard's zh screen title exists in its bundle while the authored screen has no title (QA runs #24553 and #24558)" ] }, "steps": [ "POST /api/v1/automation/FLOW/trigger with Accept-Language: zh-CN and read the refusal message", "start the screen flow with Accept-Language: zh-CN and read the paused screen title", - "in the scratch app run `os i18n extract` and inspect the scaffolded keys; add a translated screen title \"Hi {name}\" and run `os validate`", + "in the scratch app run `os i18n extract --no-objects-only` and inspect the scaffolded keys — the default `--objects-only` emits only the objects/globalActions subtree, so a bare `os i18n extract` writes NO flow key and clause 2 would read as missing scaffolding; add a translated screen title \"Hi {name}\" and run `os validate`", "in the scratch app give the host bundle a key the platform also ships (e.g. a sys_user label) and boot; GET /api/v1/meta/object/sys_user with Accept-Language for that locale" ], "acceptance": [ @@ -549,7 +550,7 @@ { "clause": "the host bundle's value wins for the overlapping platform key", "oracle": "api", - "verify": "label in the served definition", + "verify": "label in the served definition. One boot exercises ONE load order: the title's 'either load order' is scored live for the order the boot took and from packages/platform-objects/src/plugin.translations.test.ts for both ('a host bundle loaded BEFORE the platform bundles serves the host label' / 'a host bundle loaded AFTER kernel:ready also serves the host label', passing at base 9a7838edf5)", "evidence": "the excerpt" } ], @@ -569,6 +570,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-19", + "change": "fixtures.requires[2], steps[2] and acceptance[4].verify corrected — checklist-accuracy findings of the 18.0 QA sweep (QA runs #24553 and #24558, i18n.flow-copy-translation-and-host-override; wave anchor #24579 §3). `os i18n extract` defaults to --objects-only and emits no flow keys (packages/cli/src/commands/i18n/extract.ts, objects-only default true, at base 9a7838edf5); the showcase ships no refusing end node and no titled screen, so both specimens are scratch; and one boot cannot drive both load orders, which the platform-objects pin covers — measured at base 9a7838edf5 under os-verify-lock (10 passed). No clause changed", + "ref": "#24649" } ] } diff --git a/docs/qa/platform-checklist/areas/ux-conventions.json b/docs/qa/platform-checklist/areas/ux-conventions.json index 5ac64ea311b..4cfc2688f44 100644 --- a/docs/qa/platform-checklist/areas/ux-conventions.json +++ b/docs/qa/platform-checklist/areas/ux-conventions.json @@ -9,7 +9,7 @@ "title": "Every user-initiated write answers visibly — success names what changed, a refusal says why, and a no-op claims nothing", "since": "v17.7", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "browser", "personas": [ @@ -25,7 +25,7 @@ ], "knownGaps": [ "FIXED, regression row, expected PASS (objectui#11817): Setup → Users → Invite User toasts 'Invitation sent to EMAIL' — the declared successMessage 'Invitation sent to ${result.email}' (packages/platform-objects/src/identity/sys-user.object.ts#invite_user) interpolating the invitee from the action result — while the sys_invitation and sys_email rows are written (QA run #23163 at objectui 1fb1ced36243; pin ObjectView.inviteFeedback-11817.test.tsx)", - "FIXED, regression rows, expected PASS (QA run #23953 at objectui daeb77fc84dd): Studio Publish gives a success confirmation (objectui#11807); the task grid's 'Save All' toasts '1 row saved' (objectui#12303 fixed for this row); the approvals drawer's Approve toasts the framework-declared successMessage 'Approved \"${result.request.record_title}\"' (packages/plugins/plugin-approvals/src/sys-approval-request.object.ts), e.g. Approved \"EXP-2001\" (#23157, closed). Studio's 'Discard draft' now gives feedback too, but the toast and its confirm name the raw type/name — 'Draft discarded: object/NAME' (objectui ResourceEditPage.tsx, key engine.edit.draftDiscarded, unchanged at f8267df93aba) — while the item has a label: expected FAIL on acceptance[1] for that row, filed as objectstack-ai/objectui#12690 and not to be re-filed", + "FIXED, regression rows, expected PASS (QA run #23953 at objectui daeb77fc84dd): Studio Publish gives a success confirmation (objectui#11807); the task grid's 'Save All' toasts '1 row saved' (objectui#12303 fixed for this row); the approvals drawer's Approve toasts the framework-declared successMessage 'Approved \"${result.request.record_title}\"' (packages/plugins/plugin-approvals/src/sys-approval-request.object.ts), e.g. Approved \"EXP-2001\" (#23157, closed). Studio's 'Discard draft' toast and its confirm now name the item by its LABEL — QA run #24570 at objectui 4016370a read 'Draft discarded: QA Widget' — a regression row, expected PASS (objectstack-ai/objectui#12690, closed by objectui PR #12830, 0f42aaf8, in the console pin 4016370a and on objectui main; ResourceEditPage.tsx falls back to type/name only for an item with no label)", "FIXED, regression row, expected PASS (objectui#11816): leaving an inline editor with no value change no longer reads '1 row modified · Save All (1)' (QA run #23163 at objectui 1fb1ced36243; pin data-table-noop-edit-11816.test.tsx) — negative[1]", "Undo is scored only where a surface offers it, and on stock NONE does: at objectui 1fb1ced36243 only an action declaring `undoable` pushes an Undo op (inline and form edits push none), and the showcase declares no undoable action (records-forms.record-edit-undo, blocked(fixture)). So acceptance[5] scores blocked(fixture) on stock (QA run #23163 A5). Its absence elsewhere is recorded in the evidence and is never a FAIL of this item: which writes should offer Undo is a product decision, not this item's oracle" ] @@ -48,8 +48,8 @@ "sign in as admin; keep a second context (or the REST API) open to read server truth independently of the UI", "for each variant perform the write once, then within ~2 s capture a screenshot and the DOM of every feedback region — toasts ([data-sonner-toast]), role=status / role=alert regions, and the surface's own save indicator — recording the verbatim text, or its absence", "for each variant read server-side whether the write landed (GET the record, the metadata item or the sys_ row) and record the request's status from the network log", - "repeat the record rows signed in as the member persona, on member-owned records", - "negative pass: (a) submit the create form with a required field empty, then again with a value that breaks a validation rule; (b) open an inline editor and leave it without changing the value", + "repeat the record rows signed in as the member persona, on member-owned records — the stock member's set (showcase_member_default) grants showcase_task read + create only, so the member's create row runs while its inline-edit, bulk-delete and kanban rows answer 403 PERMISSION_DENIED: record those as refused writes (their error feedback is still clause 3's to score), or bind the member to the contributor position for the edit rows (showcase_contributor grants task edit, not delete)", + "negative pass: (a) submit the create form with a required field empty; then break a validation rule — showcase_task's only rule is the UPDATE-time state machine task_status_flow (no create-time rule exists), so drive it as a refused transition, e.g. a kanban move Backlog → Done; (b) open an inline editor and leave it without changing the value", "where a variant offers Undo, click it and read the record back — on stock none does (knownGaps[3]), so record acceptance[5] blocked(fixture)" ], "acceptance": [ @@ -123,6 +123,12 @@ "date": "2026-10-16", "change": "knownGaps[1], acceptance[3].verify and variants[1] and [6] corrected (checklist-accuracy notes 1–3 of QA run #23953, wave #23878). knownGaps[1] was stale: Save All toasts '1 row saved', Discard draft gives feedback and Approve toasts the declared 'Approved \"RECORD\"'; the Discard toast names the raw type/name, which is the open objectui#12690, so that row is an expected FAIL on clause 1 rather than 'no feedback'. acceptance[3].verify admitted only a 4xx, but the console refuses a required-field-empty submit in the browser and sends no request — a refusal that still says why, at the field — so the oracle now names both shapes and records which occurred. Not loosened: a 2xx write, a reasonless refusal or any success feedback still FAILS. The inline-edit and approval variants named surfaces that do not exist as written (no 'Edit inline' button; the inbox link opens the review queue)", "ref": "#24036" + }, + { + "revision": 4, + "date": "2026-10-19", + "change": "knownGaps[1], steps[3] and steps[4] corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24570, ux-conventions.write-feedback; wave anchor #24579 §3). The Studio Discard row no longer reproduces as an expected FAIL: objectstack-ai/objectui#12690 is closed (objectui PR #12830, merge 0f42aaf8, an ancestor of the console pin 4016370a and of objectui main, measured by merge-base --is-ancestor exit 0), and the run read the label. showcase_task carries no create-time validation rule (examples/app-showcase/src/data/objects/task.object.ts: one state_machine on update), and the stock member cannot update or delete tasks (security/permission-sets.ts at base 9a7838edf5). No clause changed", + "ref": "#24649" } ] }, @@ -131,7 +137,7 @@ "title": "Every disabled control explains itself — the reason shows on hover and on keyboard focus, and is exposed as an accessible description", "since": "v17.7", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "browser", "personas": [ @@ -146,7 +152,7 @@ ], "knownGaps": [ "FIXED, now the calibration reference (objectui#11811): the task record's Archive button (examples/app-showcase/src/ui/actions/index.ts#ArchiveTaskAction, disabled while has(record.done) && record.done != true) carries its reason as a tooltip AND an aria-describedby on a focusable wrapper, in both its record_header and record_section placements (objectui DeclaredActionsBar.tsx; pin DeclaredActionsBar.disabledReason-11811.test.tsx). The action spec has no author-facing reason next to `disabled`, so the reason is the generic platform fallback — record it as generic under clause 3. Check this control first to calibrate both the hover and the keyboard oracle", - "Studio's Publish button on the read-only showcase package and the import wizard's disabled Next are FIXED for the keyboard (QA run #23953 at objectui daeb77fc84dd): each carries its reason on a focusable wrapper with an aria-describedby, so both now calibrate hover AND keyboard, expected PASS; Studio's automation Enabled toggle and the field inspector's Remove pass the same way. Expected FAIL on stock, not to be re-filed: the field inspector's read-only Label / API name / Type / Required / Move up-down show the panel tooltip on hover but are not in the tab order and carry no description (objectstack-ai/objectui#12303); and the Studio validation editor's controls on a read-only package (Type, Severity, rule name, label, message, status field, transitions, Runs on, Priority, Enabled) give no reason on hover or by keyboard (objectstack-ai/objectui#12691)" + "Studio's Publish button on the read-only showcase package and the import wizard's disabled Next are FIXED for the keyboard (QA run #23953 at objectui daeb77fc84dd): each carries its reason on a focusable wrapper with an aria-describedby, so both now calibrate hover AND keyboard, expected PASS; Studio's automation Enabled toggle and the field inspector's Remove pass the same way. Regression rows, expected PASS since QA run #24570 (objectui 4016370a, two fresh loads each): the field inspector's read-only Label / API name / Type / Required / Move up-down sit in a focusable panel wrapper described by the read-only reason, with the tooltip on focus (objectstack-ai/objectui#12303, closed by objectui PR #12324, b64fda3f — the rail body described by fieldRailReasonId, StudioDesignSurface.tsx); and the Studio validation editor's body on a read-only package is a role=group tab stop described by the reason, with the tooltip on hover and focus (objectstack-ai/objectui#12691, closed by objectui PR #12833, d0a00d79, ObjectValidationsPanel.tsx). Both fixes are in the console pin 4016370a and on objectui main" ] }, "variants": [ @@ -213,6 +219,12 @@ "date": "2026-10-16", "change": "knownGaps[1] corrected (checklist-accuracy note 4 of QA run #23953, wave #23878). It said Studio Publish calibrates hover only and that Publish and the import wizard's Next carry reasons a keyboard cannot reach; at objectui daeb77fc84dd both are keyboard-reachable through a focusable wrapper with an aria-describedby, so they calibrate both oracles. The expected-FAIL list keeps the field inspector's read-only inputs (objectui#12303) and adds the validation editor's read-only controls that #23953 extracted as objectui#12691. Wording only: no step, request or oracle changed", "ref": "#24036" + }, + { + "revision": 4, + "date": "2026-10-19", + "change": "knownGaps[1]'s two expected FAILs re-pointed to regression rows — checklist-accuracy finding of the 18.0 QA sweep (QA run #24570, ux-conventions.disabled-control-reason knownGaps[1]; wave anchor #24579 §3): objectstack-ai/objectui#12303 and #12691 no longer reproduce at 4016370a. Read on objectui: both issues are closed completed, by PR #12324 (merge b64fda3f) and PR #12833 (merge d0a00d79); each merge is an ancestor of the console pin 4016370a and of objectui main (merge-base --is-ancestor exit 0), and the wrapper code is present on main. No clause changed", + "ref": "#24649" } ] }, @@ -221,7 +233,7 @@ "title": "Where a label exists users see the label — machine names, raw ids and spec paths appear only as secondary text", "since": "v17.7", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "browser", "personas": [ @@ -235,7 +247,7 @@ "read access to the served metadata of every sampled surface — labels are read from metadata, never assumed" ], "knownGaps": [ - "objectui#11862 is FIXED for the rows that still failed at 1fb1ced36243 (QA run #23953 at objectui daeb77fc84dd): the Access permission-set list, the Changes panel and the Lookup inspector's Display-field picker now lead with the label, as do Explain access's principal line (pin AccessExplainPanel.principalPerson-11862.test.tsx) and the nav editor's items — regression rows, expected PASS. Explain's server-built Principal resolution prose names the person too — 'Principal NAME (EMAIL; id USER_ID)' (packages/plugins/plugin-security/src/explain-engine.ts; #23157, closed). Expected FAIL on stock, not to be re-filed (objectstack-ai/objectui#12693): (a) Explain access's principal card renders its position and permission-set chips by machine name (org_member, showcase_member_default) and the result banner names the object by its name, while labels exist; (b) Studio Interfaces' list-view 'Columns' chips render field api names (title, owner_ref)", + "objectui#11862 is FIXED for the rows that still failed at 1fb1ced36243 (QA run #23953 at objectui daeb77fc84dd): the Access permission-set list, the Changes panel and the Lookup inspector's Display-field picker now lead with the label, as do Explain access's principal line (pin AccessExplainPanel.principalPerson-11862.test.tsx) and the nav editor's items — regression rows, expected PASS. Explain's server-built Principal resolution prose names the person too — 'Principal NAME (EMAIL; id USER_ID)' (packages/plugins/plugin-security/src/explain-engine.ts; #23157, closed). Regression rows, expected PASS since QA run #24570 (objectui 4016370a, two fresh loads each): (a) Explain access's principal card draws its position and permission-set chips by label and the result banner names the object by its label; (b) Studio Interfaces' list-view 'Columns' rows draw each field's label, its api name on the row's title (objectstack-ai/objectui#12693, closed by objectui PR #12834, cda4076b, in the console pin 4016370a and on objectui main). ⚠️ Explain's Principal resolution 'Contributed by' chips are a different surface from (a): QA run #24570 found them on machine names at 4016370a and extracted objectstack-ai/objectui#13056 — score them against the console the run serves", "FIXED, regression row, expected PASS (objectui#11866): Profile states the access the console resolves rather than the auth library's raw user.role ('user') (objectui apps/console/src/pages/system/ProfilePage.tsx; pin ProfilePage.access.test.tsx)", "FIXED, regression row, expected PASS (objectui#11778): the flow inspector's Node Type select offers the add-node palette's display names instead of raw snake_case types such as http_request, connector_action and try_catch (pin FlowNodeInspector.nodeTypeOptions-11778.test.tsx); QA run #23163 read the Node Type value as its label", "Regression rows, expected PASS: the audit-log actor and composite field values render as people and labels since objectstack-ai/objectui#11697." @@ -316,6 +328,12 @@ "date": "2026-10-16", "change": "knownGaps[0] and acceptance[3].verify corrected (checklist-accuracy note 5 of QA run #23953, wave #23878). knownGaps[0] was mostly stale: at objectui daeb77fc84dd the permission-set list, the Changes panel and the Display-field picker lead with the label, and the server-built principal prose names the person (#23157, closed), so those are regression rows; the expected FAIL moves to the two surfaces #23953 extracted as objectui#12693. acceptance[3] did not say whether a tracker reference inside an app's own authored label, or an ADR number inside server-built prose, counts — #23953 left both unscored. The verify text now scores by who wrote the text (a platform-built match FAILS; an author-written one is an authoring finding, the negative[0] rule) and records ADR numbers without scoring them, which is how #23953 read them; whether an ADR citation in user-facing server prose should FAIL is left as an open question on #24036. Not loosened: every match the clause scored before still scores", "ref": "#24036" + }, + { + "revision": 4, + "date": "2026-10-19", + "change": "knownGaps[0]'s expected FAILs (a) and (b) re-pointed to regression rows — checklist-accuracy finding of the 18.0 QA sweep (QA run #24570, ux-conventions.labels-over-machine-names knownGaps[0]; wave anchor #24579 §3): objectstack-ai/objectui#12693 (a) and (b) no longer reproduce at 4016370a. Read on objectui: the issue is closed completed by PR #12834 (merge cda4076b), an ancestor of the console pin 4016370a and of objectui main (merge-base --is-ancestor exit 0). The run's new acceptance[0] rows are the product defect it extracted (objectstack-ai/objectui#13056) and stay out of this ledger edit. No clause changed", + "ref": "#24649" } ] }