diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index ca7976c1c6b..597ffaf4b7d 100644 --- a/docs/qa/platform-checklist/areas/ai.json +++ b/docs/qa/platform-checklist/areas/ai.json @@ -202,7 +202,7 @@ "title": "MCP stdio auto-start fails closed without OS_MCP_STDIO_API_KEY; with a member-bound key, reads AND aggregates honor RLS/FLS and revocation bites on the next call", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P0", "surface": "cli", "personas": ["restricted member API key (for the RLS half — on showcase, a contributor-bound member)", "a second restricted member API key for the FLS half (on showcase, a client_liaison-bound member — see fixtures.requires)", "admin API key (for the full-row comparison)"], @@ -226,7 +226,8 @@ "run the same two reads over REST as the same member (Bearer the same key) and as the admin key; compare", "aggregate parity: over MCP stdio as the member, aggregate_records the OWD-private object with a count grouped by a readable field ({ aggregations: [{ function: 'count', alias: 'n' }], groupBy: [''] }); run the SAME aggregate over REST as the member and as the admin; compare the per-group counts", "FLS aggregate fail-closed: over MCP stdio as the member, aggregate_records with an aggregation (or a groupBy) naming the field the member cannot read (the masked field above); capture the refusal and confirm no numeric result is returned", - "revoke the member key while the stdio session is up; issue one more read; capture the refusal", + "revoke the member key while the stdio session is up (as the admin, PATCH /api/v1/data/sys_api_key/ {\"revoked\": true}); issue one more read on the SAME session; capture the tool result and parse its error envelope (code and status, not only the prose)", + "start once more with stdio enabled and the key revoked in the previous step; capture the refusal to start (the lifecycle arm of the up-front admission)", "start once more with stdio NOT enabled and no key set; confirm a clean boot (the key is only demanded when stdio is on)" ], "acceptance": [ @@ -237,10 +238,10 @@ "evidence": "the thrown message / startup log" }, { - "clause": "an unknown/revoked/expired key is rejected up front — 'OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). Refusing to start stdio (ADR-0101).' — never a fall-back to an anonymous-but-serving session", + "clause": "an unknown/revoked/expired key is rejected up front — never a fall-back to an anonymous-but-serving session. A key that resolves to nobody (unknown, owner-less) is refused with 'OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). Refusing to start stdio (ADR-0101).'; a key its own lifecycle refuses (revoked, expired, inactive) is refused naming its code since #23367: '[MCP] OS_MCP_STDIO_API_KEY is refused (API_KEY_REVOKED): … Refusing to start stdio (ADR-0101).', the elided middle being the key's own lifecycle message, whose wording this item does not pin", "oracle": "log", - "verify": "startup with osk_unknown throws /did not resolve to a valid identity/", - "evidence": "the thrown message" + "verify": "startup with osk_unknown throws /did not resolve to a valid identity/; startup with the key revoked in the revocation step throws /is refused \\(API_KEY_REVOKED\\)/ (packages/mcp/src/plugin.ts, the initial admission's isApiKeyLifecycleRefusalError arm); neither process serves", + "evidence": "the two thrown messages" }, { "clause": "a valid key binds the transport to a REAL principal and says so: '[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity (RLS/FLS/tenant applied)'", @@ -267,10 +268,10 @@ "evidence": "the refusal + a control aggregate over a readable field" }, { - "clause": "revocation is honored on the NEXT read (the identity is re-resolved per call), failing with 'MCP stdio identity is no longer valid (key revoked or expired)'", - "oracle": "log", - "verify": "the post-revocation read errors with that message instead of serving stale authority", - "evidence": "the refusal" + "clause": "revocation is honored on the NEXT read (the identity is re-resolved per call) and fails CLOSED with the key's own lifecycle refusal: the post-revocation call returns no row and is a tool ERROR whose envelope carries code API_KEY_REVOKED and status 403. The envelope's message is the key's own lifecycle refusal and its wording is NOT pinned here: the code is what a client branches on, and the lifecycle owner rewords that text (#23944, now that restore_api_key can bring a revoked key back). Since #23367 resolvePrincipal raises that branded ADR-0112 envelope (ApiKeyLifecycleRefusalError) instead of the generic 'MCP stdio identity is no longer valid (key revoked or expired)' error, which now answers only an identity that resolves to nobody at all", + "oracle": "api", + "verify": "on the SAME live stdio session: a query_records before the revoke returns rows (the control that the session was serving); after PATCH /api/v1/data/sys_api_key/ {\"revoked\": true} the next tools/call query_records answers result.isError true, and its content text parses to {error: {code: 'API_KEY_REVOKED', status: 403, message}} with no record in it (packages/mcp/src/plugin.ts resolvePrincipal; the coded tool error is errorResultWithCode in packages/mcp/src/mcp-http-tools.ts). Score the code, the status and the absence of rows, never the message's wording", + "evidence": "the pre-revocation rows, the revoke write, and the post-revocation tool result (isError, code, status, no rows)" }, { "clause": "stdio disabled ⇒ no key demanded: the boot completes and logs '[MCP] Transport not auto-started …' (the HTTP surface is served per-request regardless)", @@ -283,12 +284,15 @@ "an invalid/revoked key falling back to an anonymous-but-serving session is THE fail this item exists for — any served read without a resolved principal is a P0 FAIL", "a member-keyed MCP read returning rows the same member's REST read hides is a FAIL (MCP must not be a side door around RLS)", "an MCP aggregate count that equals the ADMIN's total (i.e. counts rows the same member's REST read hides) is a P0 FAIL — an RLS-bypassing count is the sys_attachment-total-leak class (#2976): a bypass leaks row existence and statistics even when the individual rows stay hidden", - "an aggregate over an FLS-masked field returning a number instead of failing closed is a FAIL — the input gate is the only place the leak is stopped, since output masking cannot see through an alias" + "an aggregate over an FLS-masked field returning a number instead of failing closed is a FAIL — the input gate is the only place the leak is stopped, since output masking cannot see through an alias", + "a post-revocation read that serves any row is a P0 FAIL (stale authority); a post-revocation refusal that carries no API_KEY_REVOKED code is a FAIL of the #23367 contract — a client branches on the code, never on prose" ], "traps": ["wrong-persona"], "automated": { "kind": "unit", "ref": "packages/mcp/src/__tests__/plugin.test.ts" }, "source": [ "packages/mcp/src/plugin.ts (the three refusal strings + per-call re-resolution, ADR-0101)", + "packages/core/src/security/api-key.ts#lifecycleRefusalOf (#23367 — the API_KEY_REVOKED / API_KEY_EXPIRED / API_KEY_INACTIVE refusals, 403, and the located message naming the key's prefix; raised as ApiKeyLifecycleRefusalError out of the stdio door's per-call resolvePrincipal and its up-front admission)", + "packages/mcp/src/mcp-http-tools.ts#errorResultWithCode (the data tools render a thrown coded refusal as {error: {code, message, status}} in an isError tool result)", "packages/mcp/src/__tests__/plugin.test.ts ('stdio principal admission — fail-closed')", "packages/runtime/src/domains/mcp.ts#callData (the aggregate bridge routes through callData('aggregate') → the ObjectQL engine so RLS + the FLS aggregate gate always run; the raw driver is deliberately NOT passed)", "packages/mcp/src/mcp-http-tools.ts#registered (aggregate_records registration: 'Runs under the caller's permissions, row-level security and field-level security'; registered only when the bridge exposes aggregate)", @@ -305,6 +309,12 @@ "date": "2026-10-01", "change": "checklist-accuracy findings of QA run #21056: no single showcase persona carries both RLS-hidden rows and an FLS-masked field on one object, so the item now names two (contributor for RLS, client_liaison for FLS); acceptance[5] no longer expects details.forbiddenFields on the wire, which neither transport serializes; steps[0] names `os start` as the host and says why `os dev` corrupts the stdio channel (its banner goes to stdout — 15 lines measured, against 0 for `os start`)", "ref": "#21060" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "stale clause re-pointed (#23950, from QA run #23915): acceptance[6] pinned the generic text 'MCP stdio identity is no longer valid (key revoked or expired)', but since #23367 (API-key lifecycle) resolvePrincipal raises the key's branded ADR-0112 refusal, so a revoked key's next stdio call is an isError tool result carrying code API_KEY_REVOKED, status 403 and the key's own lifecycle message. The clause now scores that code (and pins none of the message's wording, which the lifecycle owner rewords under #23944), no row served after the revoke (the fail-closed property it exists for, unchanged) and a pre-revoke control read on the same session; its oracle is the tool result (api), not a log line. Same ruling, same item: acceptance[1] attributed one message to unknown, revoked and expired keys alike, while the up-front admission now refuses a lifecycle-refused key with '[MCP] OS_MCP_STDIO_API_KEY is refused (API_KEY_REVOKED): …', so it names both messages and gains a leg (a new step starts stdio with the just-revoked key). A negative pins both failure directions", + "ref": "#23950" } ] }, diff --git a/docs/qa/platform-checklist/areas/approvals.json b/docs/qa/platform-checklist/areas/approvals.json index b31322261e9..ab5b38485bb 100644 --- a/docs/qa/platform-checklist/areas/approvals.json +++ b/docs/qa/platform-checklist/areas/approvals.json @@ -1209,7 +1209,7 @@ "title": "Every approver type resolves the slate it declares — department includes all descendants, deprecated role warns and resolves, and a slate with no concrete person takes the declared onEmptyApprovers fork, never a fake approval", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": ["dev admin (authors the scratch flows; org owner)", "Ada Auditor / Mei Phone (real logins since #9308 — directory rows the graph legs resolve to)"], @@ -1217,26 +1217,26 @@ "app": "showcase", "requires": [ "scratch ACTIVE autolaunched flows in a WRITABLE package, one per uncovered approver leg, each with a single approval node whose approvers carry the type under test (shape them after examples/app-showcase/src/automation/flows/approver-bindings.flow.ts, but note the caveat in knownGaps: that file is a DRAFT designer specimen, not a runnable fixture)", - "directory rows for the graph legs: the sys_business_unit tree IS seeded (the real org tree — examples/app-showcase/src/data/seed/index.ts), but unit MEMBERSHIP (sys_business_unit_member) and teams (sys_team / sys_team_member) are runtime writes — insert them over the data API as the admin before triggering the department/team flows", - "a manager chain for the manager leg: set a submitter's sys_user.manager_id (runtime PATCH)" + "directory rows for the graph legs: the sys_business_unit tree IS seeded (the real org tree — examples/app-showcase/src/data/seed/index.ts), but unit MEMBERSHIP (sys_business_unit_member) and teams are runtime writes. Memberships go in over the data API as the admin (POST /api/v1/data/sys_business_unit_member). A team does NOT: the data API refuses sys_team and sys_team_member writes (405 OBJECT_API_METHOD_NOT_ALLOWED), so create it with POST /api/v1/auth/organization/create-team {name} and add each member with POST /api/v1/auth/organization/add-team-member {teamId, userId}", + "a manager chain for the manager leg: as the admin, POST /api/v1/auth/admin/set-user-manager {userId: , managerId: } — a data-API PATCH of sys_user.manager_id is refused (403, the field is not editable there)" ], "knownGaps": [ "approver-bindings.flow.ts is status:'draft' with EMPTY values, deliberately — its own docblock says an active flow over those kinds would resolve to nobody and 're-teach the bug' (#3508). It exercises the DESIGNER's Value pickers, so it cannot serve as the runtime specimen the register hypothesized; every leg this matrix drives needs its own scratch active flow (the writable-package gap approvals.ooo-delegation-reroute already records)", "position, org_membership_level and expression already have LIVE stock coverage — the seeded per-group/quorum/budget flows route position, and showcase_dynamic_approval routes org_membership_level + expression (see approvals.dynamic-approver-routing). This matrix owns the missing legs (department+descendants, team, field, manager, user, role, queue, empty-slate forks); do not double-run the covered three — cite the sibling items' runs instead", - "sys_team is EMPTY on a stock boot (the seeded 'Team' rows are the demo showcase_team object, not the platform sys_team) — the team leg blocks(fixture) until the run inserts sys_team + sys_team_member rows at runtime" + "sys_team is EMPTY on a stock boot (the seeded 'Team' rows are the demo showcase_team object, not the platform sys_team) — the team leg blocks(fixture) until the run creates a team and its members at runtime, through the organization team doors (fixtures.requires)" ] }, "enumSource": { "file": "packages/spec/src/automation/approval.zod.ts", "export": "ApproverType", "expect": 10 }, "variants": ["manager", "position", "department (+ ALL descendant units)", "team", "field (multi-value fan-out)", "expression", "org_membership_level", "role (deprecated → org_membership_level: warn + resolve)", "user", "queue (dead slot, #3508)"], "steps": [ - "boot showcase isolated (dogfood §0); sign in as the dev admin; provision the directory rows (BU memberships, a team + members, a manager_id chain) over the data API and record their ids", + "boot showcase isolated (dogfood §0); sign in as the dev admin; provision the directory rows — BU memberships over the data API, a team and its members through the organization team doors, a manager chain through set-user-manager (fixtures.requires names each door) — and record their ids", "author + register the scratch flows in a writable package; per leg: trigger the flow, GET /api/v1/approvals/requests?status=pending, GET /:id and read pending_approvers (and pending_approver_names)", "department leg: point the approver at a PARENT unit whose descendant units hold the members — the slate must union the parent's members with every descendant's, deduped; deactivate a descendant and re-trigger: its members drop out (inactive units are skipped)", "field leg: a multi-select user field naming two users — two slots, in order, never one 'u1,u2' literal", "manager leg: the submitter's manager_id resolves; user leg: the literal id resolves", "role leg: author {type:'role', value:'owner'} (it still parses for the deprecation window); read the boot/trigger log for the ADR-0090 D3 deprecation warn naming the canonical spelling, and assert the slate equals what {type:'org_membership_level', value:'owner'} resolves on the same org", - "queue leg: a stored flow carrying {type:'queue'} (author it directly through the metadata channel if the designer refuses — the designer withholding it IS the xEnumDeprecated contract) — trigger: the slot resolves to nobody, the request opens carrying only the literal queue: slot, and the log carries the #3508 'not implemented — the slot resolves to nobody' warn", - "empty-slate forks, one scratch flow per policy over an UNSTAFFED position: onEmptyApprovers absent/admin_rescue → the request still opens (decidable only via the #3424 privileged override) and the loud 'resolved to no concrete approver' warn lands; 'fail' → the run fails with NO_APPROVERS and no request row; 'auto_approve' → NO request row, the run continues down the approve edge with output.autoApproved=true and the auto-approve warn lands; 'fallback' (fallbackApprovers naming a STAFFED target, e.g. org_membership_level 'owner') → the request opens on the fallback slate — pending_approvers holds the fallback's resolved ids, no literal slot — and the 'opening on the declared fallbackApprovers' warn lands; a fallback that itself resolves to nobody degrades to admin_rescue with its own 'resolved to nobody either' warn", + "queue leg: a stored flow carrying {type:'queue'} (author it directly through the metadata channel if the designer refuses — the designer withholding it IS the xEnumDeprecated contract) — trigger: the slot resolves to nobody and the log carries the #3508 'not implemented — the slot resolves to nobody' warn; the slate then names no one, so the default admin_rescue opens the request on the organization's administrators (#22824) — pending_approvers holds the administrators' ids and NOT the literal queue: slot, which is kept as the abandoned slate in node_config_json.__resolvedFrom['admin_rescue:abandoned'] — and the request stays pending", + "empty-slate forks, one scratch flow per policy over an UNSTAFFED position: onEmptyApprovers absent/admin_rescue → the request opens on the request organization's ADMINISTRATORS as ordinary approvers (#22824): first read who they are — the organization's sys_member rows whose grants hold organization_admin or organization_admin_no_bypass (on stock showcase, the dev admin as the org owner) — then read the request: pending_approvers equals exactly those ids, the abandoned position:

literal and its reason sit in node_config_json.__resolvedFrom ('admin_rescue:abandoned', 'admin_rescue:reason'), GET /:id/actions carries one system comment (no actor) 'Routed to this organization's administrators because the approver slate named no one: position '

' has no holder.', the request stays pending, and the WARN '… resolved to no concrete approver (…) — opening on this organization's administrators per onEmptyApprovers: 'admin_rescue'' lands; 'fail' → the run fails with NO_APPROVERS and no request row; 'auto_approve' → NO request row, the run continues down the approve edge with output.autoApproved=true and the auto-approve warn lands; 'fallback' (fallbackApprovers naming a STAFFED target, e.g. org_membership_level 'owner') → the request opens on the fallback slate — pending_approvers holds the fallback's resolved ids, no literal slot — and the 'opening on the declared fallbackApprovers' warn lands; a fallback that itself resolves to nobody degrades to admin_rescue with its own 'resolved to nobody either' warn, and so opens on the organization's administrators too", "throughout: read the trigger-time log — every graph expansion to nobody must carry the #3807 'expanded to nobody' warn naming type and value" ], "acceptance": [ @@ -1265,21 +1265,22 @@ "evidence": "the warn line + the two equal slates" }, { - "clause": "the empty-slate fork honors the declared policy: admin_rescue (the default) opens the request + warns loudly (decidable only via the privileged override); 'fail' kills the node with NO_APPROVERS and opens nothing; 'auto_approve' opens nothing and resumes down the approve edge with output.autoApproved=true; 'fallback' opens the request on the declared fallbackApprovers (resolved by the same resolver as approvers) and warns that the fallback fired — a fallback that resolves to nobody too degrades to admin_rescue — 'empty' meaning no CONCRETE person, so a slate of only type:value literals takes the fork too", + "clause": "the empty-slate fork honors the declared policy: admin_rescue (the default) opens the request on the request organization's ADMINISTRATORS as ordinary approvers, warns loudly and records why (#22824) — never on the slate that names no one while the organization has an administrator; 'fail' kills the node with NO_APPROVERS and opens nothing; 'auto_approve' opens nothing and resumes down the approve edge with output.autoApproved=true; 'fallback' opens the request on the declared fallbackApprovers (resolved by the same resolver as approvers) and warns that the fallback fired — a fallback that resolves to nobody too degrades to admin_rescue, and so to the administrators — 'empty' meaning no CONCRETE person, so a slate of only type:value literals takes the fork too", "oracle": "api", - "verify": "per policy: request-row presence/absence, run outcome, and the matching warn/error text ('resolved to no concrete approver…'); the admin_rescue request's pending_approvers holds only the literal slot; the fallback request's pending_approvers holds the fallback slate, never the abandoned literal slot", - "evidence": "per-policy request/run reads + log lines" + "verify": "per policy: request-row presence/absence, run outcome, and the matching warn/error text ('resolved to no concrete approver…'); the admin_rescue request's pending_approvers holds exactly the organization's administrators (the TENANT_ADMIN rung of the request's organization, ADR-0095 D3 — read from its sys_member rows and their grants) and NOT the abandoned position:

literal, which is recorded under node_config_json.__resolvedFrom['admin_rescue:abandoned'] beside 'admin_rescue:reason'; its actions carry the one system comment 'Routed to this organization's administrators because the approver slate named no one: …'; its WARN reads '… opening on this organization's administrators per onEmptyApprovers: 'admin_rescue' …'; it stays pending, never finalized (approval-service.ts openNodeRequest's empty-slate fork and organizationAdministrators; pinned by admin-rescue-named-slate.integration.test.ts); the fallback request's pending_approvers holds the fallback slate, never the abandoned literal slot", + "evidence": "per-policy request/run reads (pending_approvers, node_config_json.__resolvedFrom, the request's actions) + log lines + the administrator read" }, { - "clause": "queue is a dead slot that stays LOUD, never a fake approval: it resolves to nobody, falls back to the literal queue: slot, warns #3508 — and with default admin_rescue the request waits for an override rather than passing", + "clause": "queue is a dead slot that stays LOUD, never a fake approval: it resolves to nobody and warns #3508; its slate naming no one, the default admin_rescue opens the request on the organization's administrators (#22824) — the literal queue: slot is the abandoned slate, never a pending approver — and the request waits for an administrator's decision rather than passing", "oracle": "log", - "verify": "the 'queue is not implemented — the slot resolves to nobody (#3508)' warn + the request read showing the literal slot and status still pending", - "evidence": "warn line + request read" + "verify": "the 'approver type 'queue' is not implemented — the slot resolves to nobody' warn (#3508, approval-service.ts resolveApproverSpec) + the request read: status still pending, pending_approvers equals the organization's administrators with no queue: entry, node_config_json.__resolvedFrom['admin_rescue:abandoned'] is ['queue:'], and the routed-to-administrators comment is on its actions", + "evidence": "warn line + request read + its actions" } ], "negative": [ "a graph approver expanding to nobody with NO warn is a FAIL even though the literal-slot fallback itself is by design — the silence is what let #3807 hide behind a permanently stuck approval", "an empty slate that finalizes approved under admin_rescue (or under the absent-key default) is a FAIL — only the opt-in auto_approve policy may wave a record through, and it must say so in the log", + "an admin_rescue request that opens on the literal slot alone (pending_approvers naming no person) while its organization has an administrator is a FAIL — that is the nameless request #22824 retired: in no one's queue, counted by no bell, told to no one", "role refused at parse, or resolving a different slate than org_membership_level, is a FAIL during the deprecation window — the map (DEPRECATED_APPROVER_TYPES) is the contract", "a department approver resolving ONLY direct members while descendants hold people is a FAIL — the descendant walk is the documented semantic (the spec enum's own docline: 'Members of a department + all descendant departments')" ], @@ -1287,13 +1288,21 @@ "source": [ "packages/spec/src/automation/approval.zod.ts#ApproverType (ApproverType, 10 members incl. deprecated role + dead queue), (DEPRECATED_APPROVER_TYPES role→org_membership_level + canonicalApproverType), (NON_AUTHORABLE_APPROVER_TYPES), (onEmptyApprovers admin_rescue|fail|auto_approve|fallback, default admin_rescue), (fallbackApprovers — same shape as approvers, required iff 'fallback' and refused under any other policy: checkFallbackApproversPairing)", "packages/plugins/plugin-approvals/src/approval-service.ts#expandApprovers (expandApprovers — per-spec group tagging; expression resolved outside the flat contract), (resolveApproverSpec — role warn, per-type branches, queue warn, #3807 expanded-to-nobody warn, literal type:value fallback), (expandBusinessUnitUsers — active-descendant BFS + membership union), (the empty-slate fork: 'empty' = no concrete person, NO_APPROVERS / autoApproved / fallbackApprovers re-expansion / admin_rescue warn)", + "packages/plugins/plugin-approvals/src/approval-service.ts#organizationAdministrators (#22824 — the slate an empty admin_rescue request opens on: the request organization's members holding its TENANT_ADMIN rung, the authority the override's tenant arm trusts; platform administrators are not added for being one; no administrator ⇒ the nameless request and its loud WARN), (emptySlateReason — the reason kept in __resolvedFrom and the routed-to-administrators timeline comment)", + "packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts (the #22824 pin: the administrators' slate, each one's queue and bell, an ordinary approval rather than an override, the reason on the request, and the fallback / auto_approve / fail / no-administrator controls)", "examples/app-showcase/src/automation/flows/approver-bindings.flow.ts (the #3508 DESIGNER specimen — draft, empty values, its docblock is the evidence the runtime legs need scratch flows)", "examples/app-showcase/src/data/seed/index.ts (BU tree seeded; membership is a runtime admin action by design)", - "#3424 (privileged override — the admin_rescue escape hatch), #3447 P2, #3508, #3807, ADR-0090 D3" + "#22824 (ruling: an empty slate under admin_rescue opens on the tenant's administrators as named approvers — the #3424 override stays break-glass, for an organization with no administrator), #3424 (privileged override), #3447 P2, #3508, #3807, ADR-0090 D3, ADR-0095 D3" ], "history": [ { "revision": 1, "date": "2026-08-30", "change": "initial — the approver-type matrix had no item: siblings cover position (seeded flows) and org_membership_level/expression (dynamic-approval) but department-with-descendants, team, field fan-out, manager, literal user, the role deprecation window, the dead queue slot and all three onEmptyApprovers forks were untested. One register correction: approver-bindings.flow.ts is a draft designer specimen (empty values, status draft by documented design), not a runnable user/manager/field fixture — every uncovered leg needs a scratch active flow, recorded in knownGaps", "ref": "#sweep-2026-08-30" }, - { "revision": 2, "date": "2026-09-17", "change": "the empty-slate fork gained a fourth policy — onEmptyApprovers: 'fallback' + fallbackApprovers (PR #18525, #17931): the request opens on the declared fallback slate, resolved by the same resolver as approvers, and a fallback that resolves to nobody degrades to admin_rescue. Added that leg to the steps, the fork clause and its verify, and the two source citations; the three existing legs are unchanged", "ref": "#18527" } + { "revision": 2, "date": "2026-09-17", "change": "the empty-slate fork gained a fourth policy — onEmptyApprovers: 'fallback' + fallbackApprovers (PR #18525, #17931): the request opens on the declared fallback slate, resolved by the same resolver as approvers, and a fallback that resolves to nobody degrades to admin_rescue. Added that leg to the steps, the fork clause and its verify, and the two source citations; the three existing legs are unchanged", "ref": "#18527" }, + { + "revision": 3, + "date": "2026-10-16", + "change": "stale clauses re-pointed (#23950, from QA run #23926): acceptance[4] and acceptance[5] pinned the pre-#22824 shape, where an empty slate under the default admin_rescue opened on the literal type:value slot (position:qa_nobody, queue:support) and waited for the #3424 override. Since the #22824 ruling (PR #22850) that request opens on the request organization's administrators as ordinary approvers, with the abandoned literal and the reason kept in __resolvedFrom, a routed-to-administrators comment on the request and a WARN naming the routing, so both clauses went red against a correct product. Both now score that shape: pending_approvers equals the organization's administrators and never the literal slot, the reason is recorded, the request stays pending; the queue clause keeps its #3508 warn and its never-a-fake-approval property. The queue and empty-slate steps follow, a fallback that resolves to nobody now names where it lands, a negative fails the retired nameless request, and the sources cite organizationAdministrators, emptySlateReason and the admin-rescue-named-slate pin. The fail, auto_approve and fallback legs are unchanged. Measured on the re-run and corrected here, same class (a fixture naming a door the product has closed): the data API refuses sys_team / sys_team_member writes (405) and sys_user.manager_id (403), so fixtures.requires, step 1 and the sys_team knownGap now name the doors that work — POST /api/v1/auth/organization/create-team and add-team-member, and POST /api/v1/auth/admin/set-user-manager", + "ref": "#23950" + } ] }, { diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index 7c98539cb40..fbba946a953 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -705,38 +705,38 @@ }, { "id": "cli.migrate-meta-codemod", - "title": "os migrate meta replays the spec migration chain over authored sources and PRINTS the mechanical edit list the retirement messages prescribe — it rewrites no source file, its --out snapshot is schema-valid, replay is idempotent, and a below-floor --from is refused", + "title": "os migrate meta replays the spec migration chain over authored sources and PRINTS the mechanical edit list the retirement messages prescribe — by default it rewrites no source file, and --write rewrites in place exactly the edits it can prove, held to a re-run of the chain; its --out snapshot is schema-valid, replay is idempotent, and a below-floor --from is refused", "since": "v14.7", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "cli", "personas": ["build-time author (no session)"], "fixtures": { "app": "showcase", "requires": [ - "a scratch copy of an authored source tree. ⚠️ NOT because the command rewrites in place — it does not: the authored-source chain reads the config and writes nothing but --out (commands/migrate/meta.ts: 'The command does not silently rewrite TS config source (that AST rewrite is unsafe and lossy)'), and the --stored arm is a read-only preview until --apply. A scratch copy is still the right hygiene for planting retired spellings, and --stored --apply IS a real write — to this deployment's sys_metadata ROWS, not to files", + "a scratch copy of an authored source tree, and a SECOND fresh copy for the --write leg: --write (#9591) rewrites the authored files in place, so it never runs on the checked-out app. The default run (no --write) writes nothing but --out (commands/migrate/meta.ts: 'By default it writes no source file'), and the --stored arm is a read-only preview until --apply. --stored --apply IS a real write too — to this deployment's sys_metadata ROWS, not to files", "authored sources deliberately carrying retired spellings from the previous major, so the chain has something to report" ], "knownGaps": [ "Constructing sources that carry the OLD spellings requires authoring against a retired surface, which the current schemas refuse at parse. Author the fixture files as raw text (they are the input a real upgrader has on disk — files written before the retirement, which is exactly why they do not parse now); do NOT try to produce them through the current schema. If a retired spelling cannot be reconstructed for a sampled entry, record that entry as blocked(fixture) rather than declaring the chain clean over it.", "The registry carries entries for both the shipped major and the next one. Conversions for the NEXT major may be staged but not yet reachable through a released --from/--to pair; record which sampled entries were actually exercised.", - "The in-place SOURCE REWRITE this item was first authored against does not exist and is deliberately out of scope at revision 3: `os migrate meta` declares no --write/--fix/in-place flag and the authored-source arm's only file write is the --out snapshot. It is commissioned as #9591 for v18 (pm:on-hold, with its own Restart-when line). ⛔ Do NOT record it as blocked(dependency) and do NOT file it as a defect — print-only IS the shipped contract. The restart is mechanical instead: the last step and the last acceptance clause read the --help flag surface, and a --write appearing there is the signal to restore the rewrite assertions here." + "--write writes a site only when it can trace it to ONE object or array literal in ONE project file whose value matches the loaded one; every other site is REFUSED with a kind from a closed set (computed, helper, spread, shared, outside-project, mismatch, injected, unspellable, layout, unattributed, entangled — packages/cli/src/utils/authored-source-codemod.ts CodemodRefusalKind) and listed under 'left for you to apply by hand' with its reason. Plant the retired spellings as plain literals the config module reaches (directly, or through a relative import or a define* helper) so each is traceable. A planted site the run lists as refused, with a kind from that set and a reason naming the blocker, is the contract working, not a fail of the write clause — record its kind and re-plant that entry as a plain literal; a refused site with no kind or no reason is a finding." ] }, "steps": [ "enumerate the migration registries and record their sizes: packages/spec/src/migrations/entries/{retired-defs,retired-keys,semantic}", - "collect the command's advertised contract from the SHIPPED retirement messages: grep the spec for the house sentence they carry — `Run \\`os migrate meta --from \\` to list the mechanical edits for existing sources; apply them by hand.` (shared text in packages/spec/src/shared/retired-key.ts, reworded class-wide by #9529) — and record which majors are named. ⚠️ The sentence promises a LIST, not an automatic rewrite: what this item verifies is that the listing is complete and honest, not that files change", + "collect the command's advertised contract from the SHIPPED retirement messages: grep the spec for the house sentence they carry — `Run \\`os migrate meta --from \\` to list the mechanical edits for existing sources; \\`--write\\` applies the ones it can prove, and you apply the rest by hand.` (shared text in packages/spec/src/shared/retired-key.ts, reworded class-wide by #9529 and given its `--write` clause by #9591) — and record which majors are named. ⚠️ The sentence promises a LIST by default and an in-place write only for the edits --write can prove: the default run is judged on the listing being complete and honest, the --write run on writing exactly the provable edits and nothing else", "copy an authored source tree to a scratch dir and plant retired spellings sampled from the retired-keys registry (at minimum one ui/ key and one data/ key); record a per-file checksum of the scratch tree BEFORE the run", "run `os migrate meta --from ` and capture stdout + the exit code", "confirm every planted spelling appears in the printed `Applied mechanical change(s):` block as `: → ()`, naming the successor the registry declares", "re-checksum the scratch tree and confirm every authored file is byte-identical — the run's only file write is --out", "re-run with `--out .json`, read the snapshot back, and confirm the run states its schema verdict honestly: `Migrated stack is schema-valid` when it is, and the `Migrated stack does not yet pass schema validation — resolve the manual changes above, then run \\`os validate\\`` warning when manual changes are still outstanding", - "idempotence: replay a CANONICAL source (the unmodified authored tree, carrying no retired spelling) over a real range — `os migrate meta --from 17` (--to defaults to 18, CHAIN_TERMINUS_MAJOR: the highest major this build carries a step for, ahead of the runtime's PROTOCOL_MAJOR 17) — and confirm NO `Applied mechanical change(s):` block and exit 0; the step-18 semantic advisories are still listed (every hop lists its semantic entries unconditionally), so `Nothing to migrate …` is NOT the expected line here. Then run the identical `--from ` command on the planted tree a second time and diff the two stdouts. Separately record the EMPTY-RANGE answer: `--from 18` (range 18 → 18, no step) prints `⚠ No migration step exists for protocol 18 → 18, so this run replayed nothing — that is not a finding that the metadata is canonical.` — never the canonical success line", + "idempotence: replay a CANONICAL source — one carrying no shape the 17 → 18 chain converts; the tree the WRITE MODE leg wrote is one, while the stock showcase tree is NOT (three edge-branched decisions omit `mode`, so flow-decision-mode-inclusive-explicit lists three edits over it) — over a real range — `os migrate meta --from 17` (--to defaults to 18, CHAIN_TERMINUS_MAJOR: the higher of the runtime's PROTOCOL_MAJOR and the highest major the registries carry a step for, 18 on both counts since PROTOCOL_VERSION moved to 18 (#22215); the run's `Chain:` line prints both) — and confirm NO `Applied mechanical change(s):` block and exit 0; the step-18 semantic advisories are still listed (every hop lists its semantic entries unconditionally), so `Nothing to migrate …` is NOT the expected line here. Then run the identical `--from ` command on the planted tree a second time and diff the two stdouts with the verdict line's elapsed-time stamp (`(ms)`) normalized away — it is a timing, not report content. Separately record the EMPTY-RANGE answer: `--from 18` (range 18 → 18, no step) prints `⚠ No migration step exists for protocol 18 → 18, so this run replayed nothing — that is not a finding that the metadata is canonical.` — never the canonical success line", "run it with a --from BELOW the supported floor and capture the MigrationFloorError path (`Cannot migrate from protocol : the chain's support floor is `)", "run it with --json where supported and check the exit code in the same way #4873 required of every other migrate subcommand", "confirm the semantic-only entries behave as ADVISORIES: they are reported under ` manual change(s) require your judgment:` with their why/verify text, never silently 'fixed' (a semantic change has no mechanical rewrite by definition)", - "RESTART CHECK for #9591 — run `os migrate meta --help` and record the flag surface. Today it is exactly from/to/step/out/stored/database-url/apply/yes/force/type/json, with no in-place rewrite flag. If a --write (or equivalent) has appeared, #9591 has LANDED and this item is stale: restore the source-rewrite assertions revision 3 removed and bump the revision, rather than ticking or failing the clauses below" + "WRITE MODE (#9591): run `os migrate meta --help` and record the --write line. Then, on a SECOND fresh scratch copy carrying the same planted spellings, keep a byte copy of the tree and run `os migrate meta --from --write`; capture stdout + the exit code. Read the `Wrote of mechanical change(s) into file(s):` block (each site `: ()`), any ` mechanical change(s) left for you to apply by hand:` block (each with its `not written []: `), and the closing `Re-ran the chain over the written sources: …` line. Diff the tree against its byte copy: the changed files are exactly the files the block names, each hunk sits at a listed site, and every other byte (comments, formatting, key order) is unchanged. Then, over the WRITTEN tree: re-run `os migrate meta --from ` without --write (no written site is still listed under Applied; the semantic advisories are listed exactly as before), run `--write` a second time (it writes nothing: `--write: the chain made no mechanical change here, so no file was written.`), and run `--write --json` on a third fresh copy (exit 0; the payload's `write.status` is `written` and `write.written[]` names each site with its file and line)" ], "acceptance": [ { @@ -746,9 +746,9 @@ "evidence": "the printed block, with each sampled entry located in it" }, { - "clause": "the authored sources are UNTOUCHED — the run's only file write is the --out snapshot. This is the shipped contract, not a defect: the command declines the AST rewrite as 'unsafe and lossy' in its own header", + "clause": "WITHOUT --write the authored sources are UNTOUCHED — the default run's only file write is the --out snapshot. This is the shipped contract, not a defect: the source rewrite is opt-in (--write, acceptance[7]), and the command's own docblock says 'By default it writes no source file'", "oracle": "log", - "verify": "per-file checksums of the scratch tree before and after: every file byte-identical, and the only new or changed path is the --out target when --out was passed", + "verify": "per-file checksums of the scratch tree before and after the runs made without --write: every file byte-identical, and the only new or changed path is the --out target when --out was passed", "evidence": "the before/after checksum pairs + the --out path" }, { @@ -760,7 +760,7 @@ { "clause": "replay is IDEMPOTENT: replaying a canonical source over a range that CONTAINS steps lists zero mechanical edits and exits 0, and two identical runs print the same thing — while an EMPTY range (--from equal to the --to default, 18) is answered as an empty range ('⚠ No migration step exists for protocol N → N …'), never as a green 'already canonical' verdict over a check that never ran", "oracle": "log", - "verify": "the canonical-source replay `--from 17` (default --to 18) prints no `Applied mechanical change(s):` block (only the step-18 advisories) and exits 0; `--from 18` prints `⚠ No migration step exists for protocol 18 → 18, so this run replayed nothing — that is not a finding that the metadata is canonical.` (meta.ts printEmptyRangeAnswer) and NOT `Nothing to migrate — …`; the two identical planted-tree runs diff empty. An upgrader re-runs this command, and a chain reporting fresh work on clean input cannot be trusted", + "verify": "the canonical-source replay `--from 17` (default --to 18) over a canonical source (the written tree, never the stock showcase — see the step) prints no `Applied mechanical change(s):` block (only the step-18 advisories) and exits 0; `--from 18` prints `⚠ No migration step exists for protocol 18 → 18, so this run replayed nothing — that is not a finding that the metadata is canonical.` (meta.ts printEmptyRangeAnswer) and NOT `Nothing to migrate — …`; the two identical planted-tree runs diff empty once the elapsed-time stamp is normalized. An upgrader re-runs this command, and a chain reporting fresh work on clean input cannot be trusted", "evidence": "the canonical-replay output + exit code, the empty-range output, and the diff of the two identical-run stdouts" }, { @@ -782,16 +782,17 @@ "evidence": "exit code" }, { - "clause": "RESTART CONDITION — the command still has NO in-place rewrite flag, i.e. #9591 (v18) has not landed and this item's print-only scope is still the right one", + "clause": "--write rewrites the authored sources IN PLACE for exactly the mechanical changes it can prove, and for nothing else (#9591): each planted spelling traced to a literal is written at its site, each one it cannot trace is listed with a refusal kind and reason, no semantic advisory is written, every byte outside a written site is unchanged, and the write is held to a re-run of the chain over the written sources — so the written tree no longer carries the converted spelling, and a second --write has nothing to do", "oracle": "log", - "verify": "`os migrate meta --help` lists exactly from/to/step/out/stored/database-url/apply/yes/force/type/json. A --write/--fix/in-place flag appearing is NOT a failure of this item — it is the trigger to restore the source-rewrite assertions here and bump the revision", - "evidence": "the --help flag list" + "verify": "the WRITE MODE step: the `Wrote of mechanical change(s) into file(s):` block names a file and line for each traced planted spelling, N plus the by-hand count equals M (the Applied count of the dry run over the same input), and the run closes with `Re-ran the chain over the written sources: no mechanical change remains.` (or `only the change(s) left above remain.`) and exits 0; the diff against the pre-run byte copy touches only the named files, only at the named sites; the re-run without --write lists no written site under Applied and the same semantic advisories; the second --write prints `--write: the chain made no mechanical change here, so no file was written.`; `--write --json` exits 0 with `write.status` `written`. ⛔ A write the re-run disagrees with must come back `restored` with exit 1 and every file at its old bytes, never half-converted (meta.ts writeSources) — a reported `written` over a tree the re-run still converts is a FAIL", + "evidence": "the --write stdout and exit code, the before/after tree diff, the re-run stdout, the second --write line, and the --json write block" } ], "negative": [ "a retirement message prescribing `os migrate meta --from N` for a spelling the chain does not LIST — the prescription is then a dead end, and the author who follows it is worse off than one who was told nothing", "a listed edit whose declared successor ALSO does not parse (pointing the author at a second retired name)", - "a run that MUTATES the authored sources. Against today's contract that is a FAIL of the untouched-sources clause; if it turns out to be #9591 landing rather than a bug, the correct response is to revise this item, never to tick it", + "a run WITHOUT --write that mutates the authored sources is a FAIL of the untouched-sources clause — the rewrite is opt-in", + "a --write that changes any byte outside a listed site, writes a semantic advisory, drops a planted spelling from both its written and its by-hand list, or reports the write done while the re-run over the written tree still converts a written site, is a FAIL of the write clause", "a below-floor --from that silently no-ops and exits 0", "a replay from the target major that reports changes, or two identical runs whose stdouts disagree", "`Migrated stack is schema-valid` printed while manual changes are still outstanding — the same dishonesty as reporting 'migrated' over a semantic change nobody applied" @@ -799,12 +800,15 @@ "traps": ["destructive-in-place", "absence-inference"], "source": [ "packages/cli/src/commands/migrate/meta.ts#writeFileSync (applyMetaMigrations, composeSpecChanges, normalizeStackInput, MigrationFloorError, PROTOCOL_MAJOR; the declared flag surface and the two --out-guarded writeFileSync calls)", + "packages/cli/src/commands/migrate/meta.ts#printWriteOutcome (the --write report: the written sites, the by-hand list with each refusal kind, and the re-run verdict; the write itself and its restore-on-disagreement are MigrateMeta.writeSources)", + "packages/cli/src/utils/authored-source-codemod.ts#CodemodRefusalKind (what --write proves before writing a site, and the closed set of reasons it refuses one; edits are text splices at node positions, so bytes outside a site are unchanged)", + "packages/cli/test/migrate-meta-write.test.ts (the #9591 pin: exactness, idempotence, semantic TODOs never written, every refusal kind, and no source change without --write)", "packages/spec/src/migrations/entries/retired-defs/ · retired-keys/ · semantic/ (the registries it replays)", "the retirement messages across packages/spec that prescribe this command by name — the shared sentence in packages/spec/src/shared/retired-key.ts and its per-key sites (e.g. the BOOK_TRANSLATIONS_RETIRED text in packages/spec/src/system/book.zod.ts#pinned), pinned class-wide by packages/spec/src/shared/retired-key-migrate-sentence.test.ts", "ADR-0087 (retirement registries and conversion routes)", "#4873 (--json exit-code honesty across migrate subcommands)", "#9529 (the 2026-08-18 reword: the messages promise a LIST of mechanical edits applied by hand, not an automatic rewrite)", - "#9591 (the v18 `--write` AST codemod, pm:on-hold — `Restart-when: the v18 major development cycle opens (first v18 changeset-major accepted on main), or a maintainer instruction pulls it forward`; when it lands, the assertions removed at revision 3 come back here)", + "#9591 (the `--write` AST codemod — closed completed: the command landed by PR #22108 (commit a959493cd), and PR #22142 named `--write` in the house retirement sentence; the restart trigger revision 3 wrote into the last step and clause fired, and revision 5 restored the source-rewrite assertions as acceptance[7])", "#9733 (the disposition: keep the item active and rewrite it to today's contract rather than retire a P1 over a capability parked two majors out)", "sibling item cli.migrate-plan-apply-json (the DATABASE migrate lane — lists `meta` as a variant but asserts nothing about it)" ], @@ -832,6 +836,12 @@ "date": "2026-10-04", "change": "A4 / step 8 re-pointed: replaying from the target major is now an EMPTY range and prints '⚠ No migration step exists for protocol N → N …' (no longer the 'Nothing to migrate' success line); idempotence is proven by replaying a canonical source over --from 17 with the default --to 18 (CHAIN_TERMINUS_MAJOR, ahead of PROTOCOL_MAJOR 17): zero mechanical edits, exit 0, the step-18 semantic advisories still listed (chain.ts lists every hop's semantic entries unconditionally, so 'Nothing to migrate' does not print over 17 → 18). meta.ts printEmptyRangeAnswer / printMigrationReport, the --to flag default. Stale clause. Source fb39b385f1 (#17462, fixes #17134)", "ref": "#21735" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "stale clause re-pointed (#23950, from QA run #23921): the item's own restart trigger fired. #9591 closed completed — `os migrate meta --write` landed by PR #22108 (a959493cd) and the house retirement sentence names it since PR #22142 — so `--help` now lists --write (and --all), and acceptance[7], which asserted the print-only flag surface, went red against a correct product. As revision 3 prescribed, the source-rewrite assertions are restored rather than the restart clause deleted: acceptance[7] is now the write contract — --write rewrites in place exactly the mechanical edits it can trace to one literal in one project file, lists every other with a closed-set refusal kind, writes no semantic advisory, leaves every byte outside a written site unchanged, and is held to a re-run of the chain (restored, exit 1, on disagreement), so the written tree re-runs clean and a second --write writes nothing. The last step becomes the WRITE MODE leg on its own fresh scratch copy. Following the same landing: the title and fixtures say the rewrite is opt-in and needs its own copy; the third knownGap now describes what --write refuses instead of saying it does not exist; step 2 quotes the current house sentence (with its --write clause); acceptance[1] is scoped to runs without --write and drops a quotation ('unsafe and lossy') meta.ts no longer carries; and the mutation negative splits into a no-write half and a write half. Source gains printWriteOutcome, CodemodRefusalKind and the migrate-meta-write.test.ts pin. Measured on the re-run and corrected here, same class (a step that reds a correct product): step 8 called the unmodified tree canonical for the `--from 17` replay, but the stock showcase carries three edge-branched decisions with no `mode`, which flow-decision-mode-inclusive-explicit converts, so that replay lists three edits; the canonical source is now the tree the WRITE MODE leg wrote, and the identical-run diff normalizes the verdict line's elapsed-time stamp. Same re-run, same class: step 8 still said the --to default ran ahead of the runtime's PROTOCOL_MAJOR 17, but PROTOCOL_VERSION is 18 since #22215, so the default (meta.ts CHAIN_TERMINUS_MAJOR, the higher of the two) is 18 on both counts; the parenthetical now says so, and the measured `--from 17` / `--from 18` expectations are unchanged", + "ref": "#23950" } ] }, diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 234457b180a..06a13c12f27 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -974,10 +974,10 @@ }, { "id": "platform-core.shell-nav-personalization", - "title": "Shell nav personalization: sidebar collapse, pins and the user's pinned order, favorites, recents and header breadcrumbs each persist and stay navigable across reload; the authored menu keeps the app's order", + "title": "Shell nav personalization: sidebar collapse, pins and the user's pinned order, favorites and recents each persist and stay navigable across reload; the header's crumbs follow the route and rebuild on reload; the authored menu keeps the app's order", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "browser", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -994,7 +994,7 @@ "pinned order (desktop only): with at least three entries pinned, reorder them inside the Pinned section — the group NavigationRenderer draws above the menu, headed console.nav.favorites ('Favorites' in en, 「收藏」 in zh, a star icon), not the content-favorites group UnifiedSidebar draws below Recent. By pointer: drag a pinned row by the row itself (no grip at rest; the row's link carries cursor-grab; a press becomes a drag only after 5px of travel; a 2px insertion line, data-drop-indicator, marks where it lands). By keyboard: focus a pinned row's link, Space picks it up, the arrow keys move it, Space drops it, Escape cancels; Enter is not a drag key, it follows the link. Reload — the pinned order persists: NavigationRenderer onPinnedReorder → useNavPins.reorderPins → FavoritesProvider reorderNavPins stores it as the order of the type 'nav' favorites (nav:) in the UserDataAdapter-backed favorites list. A new pin joins the end; an unpin leaves the others in order", "favorites star/unstar: star an object/record as a favorite (useFavorites / FavoritesProvider); it appears in favorites; reload — persists; unstar removes it", "recents rail: visit several records/objects/dashboards; the sidebar Recent section (collapsed by default, top 5 — useTrackRouteAsRecent → RecentItemsProvider, localStorage objectui-recent-items scoped by userId, hydrates UserDataAdapter) lists them; reload — recents survive", - "header record-trail breadcrumbs: on a record detail the AppHeader breadcrumb shows the app/section/record trail; click a crumb to navigate back to its level; reload — the trail rebuilds from the route", + "header crumbs follow the route (objectui AppHeader at the console pin, objectui#12461 / #12526 / #12273), at a viewport at least 640px wide (the crumbs are drawn from `sm` up): open a report (the sidebar's Hours by Status entry, …/report/showcase_hours_by_status) — after the app pill and its PREVIEW chip the bar draws exactly ONE crumb, the report's label, and no 「Reports」 type crumb; open a system route (…/system/settings) — 'System' then 'Settings'; open a record detail (…//record/) — NO separator and no crumb after the chip, while the record's page header names it. The crumbs are plain text, none of them a link (the app pill's switcher is the way back to the app). Reload each route — the same crumbs rebuild from the route", "authored menu (any viewport): the app's own menu — every row outside the Pinned section — offers no reorder: no drag grip, no aria-roledescription=\"sortable\", no aria-describedby=\"nav-pinned\" (the pinned list's DndContext id, which names dnd-kit's drag instructions) and no cursor-grab on its links, and dragging a menu row moves nothing (a browser's native link-drag ghost is not a reorder). Its order is the app's authored navigation order: each level sorted by order, lower first, ties in authored array order (objectui packages/layout/src/navOrder.ts byNavOrder). Then plant the retired per-user key — localStorage objectui-nav-order- holding a JSON map of __root__ and each group id to that level's ids reversed, the shape the retired useNavOrder read — and reload: the menu order does not change, because UnifiedSidebar no longer reads the key", "record which persistence each feature uses: pins and their order, favorites and recents are UserDataAdapter-backed (cross-device sync; pins, their order and favorites are one sys_user_preference row, key ui.favorites), collapse is local (cookie sidebar_state)" ], @@ -1030,10 +1030,10 @@ "evidence": "visit sequence + post-reload recents" }, { - "clause": "header record-trail breadcrumbs render on a record and each crumb navigates back to its level; the trail rebuilds after reload", + "clause": "the header's crumbs follow the route type: a report route draws exactly one crumb after the app pill and its PREVIEW chip — the report's label, no 「Reports」 type crumb — and a system/* route draws 'System' then its section; an object route (its list, a view, a record) draws NO crumb after the chip, its page header being the one place the page is named; every route's crumbs rebuild from the route after reload", "oracle": "screenshot", - "verify": "breadcrumb screenshot + a crumb navigation that routes back + the trail present after reload", - "evidence": "breadcrumb screenshot + the crumb navigation" + "verify": "a settled screenshot of the header on each of the three routes (report, system/settings, a record) at a viewport at least 640px wide, then read the bar's left group after the PREVIEW chip: report → one text crumb equal to the report's label; system/settings → 'System', 'Settings'; record → no separator and no crumb, while the record's page header shows its title. Reload each and read again — identical. The report and system reads are the control that the probe sees crumbs at all. Source: objectui packages/app-shell/src/layout/AppHeader.tsx at the console pin (its module doc, and the extraSegments block with its 'Plain text: none of them links' render note)", + "evidence": "per-route crumb labels after the chip, before and after reload, + the record page header's title" }, { "clause": "the authored menu is not user-reorderable (any viewport): no row outside the Pinned section draws a drag grip or carries aria-roledescription=\"sortable\", aria-describedby=\"nav-pinned\" or cursor-grab, and the menu renders in the app's authored order even with a stored objectui-nav-order- key", @@ -1047,7 +1047,7 @@ "a pin, the pinned order, a favorite or a recent that does NOT survive reload is a FAIL of its persistence contract", "on the authored menu (every row outside the Pinned section, any viewport) a drag grip, an aria-roledescription=\"sortable\" element, dnd-kit's drag-instructions aria-describedby, a grab cursor, a drag that moves a row, or an order other than the app's authored order (with or without a stored objectui-nav-order- key) is a FAIL: arranging the menu is Studio's work, not the end user's", "a phone viewport (under 768px) draws no Pinned section (UnifiedSidebar enablePinning={!isMobile}): its absence there is expected, not a FAIL of the pinned order", - "a breadcrumb crumb that is inert (does not navigate) is a FAIL", + "a crumb after the chip on an object route (its list, a view or a record — the page named a second time), a 「Reports」 or 「Dashboards」 type crumb, or a report or system route that draws no crumb, is a FAIL of the header contract; a crumb that is not a link is NOT a fail — the crumbs are plain text by design (objectui#12461)", "transitional emptiness right after navigation (empty nav/recents) must be ruled out by the screenshot-first protocol before any persistence FAIL is recorded" ], "traps": ["hydration-race", "shared-browser-tab", "stale-console-bundle", "automation-input"], @@ -1061,12 +1061,18 @@ "objectui packages/app-shell/src/console/ConsoleShell.tsx (createObjectStackUserStateAdapter key ui.favorites — the favorites list, nav pins and their order included, is one per-user row)", "packages/platform-objects/src/identity/sys-user-preference.object.ts#sys_user_preference (the per-user row that favorites list is stored in)", "objectui packages/app-shell/src/context/RecentItemsProvider.tsx + hooks/useTrackRouteAsRecent.ts (recents — objectui-recent-items localStorage + UserDataAdapter hydrate)", - "objectui packages/app-shell/src/layout/AppHeader.tsx (breadcrumb record-trail)", + "objectui packages/app-shell/src/layout/AppHeader.tsx at the console pin, read at daeb77fc and again at f8267df9 (objectui#12605 there adds the product mark and a rule BEFORE the pill and leaves everything after the chip as it was) (the header crumbs: an object route — list, view, record — draws no crumb after the pill, objectui#12461; a dashboard or page route none either, objectui#12526; a report draws its label with no type crumb, objectui#12273; a system/* route draws System then its section; the crumbs are plain text), pinned in objectui by packages/app-shell/src/layout/__tests__/AppHeader.appBarLeftGroup-12461.test.tsx and AppHeader.dashboardReportCrumb-12273.test.tsx", "objectui packages/components/src/ui/sidebar.tsx (SIDEBAR_COOKIE_NAME sidebar_state — collapse persistence)" ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "initial — sidebar collapse/icon-mode, nav pin+reorder, favorites, recents rail, header breadcrumbs, each with its real persistence layer (cookie/localStorage/UserDataAdapter)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-10-09", "change": "end users no longer reorder the runtime sidebar's menu. Maintainer ruling (#22487): 「右侧浏览器,左侧菜单在用户界面不应该显示拖动按钮,拖动设计是studio的工作。」 Scope note: personal ordering survives only in the Pinned section. Landed in objectui 8a55f0ccda (objectui PR #12066, objectui#12059), and every new text cites that code. Step 3 and acceptance[2], which drag-reordered the menu (useNavOrder, localStorage objectui-nav-order-), are REPLACED, not only removed: they now judge the Pinned section's order (desktop only, since a phone draws no Pinned section), dragged by the row or reordered by keyboard (Space, arrows, Space), and persisting across reload as the order of the nav favorites in the UserDataAdapter-backed favorites list. A new step and acceptance[6] judge the negative: no grip, sortable role, drag instructions or grab cursor on the authored menu, and the authored order whatever objectui-nav-order key is stored. Title, fixtures.requires, the persistence step (now 'collapse is local'), the reload negative and source are re-pointed to the landed code. Traps gain stale-console-bundle (the console pin f0268ad784 predates 8a55f0ccda, so a console built at it still draws the menu grip) and automation-input (a one-jump synthetic pointer move never crosses dnd-kit's 5px activation distance and reads as a dead drag)", "ref": "#22487" } + { "revision": 2, "date": "2026-10-09", "change": "end users no longer reorder the runtime sidebar's menu. Maintainer ruling (#22487): 「右侧浏览器,左侧菜单在用户界面不应该显示拖动按钮,拖动设计是studio的工作。」 Scope note: personal ordering survives only in the Pinned section. Landed in objectui 8a55f0ccda (objectui PR #12066, objectui#12059), and every new text cites that code. Step 3 and acceptance[2], which drag-reordered the menu (useNavOrder, localStorage objectui-nav-order-), are REPLACED, not only removed: they now judge the Pinned section's order (desktop only, since a phone draws no Pinned section), dragged by the row or reordered by keyboard (Space, arrows, Space), and persisting across reload as the order of the nav favorites in the UserDataAdapter-backed favorites list. A new step and acceptance[6] judge the negative: no grip, sortable role, drag instructions or grab cursor on the authored menu, and the authored order whatever objectui-nav-order key is stored. Title, fixtures.requires, the persistence step (now 'collapse is local'), the reload negative and source are re-pointed to the landed code. Traps gain stale-console-bundle (the console pin f0268ad784 predates 8a55f0ccda, so a console built at it still draws the menu grip) and automation-input (a one-jump synthetic pointer move never crosses dnd-kit's 5px activation distance and reads as a dead drag)", "ref": "#22487" }, + { + "revision": 3, + "date": "2026-10-16", + "change": "stale clause re-pointed (#23950, from QA run #23939): acceptance[5] pinned app/section/record breadcrumbs on a RECORD route, each crumb navigable, but objectui#12461 removed every crumb after the app pill on an object route (its list, a view, a record): the page header is the one place that page is named, so the record route drew none and the clause went red against a correct console. Re-pointed to the header contract the console pin (daeb77fc) ships, per objectui packages/app-shell/src/layout/AppHeader.tsx: a report route draws one crumb, the report's label (no type crumb, objectui#12273); a system/* route draws System then its section; an object route draws none (objectui#12461; a dashboard or page route none either, objectui#12526); the crumbs are plain text, so 'each crumb navigates' is withdrawn by that contract, not relaxed; and every route's crumbs rebuild on reload. The clause still fails if a crumb comes back on an object route, if a report or system route loses its crumb, or if a type crumb returns. Step 6, the title, the inert-crumb negative (a non-link crumb is now the design) and the AppHeader source line follow. The pin moved to f8267df9 while this revision was open (#23973); AppHeader there draws the same crumbs after the chip, so the clause is unchanged by it", + "ref": "#23950" + } ] }, { @@ -1566,10 +1572,10 @@ }, { "id": "platform-core.activation-ledger-row-contract", - "title": "sys_metadata_activation row contract: reads open / generic-data-API writes 405, one row per (metadata_type, name) with NO tenant column on the table at all, re-enable updates never deletes, and rows exist ONLY for metadata_type flow|action (the ADR-0126 scope wall)", + "title": "sys_metadata_activation row contract: reads open / generic-data-API writes 405, one row per (metadata_type, name) with NO tenant column on the table at all, re-enable updates never deletes, and rows exist ONLY for the metadata types a shipped door writes — flow, action, position, permission (the ADR-0126 scope wall, as ADR-0131 D3 widens it)", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -1577,19 +1583,19 @@ "app": "showcase", "requires": [ "an isolated boot on a file DB (-d file:/tmp//data.db) WITH direct sqlite access to that file between boots: the object declares apiMethods ['get','list'], so REST can never stage the org-row and duplicate-row probes — same scratch-DB class of fixture platform-core.interrupted-migration-boot-report uses. On a stock boot no 'cloud' datasource is registered, so the table rides the global default driver and lives in the run's own file DB", - "the two shipped switches as row producers: the flow toggle (POST /api/v1/automation/showcase_task_completed/toggle) and the action activation door (POST /api/v1/actions/_activation/showcase_task/showcase_mark_done). Their own door contracts belong to automation.packaged-flow-disable-durable and api-backend.packaged-action-disabled-dispatch — this item owns the ROWS they leave behind" + "the shipped switches as row producers: the flow toggle (POST /api/v1/automation/showcase_task_completed/toggle), the action activation door (POST /api/v1/actions/_activation/showcase_task/showcase_mark_done), and the security catalog activation door POST /api/v1/security/_activation/:type/:name for :type position and permission (packages/runtime/src/domains/catalog-activation.ts — e.g. position legal and permission set showcase_auditor; ⛔ never admin_full_access, which the last-admin guard refuses to switch off). Their own door contracts belong to automation.packaged-flow-disable-durable, api-backend.packaged-action-disabled-dispatch and access-security.security-catalog-activation-and-clone-doors — this item owns the ROWS they leave behind" ], "knownGaps": [ - "metadata_type is an untyped Field.text (maxLength 100) with exactly two string-literal writers in the tree — 'flow' (packages/services/service-automation/src/flow-activation-store.ts) and 'action' (packages/objectql/src/action-activation.ts). There is NO spec enum to pin: the variants below are enumerated BY HAND and deliberately carry no enumSource, so a third writer (ADR-0126 §8 pre-charts tool/skill/position) lands without failing any ratchet. Re-derive the writer list (grep for METADATA_TYPE literals) before scoring the scope wall, and bump this item's variants when a third writer ships", + "metadata_type is an untyped Field.text (maxLength 100) with three writer sites and four values in the tree — 'flow' (packages/services/service-automation/src/flow-activation-store.ts), 'action' (packages/objectql/src/action-activation.ts), and 'position' / 'permission' (packages/runtime/src/domains/catalog-activation.ts, which constructs the shared store with the path's :type and closes that :type to exactly those two). There is NO spec enum to pin: the variants below are enumerated BY HAND and deliberately carry no enumSource, so a further writer (ADR-0126 §8 pre-charts tool/skill) lands without failing any ratchet. Re-derive the writer list (grep the METADATA_TYPE literals and every `new ObjectStoreMetadataActivationStore(` call site) before scoring the scope wall, and bump this item's variants when a new writer ships — revision 3 is that bump for the catalog door", "the tenant-column leg is a SCHEMA probe, not a row probe: there is no organization_id column left to hand-seed (systemFields { tenant: false } — ADR-0131 D7 withdrew ADR-0126 D3's reserved-and-never-written column before it ever shipped), so it is staged by reading the physical column list and by an INSERT that NAMES the column and is refused. Its anti-vacuity control INSERT does land a row — that row is the run's own artifact and must be deleted before the restart (or the DB discarded)" ] }, "steps": [ - "boot isolated on the file DB; as admin flip BOTH shipped switches off (flow toggle showcase_task_completed, action flip showcase_mark_done); capture both 200s", + "boot isolated on the file DB; as admin flip EVERY shipped switch off — the flow toggle (showcase_task_completed), the action flip (showcase_mark_done), and the catalog door for one position and one permission set (POST /api/v1/security/_activation/position/legal and /permission/showcase_auditor, body {\"enabled\": false}); capture the four 200s", "read the ledger through the open read path: GET /api/v1/data/sys_metadata_activation — capture every row; each carries the four declared ledger columns (metadata_type, name, package_id, active) beside the driver's id and the injected audit family, and NO tenant column: organization_id is ABSENT from the row's key set, never present-and-NULL on it. ⛔ Assert the KEY SET, never a value read — a value assertion of the shape `row.organization_id ?? null` is `null` for a column that does not exist, so it passes while measuring nothing (the dogfood pin was inverted into a key-set assertion for exactly this reason)", - "scope wall sweep: the distinct metadata_type set over ALL rows is exactly {'flow','action'} — any other value (hook/permission/position/capability/…) is a FAIL of the model, not a coverage discovery: hooks are code-only by ADR-0126 §3 amendment ruling 1 (no ledger row, no disable switch), and packaged permission sets ride their kind's own `active` field per the 2026-08-26 #12159 ruling (cross-ref access-security.packaged-permission-set-lifecycle)", + "scope wall sweep: the distinct metadata_type set over ALL rows is exactly {'flow','action','position','permission'} — the four values the shipped doors write. Any other value (hook/capability/tool/skill/…) is a FAIL of the model, not a coverage discovery: hooks are code-only by ADR-0126 §3 amendment ruling 1 (no ledger row, no disable switch), and a capability is code-only on the regime with no switch (catalog-activation.ts CATALOG_ACTIVATION_SUBJECTS). Then probe the door's own wall: POST /api/v1/security/_activation/capability/showcase.export_data {\"enabled\": false} answers 400 naming the two types it switches, and POST /api/v1/security/_activation/position/everyone {\"enabled\": false} answers 400 (an audience anchor) — re-read the ledger: neither left a row. Position and permission rows are no longer foreign here: ADR-0131 D3 moved the on/off switch of a position and of a permission set off the catalog row into this ledger (regime C as ADR-0131 D6 amends ADR-0126 §3; readDisabledCatalogNames in packages/core/src/security/resolve-authz-context.ts reads these two types and never the row's active column), which supersedes the 2026-08-26 #12159 ruling that a packaged permission set rides its kind's own active field", "write-refusal probes, all three verbs: POST /api/v1/data/sys_metadata_activation {metadata_type:'action',name:'qa_probe',package_id:'qa',active:false}, PATCH /api/v1/data/sys_metadata_activation/ {active:true}, DELETE /api/v1/data/sys_metadata_activation/ — each answers 405 naming the refused operation and the allowed set; re-read confirms every row unchanged", - "re-enable both switches — the SAME rows update to active true (same ids, row count unchanged, nothing vanished)", + "re-enable every switch flipped in step 1 — the SAME rows update to active true (same ids, row count unchanged, nothing vanished)", "uniqueness probe (direct DB): stop the server; in the sqlite file INSERT a second row for an existing (metadata_type, name) pair — the declared unique:'global' index over exactly those two columns refuses with a UNIQUE constraint error; capture it. Both key parts are required text, so no NULL arm is left to collapse: the earlier unique:'organization' spelling asked the driver to prepend the tenant column in its COALESCE'd form (ADR-0120 D3) against the #5030 NULL-distinct hole, and with the column gone normalizeDeclaredIndex resolves tenantField to null — so the materialized DDL is byte-identical either way and the current spelling is a re-SPELLING, not a change of shape (sys-metadata-activation.object.ts records it)", "no-tenant-column probe (direct DB, server still stopped): read the physical schema — PRAGMA table_info(sys_metadata_activation) — and record the column list: metadata_type, name, package_id and active are present beside the driver's id and the injected audit family (created_at / created_by / updated_at / updated_by), and NOTHING tenant-shaped is — organization_id is absent from the list entirely. Then attempt INSERT INTO sys_metadata_activation (id, metadata_type, name, package_id, active, organization_id) VALUES ('qa_probe_no_tenant','action','qa_probe_no_tenant','qa',0,'org_qa') — sqlite refuses with `table sys_metadata_activation has no column named organization_id` and no row lands. CONTROL (anti-vacuity, same DB): the SAME statement with the organization_id column and its value dropped SUCCEEDS — so the refusal names the missing column, not a malformed statement and not the unique index (the (metadata_type, name) pair is fresh on purpose, so step 6's index cannot be what answered). DELETE the control row, then restart against the SAME DB and confirm the ledger reads exactly as it did before the probe", "record the operability posture: absence of a row reads as ACTIVE (an empty ledger is the normal stock state, not an error), and the data-API read IS the only 'what is off here' surface today — ObjectQLEngine.listDisabledActions() (packages/objectql/src/engine.ts) has zero route consumers" @@ -1614,16 +1620,16 @@ "evidence": "the PRAGMA column list + the refused INSERT with sqlite's message + the successful control INSERT" }, { - "clause": "re-enable UPDATES the row, never deletes it: after flipping both switches back on, the same rows persist with active true — the ledger records the administrator's CHOICE instead of erasing it (ADR-0126 §6 wall 3), and the store's engine slice deliberately has no delete", + "clause": "re-enable UPDATES the row, never deletes it: after flipping every switch back on, the same rows persist with active true — the ledger records the administrator's CHOICE instead of erasing it (ADR-0126 §6 wall 3), and the store's engine slice deliberately has no delete", "oracle": "api", "verify": "row ids and count identical before/after step 5, active flipped to true/1 (read-then-write setActive, metadata-activation-store.ts; the delete-less MetadataActivationStoreEngine)", "evidence": "the before/after row listings keyed by id" }, { - "clause": "the scope wall: after exercising every shipped switch, rows exist ONLY for metadata_type ∈ {'flow','action'} — hand-enumerated, because no pinnable enum exists (the column is untyped Field.text; the two values are string literals at their writers). A hook/permission/position/capability row is a FAIL of the model (ADR-0126 §3: hook reclassified code-only by amendment ruling 1; permission rides its own active field, #12159 ruling)", + "clause": "the scope wall: after exercising every shipped switch, rows exist ONLY for metadata_type ∈ {'flow','action','position','permission'} — hand-enumerated, because no pinnable enum exists (the column is untyped Field.text; the values are string literals at the flow and action stores and the catalog door's closed :type set). Position and permission rows belong here since ADR-0131 D3 moved those two kinds' on/off switch off the catalog row into this ledger (written by POST /api/v1/security/_activation/:type/:name, read by readDisabledCatalogNames), superseding the #12159 'permission rides its own active field' ruling. A hook/capability (or any other) row is a FAIL of the model (ADR-0126 §3: hook reclassified code-only by amendment ruling 1; a capability is code-only with no switch), and the catalog door refuses every other :type, and the audience anchors, with a 400 that writes nothing", "oracle": "api", - "verify": "distinct metadata_type over the full table equals exactly the two-value set; re-derive the writer list by grepping METADATA_TYPE literals first, per knownGaps — a third LEGITIMATE writer means this item needs a revision, a third value from stock switches without one means the wall fell", - "evidence": "the distinct-set query result + the writer-grep excerpt" + "verify": "distinct metadata_type over the full table equals exactly the four-value set; the capability-type and audience-anchor door probes each answer 400 and the ledger's row count is unchanged after them; re-derive the writer list (the METADATA_TYPE literals and the `new ObjectStoreMetadataActivationStore(` call sites) first, per knownGaps — a new LEGITIMATE writer means this item needs a revision, a value outside the four from stock switches without one means the wall fell", + "evidence": "the distinct-set query result + the two door refusals with the unchanged row count + the writer-grep excerpt" }, { "clause": "durability posture: the object declares NO lifecycle block on purpose — a row is durable configuration, never telemetry, and rows survive a restart byte-identical (a retention policy here would be a data-loss bug that silently re-arms disabled artifacts); absence of a row reads as ACTIVE", @@ -1637,11 +1643,14 @@ "an organization_id column PRESENT on the physical table is the regression step 7 exists for, and it is WORSE than the shape it replaced: the store no longer filters or skips anything on read (there is no column left to filter on), so a resurrected tenant column would let an org-carrying row be merged straight into the deployment-wide answer — one organization's choice applied to the whole installation, #10243 from the read side. A P1-severity finding inside a P2 item, extract it", "a row deleted by re-enable is a FAIL of §6 wall 3 even though every door answered 200 — the wire looks identical either way, only the row read tells them apart", "two contradictory active rows accepted for one artifact is a FAIL of the declared unique:'global' index — and ⛔ do not diagnose it as the #5030 NULL-distinct hole: that hole needed a nullable tenant column inside the key, and this table has none. Both key parts are required text, so a duplicate accepted here means the index is missing or was never materialized, not that a NULL slipped through", - "treating this item's variants as a pinned enum is a mis-read: they are hand-enumerated and un-pinned by construction — a stale two-value list over a tree that grew a third writer is a checklist defect, not a platform one; revise the item" + "treating this item's variants as a pinned enum is a mis-read: they are hand-enumerated and un-pinned by construction — a stale value list over a tree that grew a new writer is a checklist defect, not a platform one; revise the item", + "a catalog-door write for a :type other than position or permission (capability, hook, …), or for an audience anchor (everyone, guest), that answers 2xx or leaves a ledger row is a FAIL of the door's closed :type set — it would switch off something the regime gives no switch" ], "variants": [ "metadata_type 'flow' — written by ObjectStoreFlowActivationStore (packages/services/service-automation/src/flow-activation-store.ts)", - "metadata_type 'action' — written by ObjectStoreActionActivationStore (packages/objectql/src/action-activation.ts)" + "metadata_type 'action' — written by ObjectStoreActionActivationStore (packages/objectql/src/action-activation.ts)", + "metadata_type 'position' — written by the security catalog activation door (packages/runtime/src/domains/catalog-activation.ts, the shared ObjectStoreMetadataActivationStore constructed with the path's :type), read by readDisabledCatalogNames", + "metadata_type 'permission' — the same door, for a permission set (ADR-0131 D3)" ], "traps": ["destructive-in-place", "absence-inference"], "automated": { @@ -1654,11 +1663,13 @@ "packages/core/src/utils/metadata-activation-store.ts#setActive — read-then-write setActive keyed on exactly the index's two columns (so taking the first match is taking the only one); organization_id never in the write payload; delete-less engine slice; 0-reads-as-false", "packages/core/src/utils/metadata-activation-store.ts#list — list() is the whole read path: scoped by metadata_type and by nothing else, because the ledger is deployment-wide and has no second axis. ⚠️ There is no org-row skip here any more — the module header records that the NULL-organization filter and the org-row skip both went with the column", "packages/runtime/src/api-exposure.ts#checkApiExposure (checkApiExposure — the 405 shape for a whitelisted-methods object)", - "packages/services/service-automation/src/flow-activation-store.ts + packages/objectql/src/action-activation.ts (the two metadata_type string literals — the whole live vocabulary)", + "packages/services/service-automation/src/flow-activation-store.ts + packages/objectql/src/action-activation.ts (the flow and action metadata_type string literals)", + "packages/runtime/src/domains/catalog-activation.ts#handleCatalogActivationWrite (POST /security/_activation/:type/:name — the position and permission writer; :type closed to those two, audience anchors refused, ADR-0126 §3 regime C as ADR-0131 D6 amends it)", + "packages/core/src/security/resolve-authz-context.ts#readDisabledCatalogNames (ADR-0131 D3 — the ledger's position and permission rows are the catalog's on/off switch; the catalog row's active column is not read)", "packages/objectql/src/engine.ts#listDisabledActions (listDisabledActions — zero route consumers; the data-API read is the only operability surface)", "docs/adr/0126-packaged-metadata-customization-model.md §3 (scope wall + amendment ruling 1), §4 (row contract), §6 wall 3 (record the choice, never erase it) — ⚠️ §5 D3's 'the org column is reserved' bullet is WITHDRAWN, not merely unimplemented; §5's operator-gate half stands and is owned by access-security.activation-write-operator-gate", "docs/adr/0131-total-organization-ownership-no-null-organization-id.md D7 (deployment-level state has no organization column — sys_metadata_activation is named there as reverted before 17.3 and not returning). This is what makes 'the ledger has no tenant column' a load-bearing property worth probing, rather than an obsolete assertion to retire alongside the behaviour it tested", - "#12438 (the scoped sweep), #12419 (registration home — the sibling item), #12159 (the permission-rides-its-own-field ruling, 2026-08-26), #15024 (the column drop this item is re-grounded against), #15154 (the re-grounding)" + "#12438 (the scoped sweep), #12419 (registration home — the sibling item), #12159 (the permission-rides-its-own-field ruling, 2026-08-26 — SUPERSEDED by ADR-0131 D3, which moved the switch into this ledger), #15024 (the column drop this item is re-grounded against), #15154 (the re-grounding)" ], "history": [ { @@ -1672,6 +1683,12 @@ "date": "2026-09-04", "change": "re-grounded against the tenantless table (#15024): sys_metadata_activation dropped its reserved organization_id column before it ever shipped, so three legs were describing a column that is not there. Step 2 now asserts organization_id is ABSENT from the row key set rather than present-and-NULL — the value spelling passes VACUOUSLY once the column is gone (`row.organization_id ?? null` is `null` either way), which is why the dogfood pin was inverted into a key-set assertion rather than carried forward. Step 6 keeps the uniqueness probe and corrects only its stated mechanism: a plain unique:'global' over the two required key parts, with ADR-0120 D3's COALESCE collapse recorded as history that is no longer reachable. Step 7 could not be corrected — it INSERTed a row into a column that no longer exists, so the leg was unrunnable rather than merely mis-described — and is REPLACED, not retired: the org-skip clause becomes a no-tenant-column probe (PRAGMA table_info + an INSERT naming the column, refused, bracketed by a control INSERT that lands without it). Replaced rather than retired because ADR-0131 D7 makes 'this ledger has no organization column' a load-bearing platform property, so dropping the leg would lose coverage instead of retiring an obsolete assertion. The negative entries follow the same correction: a resurrected column is now WORSE than the shape it replaced, because the store no longer filters or skips on read at all", "ref": "#15154" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "stale clause re-pointed (#23950, from QA run #23939): acceptance[4] held the scope wall at {flow, action} and named a position or permission row a FAIL of the model, on the 2026-08-26 #12159 ruling that a permission set rides its own active field. ADR-0131 D3 superseded that: the on/off switch of a position and of a permission set moved off the catalog row into this ledger, read by readDisabledCatalogNames, and the shipped door POST /api/v1/security/_activation/:type/:name (packages/runtime/src/domains/catalog-activation.ts, ADR-0126 §3 regime C as ADR-0131 D6 amends it) writes those rows — so a stock switch wrote a legitimate 'position' row and the clause went red against a correct product. The item's own knownGap asked for exactly this revision when a third writer shipped. The wall is now the four values the shipped doors write, still hand-enumerated, and it gains a door-side leg: a :type outside position/permission (capability) and an audience anchor (everyone) are refused 400 with no row, so the clause still fails if a fifth type or an anchor gets a row. Step 1 flips all four switches and step 5 re-enables them (acceptance[3] follows); the variants, the knownGap's writer list, the title, a negative and the sources follow, and the #12159 citation is marked superseded", + "ref": "#23950" } ] },